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.
- {cjm_transcript_decomp_core-0.0.4/cjm_transcript_decomp_core.egg-info → cjm_transcript_decomp_core-0.0.5}/PKG-INFO +7 -3
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/README.md +5 -1
- cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core/__init__.py +1 -0
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/alignment.py +95 -4
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/cli.py +33 -1
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/graph.py +45 -15
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/models.py +9 -1
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core/pipeline.py +136 -27
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5/cjm_transcript_decomp_core.egg-info}/PKG-INFO +7 -3
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/requires.txt +1 -1
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/pyproject.toml +1 -1
- cjm_transcript_decomp_core-0.0.5/tests/test_alignment.py +194 -0
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_graph.py +17 -0
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_models.py +3 -1
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_pipeline.py +51 -0
- cjm_transcript_decomp_core-0.0.4/cjm_transcript_decomp_core/__init__.py +0 -1
- cjm_transcript_decomp_core-0.0.4/tests/test_alignment.py +0 -45
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/LICENSE +0 -0
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/MANIFEST.in +0 -0
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/cjm_transcript_decomp_core.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {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
- {cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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
|
|
93
|
-
that chunk
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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.
|
|
32
|
-
# nouns live in cjm-capability-primitives (Option C / PILLAR 1c — the
|
|
33
|
-
# depend on the data noun, never the adapter machinery). The tuple
|
|
34
|
-
# SIDE-EFFECT imports referenced so the canonical emit cannot
|
|
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.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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=
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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"
|
{cjm_transcript_decomp_core-0.0.4 → cjm_transcript_decomp_core-0.0.5}/tests/test_pipeline.py
RENAMED
|
@@ -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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|