cjm-transcript-decomp-core 0.0.4__tar.gz → 0.0.5__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 (25) hide show
  1. {cjm_transcript_decomp_core-0.0.4/cjm_transcript_decomp_core.egg-info → cjm_transcript_decomp_core-0.0.5}/PKG-INFO +7 -3
  2. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/README.md +5 -1
  3. cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core/__init__.py +1 -0
  4. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/alignment.py +95 -4
  5. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/cli.py +33 -1
  6. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/graph.py +45 -15
  7. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/models.py +9 -1
  8. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/pipeline.py +136 -27
  9. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info}/PKG-INFO +7 -3
  10. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/requires.txt +1 -1
  11. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/pyproject.toml +1 -1
  12. cjm_transcript_decomp_core-0.0.5/tests/test_alignment.py +194 -0
  13. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_graph.py +17 -0
  14. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_models.py +3 -1
  15. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_pipeline.py +51 -0
  16. cjm_transcript_decomp_core-0.0.4/cjm_transcript_decomp_core/__init__.py +0 -1
  17. cjm_transcript_decomp_core-0.0.4/tests/test_alignment.py +0 -45
  18. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/LICENSE +0 -0
  19. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/MANIFEST.in +0 -0
  20. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/SOURCES.txt +0 -0
  21. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/dependency_links.txt +0 -0
  22. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/entry_points.txt +0 -0
  23. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/top_level.txt +0 -0
  24. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/setup.cfg +0 -0
  25. {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-decomp-core
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: A frontend-agnostic core for the transcript decomposition workflow — composes isolated capability workers (forced alignment, VAD, graph storage) into a headless pipeline that decomposes transcription run manifests into a VAD-aligned context-graph spine with traceable provenance, and a CLI as its first driver.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -12,7 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: cjm_substrate>=0.0.57
14
14
  Requires-Dist: cjm_context_graph_primitives>=0.0.15
15
- Requires-Dist: cjm_capability_primitives>=0.0.10
15
+ Requires-Dist: cjm_capability_primitives>=0.0.11
16
16
  Requires-Dist: cjm-context-graph-layer>=0.0.11
17
17
  Requires-Dist: cjm-transcript-graph-schema>=0.0.7
18
18
  Dynamic: license-file
@@ -38,6 +38,8 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
38
38
  - `assign_words_to_chunks` _function_ — Assign each FA word to a VAD chunk by timestamp overlap.
39
39
  - `build_segments_from_alignment` _function_ — Build a TextSegment per VAD chunk by grouping words by chunk assignment.
40
40
  - `map_fa_words_to_text` _function_ — Map forced-alignment words back to character spans in the original text.
41
+ - `sentence_end_word_indices` _function_ — Map capability-delivered sentence boundaries onto FA words (B.5: the
42
+ - `split_chunks_at_sentence_gaps` _function_ — The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
41
43
  - `tier1_alignment_checks` _function_ — Tier-1 deterministic pre-filters for the alignment-review seam (no AI).
42
44
 
43
45
  ### `cjm_transcript_decomp_core.cli`
@@ -68,14 +70,16 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
68
70
 
69
71
  ### `cjm_transcript_decomp_core.pipeline`
70
72
 
71
- - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
73
+ - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
72
74
  - `collect_capability_info` _function_ — Record capability identity + data-DB pointers for the run manifest (provenance).
75
+ - `compute_skeleton_hash` _function_ — Skeleton identity, pure (DEC f1024568 + 9241564f).
73
76
  - `confirm_seam` _function_ — HITL approval seam in its cheapest viable form (log + optional CLI prompt).
74
77
  - `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
75
78
  - `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
76
79
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
77
80
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
78
81
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
82
+ - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
79
83
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
80
84
  - `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
81
85
 
@@ -19,6 +19,8 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
19
19
  - `assign_words_to_chunks` _function_ — Assign each FA word to a VAD chunk by timestamp overlap.
20
20
  - `build_segments_from_alignment` _function_ — Build a TextSegment per VAD chunk by grouping words by chunk assignment.
21
21
  - `map_fa_words_to_text` _function_ — Map forced-alignment words back to character spans in the original text.
22
+ - `sentence_end_word_indices` _function_ — Map capability-delivered sentence boundaries onto FA words (B.5: the
23
+ - `split_chunks_at_sentence_gaps` _function_ — The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
22
24
  - `tier1_alignment_checks` _function_ — Tier-1 deterministic pre-filters for the alignment-review seam (no AI).
23
25
 
24
26
  ### `cjm_transcript_decomp_core.cli`
@@ -49,14 +51,16 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
49
51
 
50
52
  ### `cjm_transcript_decomp_core.pipeline`
51
53
 
52
- - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
54
+ - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
53
55
  - `collect_capability_info` _function_ — Record capability identity + data-DB pointers for the run manifest (provenance).
56
+ - `compute_skeleton_hash` _function_ — Skeleton identity, pure (DEC f1024568 + 9241564f).
54
57
  - `confirm_seam` _function_ — HITL approval seam in its cheapest viable form (log + optional CLI prompt).
55
58
  - `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
56
59
  - `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
57
60
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
58
61
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
59
62
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
63
+ - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
60
64
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
61
65
  - `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
62
66
 
@@ -0,0 +1 @@
1
+ __version__ = "0.0.5"
@@ -1,13 +1,23 @@
1
1
  """Pure forced-alignment logic (no capability calls): map FA words back to character spans in the original text, assign words to VAD chunks by timestamp, and build one text segment per VAD chunk. Extracted from the page-centric ForcedAlignmentService (Tier-1 logic)."""
2
2
 
3
3
  import re
4
- from typing import Dict, List, Optional, Tuple
4
+ from typing import Dict, List, Optional, Set, Tuple
5
5
 
6
6
  from cjm_transcript_decomp_core.models import FAWord, TextSegment, VADChunk
7
7
 
8
8
  # Strip punctuation for comparison (matches what FA models strip).
9
9
  _PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
10
10
 
11
+ # Sentence-split policy tag (DEC f1024568): versioned because the policy is a
12
+ # SKELETON IDENTITY input — any change to the split rule must bump it, or
13
+ # re-runs would silently collide with spines a different rule produced.
14
+ # 'capability' (B.5, DEC cc904eee): sentence boundaries now come from a
15
+ # sentence-segmentation CAPABILITY (pySBD first) as char spans over the
16
+ # authoritative text — the v1-v3 token heuristic (closer/abbreviation/stub
17
+ # lists) is RETIRED. The segmenter's identity (capability name + config hash)
18
+ # joins the skeleton-identity composite beside this tag.
19
+ SENTENCE_SPLIT_POLICY = "sentence-split/capability"
20
+
11
21
 
12
22
  def _strip_punct(
13
23
  text: str, # Text to normalize
@@ -89,8 +99,13 @@ def assign_words_to_chunks(
89
99
  ) -> List[int]: # Chunk index for each FA word
90
100
  """Assign each FA word to a VAD chunk by timestamp overlap.
91
101
 
92
- Words whose start_time falls within a chunk's [start, end] are assigned to
93
- that chunk; words in silence gaps go to the nearest chunk by time proximity.
102
+ Words whose start_time falls within a chunk's [start, end) are assigned to
103
+ that chunk — HALF-OPEN, so a word starting exactly on a shared boundary
104
+ belongs to the chunk that STARTS there (sentence-split cuts land exactly on
105
+ the next word's start when FA words are contiguous; the old inclusive end
106
+ pulled that word one chunk LEFT — the off-by-one the 2026-07-22 probe drive
107
+ caught). Words in silence gaps (incl. exactly at the last chunk's end) go
108
+ to the nearest chunk by time proximity.
94
109
  """
95
110
  if not vad_chunks:
96
111
  return [0] * len(fa_items)
@@ -101,7 +116,7 @@ def assign_words_to_chunks(
101
116
  best_idx = 0
102
117
  best_dist = float("inf")
103
118
  for i, chunk in enumerate(vad_chunks):
104
- if chunk.start_time <= t <= chunk.end_time:
119
+ if chunk.start_time <= t < chunk.end_time:
105
120
  best_idx = i
106
121
  break
107
122
  dist = min(abs(t - chunk.start_time), abs(t - chunk.end_time))
@@ -164,3 +179,79 @@ def tier1_alignment_checks(
164
179
  f"{empty}/{len(segments)} segment(s) have EMPTY text (VAD chunk with no aligned words)"
165
180
  )
166
181
  return warnings
182
+
183
+
184
+ def sentence_end_word_indices(
185
+ spans: List[Tuple[int, int]], # FA-word char spans from map_fa_words_to_text (ordered)
186
+ sentence_spans: List[Tuple[int, int]], # Sentence char spans over the SAME text (ordered, non-overlapping)
187
+ ) -> Set[int]: # Indices of FA words that END a sentence
188
+ """Map capability-delivered sentence boundaries onto FA words (B.5: the
189
+ successor of the v1-v3 `_ends_sentence` token heuristic).
190
+
191
+ A word ends a sentence when it is the LAST word whose span STARTS before
192
+ that sentence's end_char. Both lists are ordered over the same text, so a
193
+ single forward merge walk suffices; a sentence no word starts in
194
+ contributes nothing, and the trailing word of the text marks its sentence
195
+ but can never produce a cut (cuts live BETWEEN consecutive words)."""
196
+ out: Set[int] = set()
197
+ wi = 0
198
+ for ss, se in sentence_spans:
199
+ last = None
200
+ while wi < len(spans) and spans[wi][0] < se:
201
+ if spans[wi][0] >= ss: # the word must START inside the sentence
202
+ last = wi
203
+ wi += 1
204
+ if last is not None:
205
+ out.add(last)
206
+ return out
207
+
208
+
209
+ def split_chunks_at_sentence_gaps(
210
+ vad_chunks: List[VADChunk], # The VAD skeleton (segment-local times)
211
+ fa_items: List[FAWord], # The AUTHORITATIVE transcriber's FA words (segment-local times)
212
+ end_words: Set[int], # FA-word indices that END a sentence (sentence_end_word_indices)
213
+ min_chunk_s: float = 0.5, # Min sub-chunk duration — a split never mints a sliver
214
+ ) -> List[VADChunk]: # The refined skeleton, re-indexed (identical content when nothing splits)
215
+ """The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
216
+ VAD skeleton by cutting any chunk whose assigned text crosses a sentence end.
217
+
218
+ Runs POST-FA, PRE-fold: a chunk holding a sentence-ending word that is not
219
+ its last word splits at the corresponding FA word gap (midpoint between the
220
+ ending word's end and the next word's start — the pause the VAD's min-sil
221
+ threshold failed to cut, finding bc69e3e6). Sentence ends arrive
222
+ PRECOMPUTED (B.5): the segmentation capability's char spans over the
223
+ authoritative text, mapped onto FA words by `sentence_end_word_indices` —
224
+ this function no longer inspects text. The split decision reads ONLY the
225
+ authoritative transcriber (montage/textless chunks have no words here and
226
+ pass through untouched); every transcriber then re-folds over the refined
227
+ skeleton, so variants stay per-chunk consistent by construction. Both
228
+ sides of an accepted cut must be >= `min_chunk_s` at accept time (greedy
229
+ left-to-right), so FA jitter cannot mint unplayable slivers.
230
+ """
231
+ assignments = assign_words_to_chunks(fa_items, vad_chunks)
232
+ by_chunk: Dict[int, List[int]] = {}
233
+ for wi, ci in enumerate(assignments):
234
+ by_chunk.setdefault(ci, []).append(wi)
235
+
236
+ refined: List[VADChunk] = []
237
+ for chunk in vad_chunks:
238
+ words = by_chunk.get(chunk.index, [])
239
+ cuts: List[float] = []
240
+ cur_start = chunk.start_time
241
+ for p in range(len(words) - 1):
242
+ wi, wj = words[p], words[p + 1]
243
+ if wi not in end_words:
244
+ continue
245
+ cut = (fa_items[wi].end_time + fa_items[wj].start_time) / 2.0
246
+ if not (chunk.start_time < cut < chunk.end_time):
247
+ continue
248
+ if cut - cur_start < min_chunk_s or chunk.end_time - cut < min_chunk_s:
249
+ continue
250
+ cuts.append(cut)
251
+ cur_start = cut
252
+ bounds = [chunk.start_time] + cuts + [chunk.end_time]
253
+ for k in range(len(bounds) - 1):
254
+ refined.append(VADChunk(index=0, start_time=bounds[k], end_time=bounds[k + 1]))
255
+ for i, c in enumerate(refined):
256
+ c.index = i
257
+ return refined
@@ -59,6 +59,21 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
59
59
  "default: the manifest's sole transcriber)")
60
60
  run.add_argument("--sysmon-capability", default=None, help="monitor capability for GPU subtree attribution (CR-7); loaded first; default: no monitor")
61
61
  run.add_argument("--language", default="English", help="Forced-alignment language")
62
+ run.add_argument("--sentence-split", action=argparse.BooleanOptionalAction, default=True,
63
+ help="Run the post-FA sentence-split stage (DEC f1024568): chunks whose "
64
+ "text crosses a sentence end split at the FA word gap; commits a "
65
+ "PARALLEL spine (new skeleton hash) — existing spines are untouched. "
66
+ "DEFAULT-ON (DEC 552bde8d); --no-sentence-split opts out")
67
+ run.add_argument("--seg-capability", default="cjm-capability-pysbd",
68
+ help="Sentence-segmentation capability name (B.5; skipped with --no-sentence-split)")
69
+ run.add_argument("--split-min-chunk-s", type=float, default=0.5,
70
+ help="Sentence-split min sub-chunk duration guard, seconds (identity input)")
71
+ run.add_argument("--respine", action="store_true",
72
+ help="Mint a FRESH skeleton spine under the SAME config (DEC 9241564f): "
73
+ "the run id joins the Segment identity input, so the new spine "
74
+ "coexists with — never collides into — the config-identical one. "
75
+ "The recovery for post-upgrade re-runs the verify-collide refuses "
76
+ "(finding e8458f6e); provenance = this run's manifest")
62
77
  run.add_argument("--force", action="store_true", help="Bypass capability-side caches (VAD + FA)")
63
78
  run.add_argument("-y", "--yes", action="store_true", help="Auto-accept HITL seams (headless mode)")
64
79
  run.add_argument("--output", default=None, help="Decomp-manifest output path (single-manifest runs only; default: <output-dir>/<run_id>.json)")
@@ -132,6 +147,10 @@ async def run_command(
132
147
  language=args.language,
133
148
  force=args.force,
134
149
  assume_yes=args.yes,
150
+ sentence_split=args.sentence_split,
151
+ split_min_chunk_s=args.split_min_chunk_s,
152
+ seg_capability=args.seg_capability,
153
+ respine=args.respine,
135
154
  )
136
155
 
137
156
  # CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
@@ -142,12 +161,25 @@ async def run_command(
142
161
  sysmon_capability_name=args.sysmon_capability,
143
162
  )
144
163
  instance_ids = [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]
164
+ if cfg.sentence_split:
165
+ # B.5: the segmentation capability loads only when the split stage runs.
166
+ instance_ids.insert(2, cfg.seg_capability)
145
167
  load_order = ([args.sysmon_capability] if args.sysmon_capability else []) + instance_ids
146
168
  # F10: --graph-db-path threads a caller-wins config into the graph load
147
169
  # (the C8 pattern correction-core already used; scratch-graph loop-backs).
148
170
  configs = ({cfg.graph_capability: {"db_path": args.graph_db_path}}
149
171
  if args.graph_db_path else None)
150
- load_capabilities(manager, load_order, configs=configs)
172
+ try:
173
+ load_capabilities(manager, load_order, configs=configs)
174
+ except Exception as e:
175
+ # Sentence-split is DEFAULT-ON (DEC 552bde8d): an env without the
176
+ # segmentation capability must refuse with both outs, not crash.
177
+ if cfg.sentence_split and cfg.seg_capability in str(e):
178
+ raise SystemExit(
179
+ f"sentence-split is default-on and needs {cfg.seg_capability}: {e}\n"
180
+ f"either install it (cjm-ctl install-all with the capabilities yaml "
181
+ f"adds the env + manifest additively) or pass --no-sentence-split")
182
+ raise
151
183
 
152
184
  queue = JobQueue(deps=manager, sysmon_capability_name=args.sysmon_capability)
153
185
  await queue.start()
@@ -66,9 +66,10 @@ def resolve_root_ids(
66
66
  def build_extension_payload(
67
67
  source_entry: Dict[str, Any], # One source entry from the transcription manifest
68
68
  capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block
69
- vad_config_hash: str, # THIS run's VAD capability config hash (skeleton identity input)
69
+ skeleton_config_hash: str, # THIS run's skeleton-config hash (Segment identity input; = the VAD config hash when no split stage ran)
70
70
  text_from: str, # Authoritative transcriber (layer-0 text designation)
71
71
  segments: List[DecompSegment], # Ordered aligned segments (per-transcriber variants attached)
72
+ split_policy: Optional[str] = None, # Split policy+version that refined the skeleton (node metadata, never identity)
72
73
  ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: # (nodes, edges, ids)
73
74
  """Build the fine-spine EXTENSION payload (pure; no capability calls).
74
75
 
@@ -100,12 +101,12 @@ def build_extension_payload(
100
101
  end_char=var.end_char, text=var.text))
101
102
  used_transcripts.add(tid)
102
103
  node = SegmentNode(
103
- rendition=a["rendition"], vad_config_hash=vad_config_hash,
104
+ rendition=a["rendition"], vad_config_hash=skeleton_config_hash,
104
105
  chunk_start=seg.chunk_start, chunk_end=seg.chunk_end,
105
106
  index=seg.index, start_time=seg.start_time, end_time=seg.end_time,
106
107
  text=seg.text, audio_hash=a["model_input_hash"], source=source_id,
107
108
  text_from=(a["transcripts"].get(text_from) if seg.text.strip() else None),
108
- text_slices=slices,
109
+ split_policy=split_policy, text_slices=slices,
109
110
  )
110
111
  nodes.append(node.to_graph_node())
111
112
  seg_ids.append(node.id)
@@ -153,6 +154,7 @@ async def verify_source(
153
154
  graph_id: str, # Graph-storage capability id
154
155
  source_id: str, # Source node id to verify
155
156
  rendition_ids: List[str], # The AudioRendition ids the run extended (the fine spine is scoped to these)
157
+ segment_ids: Optional[List[str]] = None, # THIS run's committed Segment ids — id-scopes the fine-layer checks (parallel spines); None = all segments under the renditions
156
158
  ) -> Optional[SourceVerification]: # Result, or None if the Source is not found
157
159
  """Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
158
160
 
@@ -204,21 +206,49 @@ async def verify_source(
204
206
  res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
205
207
  return int(res.count or 0)
206
208
 
207
- n_segs = await _ncount()
208
- seg_next = await _ecount(relation_type=SpineRelations.NEXT, source_related=part_of_rend)
209
- seg_part_of = await _ecount(relation_type=SpineRelations.PART_OF, target_ids=rendition_ids)
210
- rend_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_ids=rendition_ids)
211
- missing_start = await _ncount(where=[PropertyPredicate("start_time", "is_null")])
212
- missing_end = await _ncount(where=[PropertyPredicate("end_time", "is_null")])
213
-
214
- # Bounded projection: sources + owning rendition in ONE pass.
215
- sq = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend,
216
- project=["sources", "rendition_id"])
217
- res = await graph_task(queue, graph_id, "query_nodes", query=sq.to_dict())
209
+ proj_rows: List[Dict[str, Any]] = []
210
+ if segment_ids is None:
211
+ n_segs = await _ncount()
212
+ seg_next = await _ecount(relation_type=SpineRelations.NEXT, source_related=part_of_rend)
213
+ seg_part_of = await _ecount(relation_type=SpineRelations.PART_OF, target_ids=rendition_ids)
214
+ rend_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_ids=rendition_ids)
215
+ missing_start = await _ncount(where=[PropertyPredicate("start_time", "is_null")])
216
+ missing_end = await _ncount(where=[PropertyPredicate("end_time", "is_null")])
217
+ # Bounded projection: sources + owning rendition in ONE pass.
218
+ sq = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend,
219
+ project=["sources", "rendition_id"])
220
+ res = await graph_task(queue, graph_id, "query_nodes", query=sq.to_dict())
221
+ proj_rows = list(res.rows or [])
222
+ else:
223
+ # Id-scoped mode (parallel spines, DEC f1024568): every fine-layer
224
+ # aggregate counts ONLY this run's committed segments — a coexisting
225
+ # sibling skeleton under the same renditions (its own NEXT chain +
226
+ # STARTS_WITH anchor) must not fail the structural checks. Batched
227
+ # (bounded reads; the 500-id convention).
228
+ async def _icount(ids, **kw):
229
+ q = NodeQuery(ids=list(ids), label=TranscriptGraphLabels.SEGMENT,
230
+ count=True, **kw)
231
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
232
+ return int(res.count or 0)
233
+
234
+ n_segs = seg_next = seg_part_of = rend_starts = missing_start = missing_end = 0
235
+ for i in range(0, len(segment_ids), 500):
236
+ b = list(segment_ids[i:i + 500])
237
+ n_segs += await _icount(b)
238
+ seg_next += await _ecount(relation_type=SpineRelations.NEXT, source_ids=b)
239
+ seg_part_of += await _ecount(relation_type=SpineRelations.PART_OF, source_ids=b)
240
+ rend_starts += await _ecount(relation_type=SpineRelations.STARTS_WITH, target_ids=b)
241
+ missing_start += await _icount(b, where=[PropertyPredicate("start_time", "is_null")])
242
+ missing_end += await _icount(b, where=[PropertyPredicate("end_time", "is_null")])
243
+ pq = NodeQuery(ids=b, label=TranscriptGraphLabels.SEGMENT,
244
+ project=["sources", "rendition_id"])
245
+ res = await graph_task(queue, graph_id, "query_nodes", query=pq.to_dict())
246
+ proj_rows.extend(res.rows or [])
247
+
218
248
  missing_sources = 0
219
249
  locators = set()
220
250
  owning_renditions = set()
221
- for r in (res.rows or []):
251
+ for r in proj_rows:
222
252
  sources = r.get("sources") or []
223
253
  if not sources:
224
254
  missing_sources += 1
@@ -117,6 +117,10 @@ class DecompConfig:
117
117
  media_type: str = "audio" # Source media type
118
118
  force: bool = False # Bypass capability-side caches (VAD + FA)
119
119
  assume_yes: bool = False # Auto-accept HITL seams (headless mode)
120
+ sentence_split: bool = True # Run the post-FA sentence-split stage (DEC f1024568; parallel spine) — DEFAULT-ON per DEC 552bde8d (hiding splits just hides model failures)
121
+ split_min_chunk_s: float = 0.5 # Sentence-split min sub-chunk duration guard (seconds)
122
+ seg_capability: str = "cjm-capability-pysbd" # Sentence-segmentation capability id (B.5; loaded only when sentence_split)
123
+ respine: bool = False # Mint a FRESH skeleton under the SAME config (DEC 9241564f): the run id joins the identity input, so the new spine never collides with the config-identical one (post-upgrade re-runs, finding e8458f6e)
120
124
 
121
125
  def to_dict(self) -> Dict[str, Any]: # Plain-dict snapshot for the manifest
122
126
  """Serialize to a plain dict."""
@@ -153,9 +157,11 @@ class DecompManifest:
153
157
  source_version: str = "" # Upstream manifest schema version
154
158
  capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) # instance_id -> identity/db_path/config_hash
155
159
  sources: List[DecompSourceRecord] = field(default_factory=list) # Extended sources, input order
160
+ skeleton_config_hash: str = "" # Segment identity input (0.2.2): VAD config hash, or the composite when a split stage ran
161
+ split_policy: Optional[str] = None # Split policy+version that refined this run's skeleton (None = raw VAD)
156
162
 
157
163
  FORMAT: str = field(default="cjm-transcript-decomp-core/run-manifest", repr=False) # Format tag
158
- VERSION: str = field(default="0.2.1", repr=False) # Schema version
164
+ VERSION: str = field(default="0.2.3", repr=False) # Schema version (0.2.3: capability-driven sentence split — seg capability in `capabilities` + segmenter identity in the skeleton composite)
159
165
 
160
166
  def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
161
167
  """Serialize to a plain dict with nested sources."""
@@ -169,6 +175,8 @@ class DecompManifest:
169
175
  "source_format": self.source_format,
170
176
  "source_version": self.source_version,
171
177
  "capabilities": self.capabilities,
178
+ "skeleton_config_hash": self.skeleton_config_hash,
179
+ "split_policy": self.split_policy,
172
180
  "sources": [s.to_dict() for s in self.sources],
173
181
  }
174
182
 
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import Any, Dict, List, Optional, Tuple
9
9
 
10
10
  from cjm_capability_primitives.forced_alignment import ForcedAlignResult
11
+ from cjm_capability_primitives.sentence_segmentation import SentenceSegmentationResult
11
12
  from cjm_capability_primitives.vad import VADResult
12
13
  from cjm_context_graph_layer.declare import Derivation, derivation_to_graph
13
14
  from cjm_context_graph_layer.journal import journal_extend, sidecar_journal_path, wires_handlers
@@ -20,7 +21,10 @@ from cjm_substrate.core.queue import JobQueue, JobStatus
20
21
  from cjm_substrate.core.workspace import resolve_recorded_tree
21
22
  from cjm_transcript_decomp_core.alignment import (assign_words_to_chunks,
22
23
  build_segments_from_alignment,
23
- map_fa_words_to_text, tier1_alignment_checks)
24
+ map_fa_words_to_text, sentence_end_word_indices,
25
+ SENTENCE_SPLIT_POLICY,
26
+ split_chunks_at_sentence_gaps,
27
+ tier1_alignment_checks)
24
28
  from cjm_transcript_decomp_core.graph import (build_extension_payload, resolve_root_ids,
25
29
  verify_source)
26
30
  from cjm_transcript_decomp_core.models import (DecompConfig, DecompManifest, DecompSegment,
@@ -28,12 +32,13 @@ from cjm_transcript_decomp_core.models import (DecompConfig, DecompManifest, Dec
28
32
  SegmentVariant, VADChunk)
29
33
 
30
34
  # Typed wire-kind registration (stage 2): importing the DTO classes is what
31
- # lets the proxy's wire_decode hand this host process TYPED results. Both result
32
- # nouns live in cjm-capability-primitives (Option C / PILLAR 1c — the tool/host
33
- # depend on the data noun, never the adapter machinery). The tuple keeps these
34
- # SIDE-EFFECT imports referenced so the canonical emit cannot prune them.
35
+ # lets the proxy's wire_decode hand this host process TYPED results. All three
36
+ # result nouns live in cjm-capability-primitives (Option C / PILLAR 1c — the
37
+ # tool/host depend on the data noun, never the adapter machinery). The tuple
38
+ # keeps these SIDE-EFFECT imports referenced so the canonical emit cannot
39
+ # prune them.
35
40
 
36
- _REGISTERED_WIRE_KINDS = (VADResult, ForcedAlignResult)
41
+ _REGISTERED_WIRE_KINDS = (VADResult, ForcedAlignResult, SentenceSegmentationResult)
37
42
 
38
43
  logger = logging.getLogger(__name__)
39
44
 
@@ -105,18 +110,26 @@ def build_alignment_composition(
105
110
  fa_id: str, # Forced-alignment capability instance id
106
111
  transcribers: List[str], # Transcriber names to align (manifest `transcripts` keys, stable order)
107
112
  force: bool = False, # Per-call cache-bypass control flag
113
+ seg_id: Optional[str] = None, # Sentence-segmentation capability instance id (B.5; None = no split stage)
114
+ seg_text_from: Optional[str] = None, # Authoritative transcriber whose text the segmenter reads
108
115
  ) -> Tuple[Composition, List[Dict[str, Any]]]: # (composition, per-pseg meta rows)
109
- """Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
116
+ """Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
110
117
 
111
118
  Each pipeline segment contributes one VAD node + one FA node PER TRANSCRIBER
112
119
  with non-empty text — VAD is audio-only (run once; the shared skeleton), FA
113
- maps each transcriber's text onto it. All nodes are independent; the host's
114
- alignment fold fans them in. Pipeline segments where EVERY transcriber's
115
- text is empty are recorded as skipped meta rows and contribute no nodes.
116
-
117
- Stage 8 (Option C): every node rides the TASK CHANNEL now — VAD on
118
- vad/detect_speech, FA on forced_alignment/align — and per-call `force` rides
119
- `control` (the adapters own caching). The native-routed path is gone.
120
+ maps each transcriber's text onto it. With the sentence-split stage on
121
+ (`seg_id` + `seg_text_from`, B.5), each pipeline segment where the
122
+ authoritative transcriber has text also contributes one sentence-
123
+ segmentation node over that text (the text is known from the manifest, so
124
+ segmentation rides the SAME composition as VAD/FA — no second round trip).
125
+ All nodes are independent; the host's alignment fold fans them in. Pipeline
126
+ segments where EVERY transcriber's text is empty are recorded as skipped
127
+ meta rows and contribute no nodes.
128
+
129
+ Stage 8 (Option C): every node rides the TASK CHANNEL — VAD on
130
+ vad/detect_speech, FA on forced_alignment/align, segmentation on
131
+ sentence_segmentation/segment_text — and per-call `force` rides `control`
132
+ (the adapters own caching; the segmentation adapter is cache-free).
120
133
  """
121
134
  nodes: List[CompositionNode] = []
122
135
  metas: List[Dict[str, Any]] = []
@@ -152,8 +165,18 @@ def build_alignment_composition(
152
165
  task_name="forced_alignment", method="align",
153
166
  control={"force": force}))
154
167
  fa_nodes[t] = fa_n
155
- metas.append({"skipped": False, "seg_start": seg_start, "pseg_index": i,
156
- "vad_node": vad_n, "fa_nodes": fa_nodes, "texts": nonempty})
168
+ meta = {"skipped": False, "seg_start": seg_start, "pseg_index": i,
169
+ "vad_node": vad_n, "fa_nodes": fa_nodes, "texts": nonempty}
170
+ if seg_id and seg_text_from in nonempty:
171
+ seg_n = f"seg_{i:04d}"
172
+ # Sentence segmentation via the task channel over the AUTHORITATIVE
173
+ # transcriber's text (the split stage reads only that transcriber).
174
+ nodes.append(CompositionNode(seg_n, seg_id,
175
+ {"text": nonempty[seg_text_from]},
176
+ task_name="sentence_segmentation",
177
+ method="segment_text"))
178
+ meta["seg_node"] = seg_n
179
+ metas.append(meta)
157
180
  return Composition(nodes=nodes), metas
158
181
 
159
182
 
@@ -178,7 +201,9 @@ async def decompose_source(
178
201
  seg_list = list(source.get("segments") or [])
179
202
 
180
203
  comp, metas = build_alignment_composition(
181
- seg_list, cfg.vad_capability, cfg.fa_capability, transcribers, force=cfg.force)
204
+ seg_list, cfg.vad_capability, cfg.fa_capability, transcribers, force=cfg.force,
205
+ seg_id=(cfg.seg_capability if cfg.sentence_split else None),
206
+ seg_text_from=text_from)
182
207
  results: Dict[str, Any] = {}
183
208
  if comp.nodes:
184
209
  comp_id = await queue.submit_composition(comp)
@@ -200,15 +225,37 @@ async def decompose_source(
200
225
  continue
201
226
  vad_chunks = vad_chunks_from_result(results[m["vad_node"]])
202
227
 
228
+ # Normalize each transcriber's FA output once (words + char spans).
229
+ per_t_words: Dict[str, List[FAWord]] = {}
230
+ per_t_spans: Dict[str, List[Tuple[int, int]]] = {}
231
+ for t, fa_n in m["fa_nodes"].items():
232
+ per_t_words[t] = fa_words_from_result(results[fa_n])
233
+ per_t_spans[t] = map_fa_words_to_text(m["texts"][t], per_t_words[t])
234
+
235
+ # Sentence-split stage (DEC f1024568 / B.5 capability form): refine the
236
+ # shared skeleton at the AUTHORITATIVE transcriber's sentence ends BEFORE
237
+ # any fold — every transcriber re-folds over the same refined skeleton,
238
+ # so variants stay per-chunk consistent by construction. Sentence
239
+ # boundaries come from the segmentation capability's char spans over the
240
+ # authoritative text, mapped onto FA words. Chunks the authoritative
241
+ # transcriber has no words for (montage/textless) pass through whole.
242
+ if cfg.sentence_split and text_from in per_t_words and m.get("seg_node"):
243
+ sentence_spans = sentence_spans_from_result(results[m["seg_node"]])
244
+ end_words = sentence_end_word_indices(per_t_spans[text_from], sentence_spans)
245
+ n_before = len(vad_chunks)
246
+ vad_chunks = split_chunks_at_sentence_gaps(
247
+ vad_chunks, per_t_words[text_from], end_words,
248
+ min_chunk_s=cfg.split_min_chunk_s)
249
+ if len(vad_chunks) != n_before:
250
+ logger.info(f"[src {source_index}] pseg @ {seg_start:.1f}s: "
251
+ f"sentence-split {n_before} -> {len(vad_chunks)} chunk(s)")
252
+
203
253
  # Per-transcriber fold over the SHARED skeleton.
204
254
  per_t_segments: Dict[str, List[Any]] = {}
205
- for t, fa_n in m["fa_nodes"].items():
206
- text = m["texts"][t]
207
- fa_words = fa_words_from_result(results[fa_n])
208
- spans = map_fa_words_to_text(text, fa_words)
209
- assignments = assign_words_to_chunks(fa_words, vad_chunks)
255
+ for t in m["fa_nodes"]:
210
256
  per_t_segments[t] = build_segments_from_alignment(
211
- text=text, spans=spans, assignments=assignments,
257
+ text=m["texts"][t], spans=per_t_spans[t],
258
+ assignments=assign_words_to_chunks(per_t_words[t], vad_chunks),
212
259
  num_chunks=len(vad_chunks), source_provider_id=t,
213
260
  )
214
261
  auth = per_t_segments.get(text_from)
@@ -382,9 +429,29 @@ async def run_decomp(
382
429
  run_id=run_id, created_at=time.time(), config=cfg.to_dict(),
383
430
  source_manifest=str(source_manifest_path),
384
431
  source_format=src.get("format", ""), source_version=src.get("version", ""),
385
- capabilities=collect_capability_info(manager, [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]),
432
+ capabilities=collect_capability_info(manager, [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]
433
+ + ([cfg.seg_capability] if cfg.sentence_split else [])),
386
434
  )
387
435
  vad_config_hash = str((manifest.capabilities.get(cfg.vad_capability) or {}).get("config_hash") or "")
436
+ # Skeleton identity (DEC f1024568): the Segment identity input WIDENS to a
437
+ # skeleton-config hash — the raw VAD hash when no split stage runs (legacy-
438
+ # identical ids), a composite over {vad config hash, split policy + params}
439
+ # when sentence-split is on, so split spines COEXIST with the originals by
440
+ # construction (no node id is shared across skeletons).
441
+ split_policy = SENTENCE_SPLIT_POLICY if cfg.sentence_split else None
442
+ # B.5: the SEGMENTER's identity (capability name + effective-config hash)
443
+ # joins the composite — two spines cut by different segmenters (or the
444
+ # same segmenter reconfigured) can never share node ids. --respine widens
445
+ # the identity with THIS run's id (fresh spine, same config, DEC 9241564f).
446
+ seg_config_hash = (str((manifest.capabilities.get(cfg.seg_capability) or {}).get("config_hash") or "")
447
+ if split_policy else "")
448
+ skeleton_config_hash = compute_skeleton_hash(
449
+ vad_config_hash, split_policy=split_policy,
450
+ split_min_chunk_s=cfg.split_min_chunk_s,
451
+ seg_capability=cfg.seg_capability, seg_config_hash=seg_config_hash,
452
+ respine_token=run_id if cfg.respine else None)
453
+ manifest.skeleton_config_hash = skeleton_config_hash
454
+ manifest.split_policy = split_policy
388
455
  # Pipeline writes append through to the graph db's SIDECAR journal (DEC ccbab9f5 /
389
456
  # finding 4219da27): unjournaled ingestion would erode db-rebuildability.
390
457
  graph_db_path = (manifest.capabilities.get(cfg.graph_capability) or {}).get("db_path")
@@ -417,7 +484,8 @@ async def run_decomp(
417
484
  break
418
485
 
419
486
  nodes, edges, ids = build_extension_payload(
420
- source, src_capabilities, vad_config_hash, text_from, aligned)
487
+ source, src_capabilities, skeleton_config_hash, text_from, aligned,
488
+ split_policy=split_policy)
421
489
 
422
490
  if not confirm_seam("commit-review",
423
491
  [f"{title}: extending Source {ids['source'][:8]}… with "
@@ -450,8 +518,11 @@ async def run_decomp(
450
518
  actor="pipeline:cjm-transcript-decomp-core", run=run_id,
451
519
  args={"method": "alignment-fold/v1"})
452
520
 
453
- # Fine spine hangs under the run's renditions — verify scopes there.
454
- vr = await verify_source(queue, cfg.graph_capability, ids["source"], ids["renditions"])
521
+ # Fine spine hangs under the run's renditions — verify scopes there,
522
+ # id-scoped to THIS run's segments (a coexisting sibling spine under
523
+ # the same renditions must not fail the structural checks).
524
+ vr = await verify_source(queue, cfg.graph_capability, ids["source"],
525
+ ids["renditions"], segment_ids=ids["segments"])
455
526
  if vr is None:
456
527
  logger.error(f"[src {i}] verify: Source {ids['source']} NOT FOUND in graph")
457
528
  else:
@@ -501,3 +572,41 @@ def decomp_replay_handlers() -> Dict[str, Any]: # verb -> async handler(queue,
501
572
  `apply_wires` (identity-comparable across cores: transcription also emits
502
573
  `derivation`, and the shared handler keeps that collision legal)."""
503
574
  return wires_handlers("spine-extension", "derivation")
575
+
576
+
577
+ def sentence_spans_from_result(
578
+ result: "SentenceSegmentationResult", # Typed segmentation result (wire-decoded at the proxy)
579
+ ) -> List[Tuple[int, int]]: # Ordered (start_char, end_char) sentence spans
580
+ """Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5)."""
581
+ return [(int(s.start_char), int(s.end_char)) for s in result.spans]
582
+
583
+
584
+ def compute_skeleton_hash(
585
+ vad_config_hash: str, # The VAD stage's effective-config hash (the legacy identity)
586
+ split_policy: Optional[str] = None, # Split policy+version when the split stage ran (None = no split)
587
+ split_min_chunk_s: float = 0.5, # Split guard (identity input when splitting)
588
+ seg_capability: str = "", # Segmenter capability name (identity input when splitting, B.5)
589
+ seg_config_hash: str = "", # Segmenter effective-config hash (identity input when splitting)
590
+ respine_token: Optional[str] = None, # Distinct-spine token (--respine, DEC 9241564f); None = normal identity
591
+ ) -> str: # The skeleton-config hash every Segment id + skeleton_hash prop derive from
592
+ """Skeleton identity, pure (DEC f1024568 + 9241564f).
593
+
594
+ The raw VAD hash when no split stage runs (legacy-identical ids); a
595
+ composite over {vad, split policy+params, segmenter identity} when
596
+ sentence-split is on. `respine_token` WIDENS whichever identity applies:
597
+ same config, FRESH spine, as a deliberate act — the minted hash can never
598
+ collide with the config-identical spine (the e8458f6e post-upgrade
599
+ verify-collide), and the token (the decomp run id) keeps the re-spine
600
+ provenance-visible in the manifest."""
601
+ h = vad_config_hash
602
+ if split_policy:
603
+ h = compute_config_hash({
604
+ "vad_config_hash": vad_config_hash,
605
+ "split_policy": split_policy,
606
+ "split_min_chunk_s": split_min_chunk_s,
607
+ "seg_capability": seg_capability,
608
+ "seg_config_hash": seg_config_hash,
609
+ })
610
+ if respine_token:
611
+ h = compute_config_hash({"base_skeleton_hash": h, "respine": str(respine_token)})
612
+ return h
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-decomp-core
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: A frontend-agnostic core for the transcript decomposition workflow — composes isolated capability workers (forced alignment, VAD, graph storage) into a headless pipeline that decomposes transcription run manifests into a VAD-aligned context-graph spine with traceable provenance, and a CLI as its first driver.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -12,7 +12,7 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: cjm_substrate>=0.0.57
14
14
  Requires-Dist: cjm_context_graph_primitives>=0.0.15
15
- Requires-Dist: cjm_capability_primitives>=0.0.10
15
+ Requires-Dist: cjm_capability_primitives>=0.0.11
16
16
  Requires-Dist: cjm-context-graph-layer>=0.0.11
17
17
  Requires-Dist: cjm-transcript-graph-schema>=0.0.7
18
18
  Dynamic: license-file
@@ -38,6 +38,8 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
38
38
  - `assign_words_to_chunks` _function_ — Assign each FA word to a VAD chunk by timestamp overlap.
39
39
  - `build_segments_from_alignment` _function_ — Build a TextSegment per VAD chunk by grouping words by chunk assignment.
40
40
  - `map_fa_words_to_text` _function_ — Map forced-alignment words back to character spans in the original text.
41
+ - `sentence_end_word_indices` _function_ — Map capability-delivered sentence boundaries onto FA words (B.5: the
42
+ - `split_chunks_at_sentence_gaps` _function_ — The sentence-split stage (SENTENCE_SPLIT_POLICY, DEC f1024568): refine the
41
43
  - `tier1_alignment_checks` _function_ — Tier-1 deterministic pre-filters for the alignment-review seam (no AI).
42
44
 
43
45
  ### `cjm_transcript_decomp_core.cli`
@@ -68,14 +70,16 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
68
70
 
69
71
  ### `cjm_transcript_decomp_core.pipeline`
70
72
 
71
- - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
73
+ - `build_alignment_composition` _function_ — Build the whole-source M×(VAD ∥ T×FA ∥ SEG) composition (D8 fan-in, stage-5 variants).
72
74
  - `collect_capability_info` _function_ — Record capability identity + data-DB pointers for the run manifest (provenance).
75
+ - `compute_skeleton_hash` _function_ — Skeleton identity, pure (DEC f1024568 + 9241564f).
73
76
  - `confirm_seam` _function_ — HITL approval seam in its cheapest viable form (log + optional CLI prompt).
74
77
  - `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
75
78
  - `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
76
79
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
77
80
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
78
81
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
82
+ - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
79
83
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
80
84
  - `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
81
85
 
@@ -1,5 +1,5 @@
1
1
  cjm_substrate>=0.0.57
2
2
  cjm_context_graph_primitives>=0.0.15
3
- cjm_capability_primitives>=0.0.10
3
+ cjm_capability_primitives>=0.0.11
4
4
  cjm-context-graph-layer>=0.0.11
5
5
  cjm-transcript-graph-schema>=0.0.7
@@ -14,7 +14,7 @@ classifiers = [
14
14
  "Programming Language :: Python :: 3",
15
15
  "Programming Language :: Python :: 3 :: Only",
16
16
  ]
17
- dependencies = ['cjm_substrate>=0.0.57', 'cjm_context_graph_primitives>=0.0.15', 'cjm_capability_primitives>=0.0.10', 'cjm-context-graph-layer>=0.0.11', 'cjm-transcript-graph-schema>=0.0.7']
17
+ dependencies = ['cjm_substrate>=0.0.57', 'cjm_context_graph_primitives>=0.0.15', 'cjm_capability_primitives>=0.0.11', 'cjm-context-graph-layer>=0.0.11', 'cjm-transcript-graph-schema>=0.0.7']
18
18
 
19
19
  [project.urls]
20
20
  Repository = "https://github.com/cj-mills/cjm-transcript-decomp-core"
@@ -0,0 +1,194 @@
1
+ """Tests for cjm_transcript_decomp_core.alignment — pure forced-alignment logic.
2
+
3
+ Projected from the alignment notebook's smoke-check cell at the golden-reference flip
4
+ (pure logic; deterministic)."""
5
+ from cjm_transcript_decomp_core.alignment import (
6
+ assign_words_to_chunks,
7
+ build_segments_from_alignment,
8
+ map_fa_words_to_text,
9
+ sentence_end_word_indices,
10
+ split_chunks_at_sentence_gaps,
11
+ tier1_alignment_checks,
12
+ )
13
+ from cjm_transcript_decomp_core.models import FAWord, VADChunk
14
+
15
+ TEXT = "Hello world. Foo bar."
16
+ FA = [FAWord("hello", 0.0, 0.5), FAWord("world", 0.5, 1.0),
17
+ FAWord("foo", 2.0, 2.5), FAWord("bar", 2.5, 3.0)]
18
+ CHUNKS = [VADChunk(0, 0.0, 1.2), VADChunk(1, 1.9, 3.1)]
19
+
20
+
21
+ def test_map_fa_words_to_text_spans():
22
+ spans = map_fa_words_to_text(TEXT, FA)
23
+ assert len(spans) == 4
24
+ assert TEXT[spans[0][0]:spans[0][1]] == "Hello"
25
+ assert TEXT[spans[1][0]:spans[1][1]] == "world."
26
+
27
+
28
+ def test_assign_words_to_chunks_by_timestamp():
29
+ assert assign_words_to_chunks(FA, CHUNKS) == [0, 0, 1, 1]
30
+
31
+
32
+ def test_build_segments_per_chunk():
33
+ spans = map_fa_words_to_text(TEXT, FA)
34
+ assign = assign_words_to_chunks(FA, CHUNKS)
35
+ segs = build_segments_from_alignment(TEXT, spans, assign, num_chunks=2)
36
+ assert len(segs) == 2
37
+ assert segs[0].text == "Hello world."
38
+ assert segs[1].text == "Foo bar."
39
+ assert tier1_alignment_checks(segs, CHUNKS) == []
40
+
41
+
42
+ def test_empty_chunk_yields_empty_segment_warning():
43
+ # empty-chunk case -> empty-segment warning
44
+ spans = map_fa_words_to_text(TEXT, FA)
45
+ segs2 = build_segments_from_alignment(TEXT, spans, [0, 0, 0, 0], num_chunks=2)
46
+ assert segs2[1].text == ""
47
+ assert any("EMPTY" in w for w in tier1_alignment_checks(segs2, CHUNKS))
48
+
49
+
50
+ def test_sentence_split_cuts_at_fa_word_gap():
51
+ # One fluent chunk holding two sentences (the bc69e3e6 shape): the cut lands
52
+ # at the midpoint of the FA gap after the sentence-ending word.
53
+ text = "Hello world. Foo bar."
54
+ fa = [FAWord("hello", 0.0, 0.5), FAWord("world", 0.5, 1.0),
55
+ FAWord("foo", 1.4, 2.5), FAWord("bar", 2.5, 3.0)]
56
+ chunks = [VADChunk(0, 0.0, 3.1)]
57
+ spans = map_fa_words_to_text(text, fa)
58
+ end_words = sentence_end_word_indices(
59
+ spans, sent_spans(text, "Hello world.", "Foo bar."))
60
+ assert end_words == {1, 3}
61
+ refined = split_chunks_at_sentence_gaps(chunks, fa, end_words)
62
+ assert [(c.start_time, c.end_time) for c in refined] == [(0.0, 1.2), (1.2, 3.1)]
63
+ assert [c.index for c in refined] == [0, 1]
64
+ # ...and the standard fold over the refined skeleton yields <=1 sentence per chunk.
65
+ segs = build_segments_from_alignment(
66
+ text, spans, assign_words_to_chunks(fa, refined), num_chunks=2)
67
+ assert [s.text for s in segs] == ["Hello world.", "Foo bar."]
68
+
69
+
70
+ def test_sentence_split_no_op_cases():
71
+ text = "Hello world. Foo bar."
72
+ fa = [FAWord("hello", 0.0, 0.5), FAWord("world", 0.5, 1.0),
73
+ FAWord("foo", 2.0, 2.5), FAWord("bar", 2.5, 3.0)]
74
+ chunks = [VADChunk(0, 0.0, 1.2), VADChunk(1, 1.9, 3.1)]
75
+ spans = map_fa_words_to_text(text, fa)
76
+ end_words = sentence_end_word_indices(
77
+ spans, sent_spans(text, "Hello world.", "Foo bar."))
78
+ # Already one sentence per chunk: the sentence ends fall on chunk-final
79
+ # words, so nothing splits and the skeleton passes through re-indexed.
80
+ refined = split_chunks_at_sentence_gaps(chunks, fa, end_words)
81
+ assert [(c.start_time, c.end_time) for c in refined] == [(0.0, 1.2), (1.9, 3.1)]
82
+ # Textless/montage chunks (no words assigned anywhere) pass through whole.
83
+ assert [(c.start_time, c.end_time)
84
+ for c in split_chunks_at_sentence_gaps(chunks, [], set())] \
85
+ == [(0.0, 1.2), (1.9, 3.1)]
86
+
87
+
88
+ def test_sentence_split_min_duration_guard():
89
+ # The sentence gap sits 0.25s from the chunk end — a cut would mint a
90
+ # sliver, so the guard refuses it and the chunk survives whole.
91
+ text = "Hello world. Hi."
92
+ fa = [FAWord("hello", 0.0, 0.5), FAWord("world", 0.5, 2.6),
93
+ FAWord("hi", 2.7, 2.9)]
94
+ chunks = [VADChunk(0, 0.0, 2.9)]
95
+ spans = map_fa_words_to_text(text, fa)
96
+ end_words = sentence_end_word_indices(
97
+ spans, sent_spans(text, "Hello world.", "Hi."))
98
+ refined = split_chunks_at_sentence_gaps(chunks, fa, end_words, min_chunk_s=0.5)
99
+ assert [(c.start_time, c.end_time) for c in refined] == [(0.0, 2.9)]
100
+ # A permissive guard lets the same cut through.
101
+ loose = split_chunks_at_sentence_gaps(chunks, fa, end_words, min_chunk_s=0.1)
102
+ assert [(round(c.start_time, 2), round(c.end_time, 2)) for c in loose] \
103
+ == [(0.0, 2.65), (2.65, 2.9)]
104
+
105
+
106
+ def test_sentence_split_three_way_cut():
107
+ # Three capability-delivered sentences in one chunk ('dispatch."' / 'Why?' /
108
+ # 'Because.') -> two cuts at the FA gaps after each sentence-ending word.
109
+ text = 'He said "dispatch." Why? Because.'
110
+ fa = [FAWord("he", 0.0, 0.3), FAWord("said", 0.3, 0.6),
111
+ FAWord("dispatch", 0.6, 1.5), FAWord("why", 2.1, 2.8),
112
+ FAWord("because", 3.5, 4.2)]
113
+ chunks = [VADChunk(0, 0.0, 4.4)]
114
+ spans = map_fa_words_to_text(text, fa)
115
+ end_words = sentence_end_word_indices(
116
+ spans, sent_spans(text, 'He said "dispatch."', "Why?", "Because."))
117
+ assert end_words == {2, 3, 4}
118
+ refined = split_chunks_at_sentence_gaps(chunks, fa, end_words)
119
+ assert [(round(c.start_time, 2), round(c.end_time, 2)) for c in refined] \
120
+ == [(0.0, 1.8), (1.8, 3.15), (3.15, 4.4)]
121
+
122
+
123
+ def test_sentence_split_capability_spans_drive_the_decision():
124
+ # (1) The v2-era abbreviation class ('U.S.' mid-sentence) is now the
125
+ # SEGMENTER's problem: one capability span covering the whole text means
126
+ # no end-word lands mid-chunk, so nothing cuts (segmenter correctness for
127
+ # this class is pinned in the cjm-capability-pysbd suite).
128
+ text = "Sitting down with the U.S. Secretary of Energy."
129
+ fa = [FAWord("sitting", 0.0, 0.4), FAWord("down", 0.4, 0.8),
130
+ FAWord("with", 0.8, 1.1), FAWord("the", 1.1, 1.3),
131
+ FAWord("us", 1.3, 2.0), FAWord("secretary", 2.0, 2.9),
132
+ FAWord("of", 2.9, 3.1), FAWord("energy", 3.1, 3.8)]
133
+ chunks = [VADChunk(0, 0.0, 4.0)]
134
+ spans = map_fa_words_to_text(text, fa)
135
+ end_words = sentence_end_word_indices(spans, sent_spans(text, text))
136
+ assert [(c.start_time, c.end_time)
137
+ for c in split_chunks_at_sentence_gaps(chunks, fa, end_words)] \
138
+ == [(0.0, 4.0)]
139
+
140
+ # (2) Contiguous FA words (zero gap at the cut): the boundary word must land
141
+ # RIGHT of the cut — text and audio agree (the off-by-one drive finding).
142
+ text2 = "Stagnant on energy growth. Any fundamental reason?"
143
+ fa2 = [FAWord("stagnant", 0.0, 0.6), FAWord("on", 0.6, 0.8),
144
+ FAWord("energy", 0.8, 1.2), FAWord("growth", 1.2, 1.8),
145
+ FAWord("any", 1.8, 2.1), FAWord("fundamental", 2.1, 2.8),
146
+ FAWord("reason", 2.8, 3.3)]
147
+ chunks2 = [VADChunk(0, 0.0, 3.5)]
148
+ spans2 = map_fa_words_to_text(text2, fa2)
149
+ end_words2 = sentence_end_word_indices(
150
+ spans2, sent_spans(text2, "Stagnant on energy growth.",
151
+ "Any fundamental reason?"))
152
+ refined = split_chunks_at_sentence_gaps(chunks2, fa2, end_words2)
153
+ assert [(c.start_time, c.end_time) for c in refined] == [(0.0, 1.8), (1.8, 3.5)]
154
+ segs = build_segments_from_alignment(
155
+ text2, spans2, assign_words_to_chunks(fa2, refined), num_chunks=2)
156
+ assert [s.text for s in segs] == ["Stagnant on energy growth.",
157
+ "Any fundamental reason?"]
158
+
159
+
160
+ def test_sentence_end_word_indices_merge_walk():
161
+ # The word<->sentence merge walk directly: the 'Mr. Gorbachev' class is one
162
+ # capability span (no mid-sentence end word); a real sentence end right
163
+ # after a title stub still marks its last word.
164
+ text = "He met Dr. Smith. Then he left."
165
+ fa = [FAWord("he", 0.0, 0.3), FAWord("met", 0.3, 0.6),
166
+ FAWord("dr", 0.6, 0.9), FAWord("smith", 0.9, 1.6),
167
+ FAWord("then", 2.4, 2.7), FAWord("he", 2.7, 2.9),
168
+ FAWord("left", 2.9, 3.3)]
169
+ spans = map_fa_words_to_text(text, fa)
170
+ end_words = sentence_end_word_indices(
171
+ spans, sent_spans(text, "He met Dr. Smith.", "Then he left."))
172
+ assert end_words == {3, 6} # 'Smith.' and 'left.' — never 'Dr.'
173
+ chunks = [VADChunk(0, 0.0, 3.5)]
174
+ assert [(c.start_time, c.end_time)
175
+ for c in split_chunks_at_sentence_gaps(chunks, fa, end_words)] \
176
+ == [(0.0, 2.0), (2.0, 3.5)]
177
+ # A sentence no word starts in (e.g. segmenter output beyond the FA tail)
178
+ # contributes nothing; empty inputs stay empty.
179
+ assert sentence_end_word_indices(spans, [(900, 950)]) == set()
180
+ assert sentence_end_word_indices([], [(0, 10)]) == set()
181
+ assert sentence_end_word_indices(spans, []) == set()
182
+
183
+
184
+ def sent_spans(text, *sentences):
185
+ """Char spans for the given sentence strings within `text` — what the
186
+ segmentation capability delivers for this text (B.5: the tests feed the
187
+ capability's OUTPUT SHAPE; segmenter correctness lives in the
188
+ cjm-capability-pysbd suite)."""
189
+ spans, pos = [], 0
190
+ for s in sentences:
191
+ start = text.index(s, pos)
192
+ spans.append((start, start + len(s)))
193
+ pos = start + len(s)
194
+ return spans
@@ -106,3 +106,20 @@ def test_provenance_refs_and_text_from():
106
106
  n2 = nodes[2]
107
107
  assert len(n2["sources"]) == 1 and "text_from" not in n2["properties"]
108
108
  assert n2["properties"]["text"] == ""
109
+
110
+
111
+ def test_parallel_spines_disjoint_ids_and_split_metadata():
112
+ # DEC f1024568: a split run passes a DIFFERENT skeleton hash — every node id
113
+ # forks, so the split spine coexists with the original by construction.
114
+ base_nodes, _, base_ids = build_extension_payload(
115
+ SOURCE_ENTRY, CAPABILITIES, "sha256:vad", "voxtral", _segments())
116
+ split_nodes, _, split_ids = build_extension_payload(
117
+ SOURCE_ENTRY, CAPABILITIES, "sha256:skel-split", "voxtral", _segments(),
118
+ split_policy="sentence-split/v1")
119
+ assert set(base_ids["segments"]).isdisjoint(split_ids["segments"])
120
+ # The identity input rides every node as the queryable skeleton_hash prop;
121
+ # split runs also record their policy tag (base runs carry no split_policy).
122
+ assert all(n["properties"]["skeleton_hash"] == "sha256:vad" for n in base_nodes)
123
+ assert all("split_policy" not in n["properties"] for n in base_nodes)
124
+ assert all(n["properties"]["skeleton_hash"] == "sha256:skel-split" for n in split_nodes)
125
+ assert all(n["properties"]["split_policy"] == "sentence-split/v1" for n in split_nodes)
@@ -38,7 +38,9 @@ def test_manifest_shape_and_run_id():
38
38
  m = DecompManifest(run_id="r", created_at=0.0, config={}, source_manifest="/tmp/s.json")
39
39
  md = m.to_dict()
40
40
  assert md["format"] == "cjm-transcript-decomp-core/run-manifest"
41
- assert md["version"] == "0.2.1" and md["sources"] == []
41
+ assert md["version"] == "0.2.3" and md["sources"] == []
42
+ # 0.2.2: skeleton identity rides the manifest (empty/None until run_decomp fills them)
43
+ assert md["skeleton_config_hash"] == "" and md["split_policy"] is None
42
44
  rec = DecompSourceRecord(source_node_id="sid", source_path="/a.mp3", title="a",
43
45
  segment_count=2, segment_ids=["s1", "s2"])
44
46
  assert rec.to_dict()["source_node_id"] == "sid"
@@ -64,3 +64,54 @@ def test_alignment_nodes_ride_the_task_channel():
64
64
  assert comp.nodes[2].control == {"force": False}
65
65
  run = new_composition_run(comp, "r")
66
66
  assert set(run.ready_nodes()) == {"vad_0000", "fa_t0_0000", "fa_t1_0000", "vad_0002", "fa_t0_0002"}
67
+
68
+
69
+ def test_alignment_composition_seg_nodes():
70
+ # B.5: with a segmentation capability + authoritative transcriber, each
71
+ # non-skipped pseg where the authoritative transcriber has text also gets
72
+ # one sentence_segmentation/segment_text node over that text — riding the
73
+ # SAME composition (the text comes from the manifest, not from FA).
74
+ comp, metas = build_alignment_composition(
75
+ SEGS, "silero", "qwen3", ["whisper", "voxtral"],
76
+ seg_id="pysbd", seg_text_from="voxtral")
77
+ by_name = {n.id: n for n in comp.nodes}
78
+ # pseg0: voxtral has text -> seg node; pseg1: skipped; pseg2: voxtral empty -> none.
79
+ assert metas[0]["seg_node"] == "seg_0000"
80
+ seg = by_name["seg_0000"]
81
+ assert seg.kwargs == {"text": "Alpha."}
82
+ assert seg.task_name == "sentence_segmentation" and seg.method == "segment_text"
83
+ assert "seg_node" not in metas[2]
84
+ assert len(comp.nodes) == 6
85
+ # Without a seg capability the composition is unchanged (no seg nodes).
86
+ comp2, metas2 = build_alignment_composition(SEGS, "silero", "qwen3",
87
+ ["whisper", "voxtral"])
88
+ assert len(comp2.nodes) == 5 and all("seg_node" not in m for m in metas2)
89
+
90
+
91
+ def test_compute_skeleton_hash_identity_and_respine():
92
+ """DEC f1024568 + 9241564f: no-split = the raw VAD hash (legacy-identical
93
+ ids); split = the B.5 composite; --respine widens EITHER identity with the
94
+ run token — distinct from the config-identical spine, deterministic per
95
+ token, and never equal across tokens."""
96
+ from cjm_transcript_decomp_core.pipeline import compute_skeleton_hash
97
+ vad = "vadhash123"
98
+ assert compute_skeleton_hash(vad) == vad
99
+ split = compute_skeleton_hash(vad, split_policy="pysbd-sentence-v2",
100
+ split_min_chunk_s=0.5,
101
+ seg_capability="cjm-capability-pysbd",
102
+ seg_config_hash="seghash")
103
+ assert split != vad
104
+ assert split == compute_skeleton_hash(vad, split_policy="pysbd-sentence-v2",
105
+ split_min_chunk_s=0.5,
106
+ seg_capability="cjm-capability-pysbd",
107
+ seg_config_hash="seghash")
108
+ r1 = compute_skeleton_hash(vad, respine_token="decomp_run_1")
109
+ r2 = compute_skeleton_hash(vad, respine_token="decomp_run_2")
110
+ assert r1 != vad and r2 != vad and r1 != r2
111
+ assert r1 == compute_skeleton_hash(vad, respine_token="decomp_run_1")
112
+ rs = compute_skeleton_hash(vad, split_policy="pysbd-sentence-v2",
113
+ split_min_chunk_s=0.5,
114
+ seg_capability="cjm-capability-pysbd",
115
+ seg_config_hash="seghash",
116
+ respine_token="decomp_run_1")
117
+ assert rs not in (split, r1, vad)
@@ -1 +0,0 @@
1
- __version__ = "0.0.4"
@@ -1,45 +0,0 @@
1
- """Tests for cjm_transcript_decomp_core.alignment — pure forced-alignment logic.
2
-
3
- Projected from the alignment notebook's smoke-check cell at the golden-reference flip
4
- (pure logic; deterministic)."""
5
- from cjm_transcript_decomp_core.alignment import (
6
- assign_words_to_chunks,
7
- build_segments_from_alignment,
8
- map_fa_words_to_text,
9
- tier1_alignment_checks,
10
- )
11
- from cjm_transcript_decomp_core.models import FAWord, VADChunk
12
-
13
- TEXT = "Hello world. Foo bar."
14
- FA = [FAWord("hello", 0.0, 0.5), FAWord("world", 0.5, 1.0),
15
- FAWord("foo", 2.0, 2.5), FAWord("bar", 2.5, 3.0)]
16
- CHUNKS = [VADChunk(0, 0.0, 1.2), VADChunk(1, 1.9, 3.1)]
17
-
18
-
19
- def test_map_fa_words_to_text_spans():
20
- spans = map_fa_words_to_text(TEXT, FA)
21
- assert len(spans) == 4
22
- assert TEXT[spans[0][0]:spans[0][1]] == "Hello"
23
- assert TEXT[spans[1][0]:spans[1][1]] == "world."
24
-
25
-
26
- def test_assign_words_to_chunks_by_timestamp():
27
- assert assign_words_to_chunks(FA, CHUNKS) == [0, 0, 1, 1]
28
-
29
-
30
- def test_build_segments_per_chunk():
31
- spans = map_fa_words_to_text(TEXT, FA)
32
- assign = assign_words_to_chunks(FA, CHUNKS)
33
- segs = build_segments_from_alignment(TEXT, spans, assign, num_chunks=2)
34
- assert len(segs) == 2
35
- assert segs[0].text == "Hello world."
36
- assert segs[1].text == "Foo bar."
37
- assert tier1_alignment_checks(segs, CHUNKS) == []
38
-
39
-
40
- def test_empty_chunk_yields_empty_segment_warning():
41
- # empty-chunk case -> empty-segment warning
42
- spans = map_fa_words_to_text(TEXT, FA)
43
- segs2 = build_segments_from_alignment(TEXT, spans, [0, 0, 0, 0], num_chunks=2)
44
- assert segs2[1].text == ""
45
- assert any("EMPTY" in w for w in tier1_alignment_checks(segs2, CHUNKS))