cjm-transcript-decomp-core 0.0.2__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 (27) hide show
  1. {cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info → cjm_transcript_decomp_core-0.0.5}/PKG-INFO +13 -7
  2. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/README.md +8 -2
  3. cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core/__init__.py +1 -0
  4. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/alignment.py +95 -4
  5. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/cli.py +93 -19
  6. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/graph.py +45 -15
  7. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/models.py +18 -3
  8. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/pipeline.py +153 -29
  9. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info}/PKG-INFO +13 -7
  10. cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info/entry_points.txt +5 -0
  11. cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info/requires.txt +5 -0
  12. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/pyproject.toml +4 -1
  13. cjm_transcript_decomp_core-0.0.5/tests/test_alignment.py +194 -0
  14. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_cli.py +14 -1
  15. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_graph.py +17 -0
  16. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_models.py +3 -1
  17. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/tests/test_pipeline.py +51 -0
  18. cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core/__init__.py +0 -1
  19. cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info/entry_points.txt +0 -2
  20. cjm_transcript_decomp_core-0.0.2/cjm_transcript_decomp_core.egg-info/requires.txt +0 -5
  21. cjm_transcript_decomp_core-0.0.2/tests/test_alignment.py +0 -45
  22. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/LICENSE +0 -0
  23. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/MANIFEST.in +0 -0
  24. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/SOURCES.txt +0 -0
  25. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/dependency_links.txt +0 -0
  26. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/top_level.txt +0 -0
  27. {cjm_transcript_decomp_core-0.0.2 → cjm_transcript_decomp_core-0.0.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-decomp-core
3
- Version: 0.0.2
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
@@ -10,10 +10,10 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.12
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: cjm_substrate>=0.0.51
14
- Requires-Dist: cjm_context_graph_primitives>=0.0.12
15
- Requires-Dist: cjm_capability_primitives>=0.0.10
16
- Requires-Dist: cjm-context-graph-layer>=0.0.10
13
+ Requires-Dist: cjm_substrate>=0.0.57
14
+ Requires-Dist: cjm_context_graph_primitives>=0.0.15
15
+ Requires-Dist: cjm_capability_primitives>=0.0.11
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
19
19
 
@@ -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`
@@ -45,7 +47,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
45
47
  - `build_parser` _function_ — Build the CLI parser (subcommands: run).
46
48
  - `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
47
49
  - `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
48
- - `run_command` _function_ — Execute the `run` subcommand: extend a transcription-emitted root with the fine spine.
50
+ - `run_command` _function_ — Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
49
51
 
50
52
  ### `cjm_transcript_decomp_core.graph`
51
53
 
@@ -68,16 +70,20 @@ 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).
77
+ - `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
74
78
  - `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
75
79
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
76
80
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
77
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).
78
83
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
79
84
  - `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
80
85
 
81
86
  ## Dependencies
82
87
 
83
88
  **Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`
89
+ **Used by:** `cjm-transcript-decomp-tui`
@@ -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`
@@ -26,7 +28,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
26
28
  - `build_parser` _function_ — Build the CLI parser (subcommands: run).
27
29
  - `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
28
30
  - `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
29
- - `run_command` _function_ — Execute the `run` subcommand: extend a transcription-emitted root with the fine spine.
31
+ - `run_command` _function_ — Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
30
32
 
31
33
  ### `cjm_transcript_decomp_core.graph`
32
34
 
@@ -49,16 +51,20 @@ 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).
58
+ - `decomp_replay_handlers` _function_ — The decomp core's replay vocabulary (DEC 426658f1, replay stays DOMAIN-OWNED).
55
59
  - `decompose_source` _function_ — Decompose one source into aligned fine segments with per-transcriber variants.
56
60
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
57
61
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
58
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).
59
64
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
60
65
  - `vad_chunks_from_result` _function_ — Normalize a typed VAD result into segment-local VAD chunks.
61
66
 
62
67
  ## Dependencies
63
68
 
64
69
  **Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`
70
+ **Used by:** `cjm-transcript-decomp-tui`
@@ -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
@@ -10,9 +10,11 @@ Prerequisite runtime (once, from the repo root):
10
10
  cjm-ctl --cjm-config cjm.yaml setup-runtime
11
11
  cjm-ctl --cjm-config cjm.yaml install-all --capabilities capabilities_test.yaml --force
12
12
 
13
- Then decompose a transcription-core run manifest:
13
+ Then decompose transcription-core run manifest(s) — a multi-manifest batch
14
+ shares ONE loaded capability stack (models load once, per-manifest runs):
14
15
 
15
16
  cjm-transcript-decomp-core run path/to/transcription-run.json --yes
17
+ cjm-transcript-decomp-core run runs/run_a.json runs/run_b.json --yes
16
18
  # GPU runs: opt into CR-7 GPU subtree attribution
17
19
  cjm-transcript-decomp-core run run.json --yes --sysmon-capability cjm-capability-monitor-nvidia
18
20
  """
@@ -21,11 +23,13 @@ import argparse
21
23
  import asyncio
22
24
  import getpass
23
25
  import logging
26
+ import os
24
27
  from pathlib import Path
25
28
  from typing import Any, Dict, List, Optional
26
29
 
27
30
  from cjm_substrate.core.manager import CapabilityManager
28
31
  from cjm_substrate.core.queue import JobQueue
32
+ from cjm_substrate.core.workspace import resolve_workspace
29
33
  from cjm_transcript_decomp_core.models import DecompConfig
30
34
  from cjm_transcript_decomp_core.pipeline import load_source_manifest, run_decomp
31
35
 
@@ -41,7 +45,8 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
41
45
  sub = parser.add_subparsers(dest="command", required=True)
42
46
 
43
47
  run = sub.add_parser("run", help="Extend a transcription-emitted graph root with the fine spine")
44
- run.add_argument("manifest", help="Transcription-core run manifest JSON (the source to decompose)")
48
+ run.add_argument("manifests", nargs="+",
49
+ help="Transcription-core run manifest JSON(s) — a batch shares ONE loaded capability stack")
45
50
  run.add_argument("--manifests-dir", default=".cjm/manifests", help="Capability manifests directory")
46
51
  run.add_argument("--vad-capability", default="cjm-capability-silero-vad", help="VAD capability name")
47
52
  run.add_argument("--fa-capability", default="cjm-capability-qwen3-forced-aligner", help="Forced-alignment capability name")
@@ -54,9 +59,32 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
54
59
  "default: the manifest's sole transcriber)")
55
60
  run.add_argument("--sysmon-capability", default=None, help="monitor capability for GPU subtree attribution (CR-7); loaded first; default: no monitor")
56
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")
57
77
  run.add_argument("--force", action="store_true", help="Bypass capability-side caches (VAD + FA)")
58
78
  run.add_argument("-y", "--yes", action="store_true", help="Auto-accept HITL seams (headless mode)")
59
- run.add_argument("--output", default=None, help="Decomp-manifest output path (default: runs/<run_id>.json)")
79
+ run.add_argument("--output", default=None, help="Decomp-manifest output path (single-manifest runs only; default: <output-dir>/<run_id>.json)")
80
+ run.add_argument("--output-dir", default=None,
81
+ help="Decomp-manifest output directory (callers with a shared runs dir "
82
+ "pass it so manifests land beside their sources; default: the "
83
+ "workspace's runs/ when one is active, else runs/ under the cwd)")
84
+ run.add_argument("--workspace", default=None,
85
+ help="Workspace root (5daadfc4; default: CJM_WORKSPACE env, else upward walk "
86
+ "from cwd). Supplies the --output-dir default and is exported so "
87
+ "substrate config + capability workers resolve workspace-scoped paths")
60
88
  run.add_argument("--actor", default=None,
61
89
  help="Journal attribution for who/what initiated this run (default: cli:<username>)")
62
90
  run.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
@@ -85,11 +113,31 @@ def load_capabilities(
85
113
 
86
114
  async def run_command(
87
115
  args: argparse.Namespace, # Parsed CLI arguments for the `run` subcommand
88
- ) -> int: # Process exit code (0 = all sources extended + verified)
89
- """Execute the `run` subcommand: extend a transcription-emitted root with the fine spine."""
90
- manifest_path = str(Path(args.manifest).resolve())
91
- if not Path(manifest_path).exists():
92
- raise SystemExit(f"source manifest not found: {manifest_path}")
116
+ ) -> int: # Process exit code (0 = every manifest fully extended + verified)
117
+ """Execute the `run` subcommand: extend transcription-run manifest(s) with the fine spine.
118
+
119
+ Batch shape (decomp-TUI demand, work item 0ff6bf0f): N manifests ride ONE
120
+ capability stack — the models load once and every manifest reuses them,
121
+ where N CLI invocations would pay N model loads. Each manifest keeps its
122
+ own run_id, decomp manifest, and RUN_STARTED/RUN_FINISHED journal bracket,
123
+ so a batch member is indistinguishable from a solo run downstream. A failed
124
+ member records and the batch continues (unattended queueing must not sink
125
+ the rest); the exit code aggregates across members.
126
+ """
127
+ # 5daadfc4 workspace: resolve BEFORE any substrate config loads; export so
128
+ # the process tree (substrate config, capability workers) is workspace-scoped.
129
+ ws = resolve_workspace(explicit=getattr(args, "workspace", None))
130
+ if ws is not None:
131
+ os.environ["CJM_WORKSPACE"] = str(ws.root)
132
+ output_dir = (Path(args.output_dir) if args.output_dir
133
+ else (ws.runs_dir if ws is not None else Path("runs")))
134
+ manifest_paths = [str(Path(m).resolve()) for m in args.manifests]
135
+ missing = [m for m in manifest_paths if not Path(m).exists()]
136
+ if missing:
137
+ raise SystemExit(f"source manifest(s) not found: {', '.join(missing)}")
138
+ if args.output and len(manifest_paths) > 1:
139
+ raise SystemExit("--output names ONE decomp manifest — omit it for batch runs "
140
+ "(each lands at runs/<run_id>.json)")
93
141
 
94
142
  cfg = DecompConfig(
95
143
  vad_capability=args.vad_capability,
@@ -99,6 +147,10 @@ async def run_command(
99
147
  language=args.language,
100
148
  force=args.force,
101
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,
102
154
  )
103
155
 
104
156
  # CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
@@ -109,20 +161,50 @@ async def run_command(
109
161
  sysmon_capability_name=args.sysmon_capability,
110
162
  )
111
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)
112
167
  load_order = ([args.sysmon_capability] if args.sysmon_capability else []) + instance_ids
113
168
  # F10: --graph-db-path threads a caller-wins config into the graph load
114
169
  # (the C8 pattern correction-core already used; scratch-graph loop-backs).
115
170
  configs = ({cfg.graph_capability: {"db_path": args.graph_db_path}}
116
171
  if args.graph_db_path else None)
117
- 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
118
183
 
119
184
  queue = JobQueue(deps=manager, sysmon_capability_name=args.sysmon_capability)
120
185
  await queue.start()
186
+ all_ok = True
121
187
  try:
122
188
  # CR-14 follow-up: actor attribution (operator identity by default;
123
189
  # agents/services pass --actor explicitly).
124
190
  actor = args.actor or f"cli:{getpass.getuser()}"
125
- manifest = await run_decomp(manager, queue, cfg, manifest_path, actor=actor)
191
+ for mp in manifest_paths:
192
+ try:
193
+ manifest = await run_decomp(manager, queue, cfg, mp, actor=actor)
194
+ except Exception as e: # One member's failure must not sink the batch
195
+ logger.error(f"decomp failed for {mp}: {e}")
196
+ print(f"FAILED {mp}: {e}")
197
+ all_ok = False
198
+ continue
199
+ out = (Path(args.output) if args.output
200
+ else output_dir / f"{manifest.run_id}.json")
201
+ manifest.save(out, workspace=ws)
202
+ n_manifest_sources = len(load_source_manifest(mp).get("sources", []) or [])
203
+ n_sources = len(manifest.sources)
204
+ n_segs = sum(s.segment_count for s in manifest.sources)
205
+ print(f"decomp manifest: {out}")
206
+ print(f"sources extended: {n_sources}/{n_manifest_sources} segment nodes: {n_segs}")
207
+ all_ok = all_ok and (n_sources == n_manifest_sources)
126
208
  finally:
127
209
  await queue.stop()
128
210
  for iid in reversed(load_order): # Reverse load order; the monitor unloads last
@@ -130,15 +212,7 @@ async def run_command(
130
212
  manager.unload_capability(iid)
131
213
  except Exception as e: # Best-effort teardown; never mask the run's outcome
132
214
  logger.warning(f"unload {iid} failed: {e}")
133
-
134
- out = Path(args.output) if args.output else Path("runs") / f"{manifest.run_id}.json"
135
- manifest.save(out)
136
- n_manifest_sources = len(load_source_manifest(manifest_path).get("sources", []) or [])
137
- n_sources = len(manifest.sources)
138
- n_segs = sum(s.segment_count for s in manifest.sources)
139
- print(f"decomp manifest: {out}")
140
- print(f"sources extended: {n_sources}/{n_manifest_sources} segment nodes: {n_segs}")
141
- return 0 if n_sources == n_manifest_sources else 1
215
+ return 0 if all_ok else 1
142
216
 
143
217
 
144
218
  def main(
@@ -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
@@ -7,6 +7,8 @@ from dataclasses import asdict, dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import Any, Dict, List, Optional, Union
9
9
 
10
+ from cjm_substrate.core.workspace import relativize_recorded
11
+
10
12
 
11
13
  @dataclass
12
14
  class FAWord:
@@ -115,6 +117,10 @@ class DecompConfig:
115
117
  media_type: str = "audio" # Source media type
116
118
  force: bool = False # Bypass capability-side caches (VAD + FA)
117
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)
118
124
 
119
125
  def to_dict(self) -> Dict[str, Any]: # Plain-dict snapshot for the manifest
120
126
  """Serialize to a plain dict."""
@@ -151,9 +157,11 @@ class DecompManifest:
151
157
  source_version: str = "" # Upstream manifest schema version
152
158
  capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) # instance_id -> identity/db_path/config_hash
153
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)
154
162
 
155
163
  FORMAT: str = field(default="cjm-transcript-decomp-core/run-manifest", repr=False) # Format tag
156
- 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)
157
165
 
158
166
  def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
159
167
  """Serialize to a plain dict with nested sources."""
@@ -167,17 +175,24 @@ class DecompManifest:
167
175
  "source_format": self.source_format,
168
176
  "source_version": self.source_version,
169
177
  "capabilities": self.capabilities,
178
+ "skeleton_config_hash": self.skeleton_config_hash,
179
+ "split_policy": self.split_policy,
170
180
  "sources": [s.to_dict() for s in self.sources],
171
181
  }
172
182
 
173
183
  def save(
174
184
  self,
175
185
  path: Union[str, Path], # Destination JSON file (parent dirs created)
186
+ workspace=None, # Active Workspace; owned paths record as ${WS}/<rel> (5daadfc4 rung f)
176
187
  ) -> Path: # The written path
177
- """Write the manifest as pretty-printed JSON."""
188
+ """Write the manifest as pretty-printed JSON.
189
+
190
+ With `workspace`, recorded paths under its root take the ${WS}/ token
191
+ form (relativize_recorded), so the manifest relocates with the
192
+ workspace; readers resolve via resolve_recorded_tree at load."""
178
193
  out = Path(path)
179
194
  out.parent.mkdir(parents=True, exist_ok=True)
180
- out.write_text(json.dumps(self.to_dict(), indent=2))
195
+ out.write_text(json.dumps(relativize_recorded(self.to_dict(), workspace), indent=2))
181
196
  return out
182
197
 
183
198