cjm-transcript-decomp-core 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,186 @@
1
+ """Lean data shapes for the transcript-decomposition pipeline: in-core mirrors of the forced-alignment / VAD / text DTOs (no FastHTML deps), run configuration, the committed graph-segment carrier, and the decomposition run manifest (proto-bundle)."""
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+
11
+ @dataclass
12
+ class FAWord:
13
+ """One word-level forced-alignment result (segment-local times).
14
+
15
+ Lean in-core mirror of the forced-alignment capability's `ForcedAlignItem`
16
+ wire shape, so the core needs no interface-library dependency for a
17
+ three-field DTO (pass-2 evidence: the typed FA result belongs to the
18
+ forced-alignment adapter interface).
19
+ """
20
+ text: str # The aligned word (punctuation typically stripped by the model)
21
+ start_time: float # Word start in seconds (relative to the segment audio)
22
+ end_time: float # Word end in seconds (relative to the segment audio)
23
+
24
+ @classmethod
25
+ def from_wire(
26
+ cls,
27
+ d: Dict[str, Any], # Capability wire dict
28
+ ) -> "FAWord": # Parsed word (extra keys ignored)
29
+ """Build from a capability wire dict, tolerating extra keys."""
30
+ return cls(
31
+ text=str(d.get("text", "")),
32
+ start_time=float(d.get("start_time", 0.0)),
33
+ end_time=float(d.get("end_time", 0.0)),
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class VADChunk:
39
+ """A voice-activity time range within one pipeline segment (segment-local)."""
40
+ index: int # Chunk index within the pipeline segment
41
+ start_time: float # Start in seconds (relative to the segment audio)
42
+ end_time: float # End in seconds (relative to the segment audio)
43
+
44
+ @property
45
+ def duration(self) -> float: # Chunk duration in seconds
46
+ """Chunk duration."""
47
+ return self.end_time - self.start_time
48
+
49
+
50
+ @dataclass
51
+ class TextSegment:
52
+ """A text segment produced by alignment, before graph commit.
53
+
54
+ Lean in-core mirror of the page-centric `TextSegment` (which imports FastHTML
55
+ card-stack types); the core carries only the alignment-relevant fields
56
+ (E11 precedent — keep core logic free of UI-adjacent deps).
57
+ """
58
+ index: int # Position within its pipeline segment
59
+ text: str # Segment text content
60
+ source_id: Optional[str] = None # Upstream transcription row id (job_id)
61
+ source_provider_id: Optional[str] = None # Producing capability name
62
+ start_char: Optional[int] = None # Start char offset into the pipeline-segment text
63
+ end_char: Optional[int] = None # End char offset into the pipeline-segment text
64
+
65
+
66
+ @dataclass
67
+ class SegmentVariant:
68
+ """One transcriber's text + char range for one fine segment (stage 5).
69
+
70
+ Becomes a `GraphNodeRef(Transcript) + CharSlice` provenance ref on the
71
+ committed Segment node — text is stored ONCE per transcriber at the coarse
72
+ Transcript node; fine-grained variants are SLICES, never duplicated text
73
+ (Thread-1 slices-until-promoted)."""
74
+ transcriber: str # Transcriber capability name
75
+ text: str # This transcriber's text for the chunk
76
+ start_char: Optional[int] # Char offset into that transcriber's pipeline-segment text
77
+ end_char: Optional[int] # End char offset
78
+
79
+ def to_dict(self) -> Dict[str, Any]: # Plain-dict form
80
+ """Serialize to a plain dict."""
81
+ return asdict(self)
82
+
83
+
84
+ @dataclass
85
+ class DecompSegment:
86
+ """One fine spine segment (stage 5: shared audio-side skeleton + per-transcriber variants).
87
+
88
+ Identity is AUDIO-SIDE (owning pipeline segment + chunk range) — shared
89
+ across transcribers by construction; `text` is the AUTHORITATIVE
90
+ (accuracy-transcriber) alignment, with every transcriber's chunk text
91
+ riding `variants` (the authoritative one included)."""
92
+ index: int # Source-wide 0-based fine-spine position
93
+ text: str # Authoritative text (the --text-from transcriber's alignment; "" = no aligned words)
94
+ start_time: float # Start in source-audio seconds (navigation)
95
+ end_time: float # End in source-audio seconds
96
+ chunk_start: float # VAD chunk start (chunk-local seconds within the pipeline-segment WAV)
97
+ chunk_end: float # VAD chunk end (chunk-local seconds)
98
+ vad_chunk_index: int # Chunk index within its pipeline segment
99
+ pseg_index: int # Owning pipeline-segment (AudioSegment) index within the source
100
+ variants: List[SegmentVariant] = field(default_factory=list) # Per-transcriber chunk texts + char ranges
101
+
102
+ def to_dict(self) -> Dict[str, Any]: # Plain-dict form
103
+ """Serialize to a plain dict."""
104
+ return asdict(self)
105
+
106
+
107
+ @dataclass
108
+ class DecompConfig:
109
+ """Configuration for one transcript-decomposition run."""
110
+ vad_capability: str = "cjm-capability-silero-vad" # VAD capability id
111
+ fa_capability: str = "cjm-capability-qwen3-forced-aligner" # Forced-alignment capability id
112
+ graph_capability: str = "cjm-capability-graph-sqlite" # Graph-storage capability id
113
+ text_from: Optional[str] = None # Authoritative transcriber for layer-0 text (None: sole transcriber; REQUIRED for multi-transcriber manifests)
114
+ language: str = "English" # Forced-alignment language
115
+ media_type: str = "audio" # Source media type
116
+ force: bool = False # Bypass capability-side caches (VAD + FA)
117
+ assume_yes: bool = False # Auto-accept HITL seams (headless mode)
118
+
119
+ def to_dict(self) -> Dict[str, Any]: # Plain-dict snapshot for the manifest
120
+ """Serialize to a plain dict."""
121
+ return asdict(self)
122
+
123
+
124
+ @dataclass
125
+ class DecompSourceRecord:
126
+ """Record of one Source whose fine spine this run committed (stage 5:
127
+ `Document` dissolved into `Source` — decomp EXTENDS the transcription-
128
+ emitted root rather than creating a document)."""
129
+ source_node_id: str # Graph Source node id (deterministic; recomputable from the content hash)
130
+ source_path: str # Originating source audio path (from the upstream manifest)
131
+ title: str # Source display title
132
+ segment_count: int # Number of fine Segment nodes committed
133
+ segment_ids: List[str] = field(default_factory=list) # Graph Segment node ids, in order
134
+
135
+ def to_dict(self) -> Dict[str, Any]: # Plain-dict form
136
+ """Serialize to a plain dict."""
137
+ return asdict(self)
138
+
139
+
140
+ @dataclass
141
+ class DecompManifest:
142
+ """Durable record of one decomposition run (proto-bundle; see CR-20).
143
+
144
+ Schema 0.2.0 (stage 5): `documents` became `sources` (Document dissolved
145
+ into Source); entries carry the deterministic Source node id."""
146
+ run_id: str # Unique run identifier
147
+ created_at: float # Unix timestamp at run start
148
+ config: Dict[str, Any] # DecompConfig snapshot
149
+ source_manifest: str # Path to the consumed transcription run manifest
150
+ source_format: str = "" # Upstream manifest format tag (interchange contract)
151
+ source_version: str = "" # Upstream manifest schema version
152
+ capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) # instance_id -> identity/db_path/config_hash
153
+ sources: List[DecompSourceRecord] = field(default_factory=list) # Extended sources, input order
154
+
155
+ 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
157
+
158
+ def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
159
+ """Serialize to a plain dict with nested sources."""
160
+ return {
161
+ "format": self.FORMAT,
162
+ "version": self.VERSION,
163
+ "run_id": self.run_id,
164
+ "created_at": self.created_at,
165
+ "config": self.config,
166
+ "source_manifest": self.source_manifest,
167
+ "source_format": self.source_format,
168
+ "source_version": self.source_version,
169
+ "capabilities": self.capabilities,
170
+ "sources": [s.to_dict() for s in self.sources],
171
+ }
172
+
173
+ def save(
174
+ self,
175
+ path: Union[str, Path], # Destination JSON file (parent dirs created)
176
+ ) -> Path: # The written path
177
+ """Write the manifest as pretty-printed JSON."""
178
+ out = Path(path)
179
+ out.parent.mkdir(parents=True, exist_ok=True)
180
+ out.write_text(json.dumps(self.to_dict(), indent=2))
181
+ return out
182
+
183
+
184
+ def new_run_id() -> str: # e.g. "decomp_20260607_153000_1a2b3c4d"
185
+ """Generate a unique, sortable decomposition run id."""
186
+ return f"decomp_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,475 @@
1
+ """The headless decomposition pipeline (stage 5: decomp is an EXTENDER). Load a transcription run manifest, verify the transcription-emitted graph root exists (the graph begins at transcription), then per source per pipeline-segment run VAD + per-transcriber forced alignment, build one aligned segment per VAD chunk with per-transcriber text variants, and attach the fine spine under the existing AudioSegment nodes via the layer's idempotent extend_graph — with HITL approval seams between alignment, commit, and the next source."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from cjm_capability_primitives.forced_alignment import ForcedAlignResult
11
+ from cjm_capability_primitives.vad import VADResult
12
+ from cjm_context_graph_layer.declare import Derivation, derivation_to_graph
13
+ from cjm_context_graph_layer.ops import extend_graph, graph_task
14
+ from cjm_substrate.core.empirical_store import compute_config_hash
15
+ from cjm_substrate.core.journal_store import JournalEvent, SubstrateEventType
16
+ from cjm_substrate.core.manager import CapabilityManager
17
+ from cjm_substrate.core.ports import Composition, CompositionNode, NodeState
18
+ from cjm_substrate.core.queue import JobQueue, JobStatus
19
+ from cjm_transcript_decomp_core.alignment import (assign_words_to_chunks,
20
+ build_segments_from_alignment,
21
+ map_fa_words_to_text, tier1_alignment_checks)
22
+ from cjm_transcript_decomp_core.graph import (build_extension_payload, resolve_root_ids,
23
+ verify_source)
24
+ from cjm_transcript_decomp_core.models import (DecompConfig, DecompManifest, DecompSegment,
25
+ DecompSourceRecord, FAWord, new_run_id,
26
+ SegmentVariant, VADChunk)
27
+
28
+ # Typed wire-kind registration (stage 2): importing the DTO classes is what
29
+ # lets the proxy's wire_decode hand this host process TYPED results. Both result
30
+ # nouns live in cjm-capability-primitives (Option C / PILLAR 1c — the tool/host
31
+ # depend on the data noun, never the adapter machinery). The tuple keeps these
32
+ # SIDE-EFFECT imports referenced so the canonical emit cannot prune them.
33
+
34
+ _REGISTERED_WIRE_KINDS = (VADResult, ForcedAlignResult)
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ async def submit_and_wait(
40
+ queue: JobQueue, # Started job queue
41
+ instance_id: str, # Capability instance to invoke
42
+ *,
43
+ timeout: Optional[float] = None, # Seconds to wait; None = no limit
44
+ **kwargs, # Forwarded to the capability's execute()
45
+ ) -> Any: # The completed job's result payload
46
+ """Submit one capability job, wait for it, and return its result (raise on failure)."""
47
+ job_id = await queue.submit(instance_id, **kwargs)
48
+ job = await queue.wait_for_job(job_id, timeout=timeout)
49
+ if job.status != JobStatus.completed:
50
+ raise RuntimeError(f"{instance_id} job {job_id} {job.status}: {job.error}")
51
+ return job.result
52
+
53
+
54
+ def load_source_manifest(
55
+ path: str, # Path to a transcription-core run manifest JSON
56
+ ) -> Dict[str, Any]: # Parsed manifest dict
57
+ """Load + lightly validate a transcription-core run manifest.
58
+
59
+ Consumed as untyped JSON — the format/version tags are the only interchange
60
+ contract (no shared schema type across cores; manifest-as-interchange, CR-20).
61
+ """
62
+ data = json.loads(Path(path).read_text())
63
+ fmt = data.get("format", "")
64
+ if "transcription-core" not in fmt:
65
+ logger.warning(f"unexpected source manifest format: {fmt!r} (continuing)")
66
+ return data
67
+
68
+
69
+ def vad_chunks_from_result(
70
+ result: VADResult, # Typed VAD result (wire-decoded at the proxy)
71
+ ) -> List[VADChunk]: # Segment-local VAD chunks, sorted, re-indexed
72
+ """Normalize a typed VAD result into segment-local VAD chunks.
73
+
74
+ Pure normalizer (stage 3): the capability invocation moved into the
75
+ per-source composition (`build_alignment_composition`); this folds one
76
+ node's typed result. Per-segment VAD (not whole-source) is the validated
77
+ decomp path — originally a browser Web-Audio constraint, kept on the
78
+ merits (D11).
79
+ """
80
+ chunks = [VADChunk(index=0, start_time=float(r.start), end_time=float(r.end))
81
+ for r in result.ranges]
82
+ chunks.sort(key=lambda c: c.start_time)
83
+ for i, c in enumerate(chunks):
84
+ c.index = i
85
+ return chunks
86
+
87
+
88
+ def fa_words_from_result(
89
+ result: "ForcedAlignResult", # Typed FA result (wire-decoded at the proxy)
90
+ ) -> List[FAWord]: # Word-level alignment results
91
+ """Normalize a typed forced-alignment result into FA words (pure; stage 3)."""
92
+ return [FAWord(text=str(it.text), start_time=float(it.start_time),
93
+ end_time=float(it.end_time))
94
+ for it in result.items]
95
+
96
+
97
+ def build_alignment_composition(
98
+ seg_list: List[Dict[str, Any]], # Pipeline-segment entries from the transcription manifest (0.2.0)
99
+ vad_id: str, # VAD capability instance id
100
+ fa_id: str, # Forced-alignment capability instance id
101
+ transcribers: List[str], # Transcriber names to align (manifest `transcripts` keys, stable order)
102
+ force: bool = False, # Per-call cache-bypass control flag
103
+ ) -> Tuple[Composition, List[Dict[str, Any]]]: # (composition, per-pseg meta rows)
104
+ """Build the whole-source M×(VAD ∥ T×FA) composition (D8 fan-in, stage-5 variants).
105
+
106
+ Each pipeline segment contributes one VAD node + one FA node PER TRANSCRIBER
107
+ with non-empty text — VAD is audio-only (run once; the shared skeleton), FA
108
+ maps each transcriber's text onto it. All nodes are independent; the host's
109
+ alignment fold fans them in. Pipeline segments where EVERY transcriber's
110
+ text is empty are recorded as skipped meta rows and contribute no nodes.
111
+
112
+ Stage 8 (Option C): every node rides the TASK CHANNEL now — VAD on
113
+ vad/detect_speech, FA on forced_alignment/align — and per-call `force` rides
114
+ `control` (the adapters own caching). The native-routed path is gone.
115
+ """
116
+ nodes: List[CompositionNode] = []
117
+ metas: List[Dict[str, Any]] = []
118
+ for i, pseg in enumerate(seg_list):
119
+ # Manifest entries are JSON dicts (manifest-as-interchange, CR-20).
120
+ model_input = str(pseg.get("model_input_path", ""))
121
+ seg_start = float(pseg.get("start", 0.0))
122
+ transcripts = pseg.get("transcripts") or {}
123
+ texts = {t: str((transcripts.get(t) or {}).get("text") or "")
124
+ for t in transcribers if t in transcripts}
125
+ nonempty = {t: x for t, x in texts.items() if x.strip()}
126
+ if not nonempty:
127
+ metas.append({"skipped": True, "seg_start": seg_start, "pseg_index": i})
128
+ continue
129
+ vad_n = f"vad_{i:04d}"
130
+ # VAD via the task channel (vad/detect_speech); the adapter owns the cache
131
+ # + force. model_input is already model-ready (16k mono, converted upstream
132
+ # by transcription-core), so no convert here.
133
+ nodes.append(CompositionNode(vad_n, vad_id,
134
+ {"audio": model_input},
135
+ task_name="vad", method="detect_speech",
136
+ control={"force": force}))
137
+ fa_nodes: Dict[str, str] = {}
138
+ for ti, t in enumerate(transcribers):
139
+ if t not in nonempty:
140
+ continue
141
+ fa_n = f"fa_t{ti}_{i:04d}"
142
+ # FA via the task channel (forced_alignment/align); the adapter owns
143
+ # the cache + force. text is the per-transcriber transcript aligned
144
+ # against the (model-ready) audio.
145
+ nodes.append(CompositionNode(fa_n, fa_id,
146
+ {"audio": model_input, "text": nonempty[t]},
147
+ task_name="forced_alignment", method="align",
148
+ control={"force": force}))
149
+ fa_nodes[t] = fa_n
150
+ metas.append({"skipped": False, "seg_start": seg_start, "pseg_index": i,
151
+ "vad_node": vad_n, "fa_nodes": fa_nodes, "texts": nonempty})
152
+ return Composition(nodes=nodes), metas
153
+
154
+
155
+ async def decompose_source(
156
+ queue: JobQueue,
157
+ cfg: DecompConfig, # Run configuration
158
+ source: Dict[str, Any], # One source entry from the transcription manifest
159
+ source_index: int, # Position of this source within the run
160
+ transcribers: List[str], # Transcriber names (manifest order)
161
+ text_from: str, # Authoritative transcriber (layer-0 text)
162
+ ) -> Tuple[str, List[DecompSegment]]: # (source_path, ordered aligned segments)
163
+ """Decompose one source into aligned fine segments with per-transcriber variants.
164
+
165
+ The VAD-chunk skeleton is computed ONCE per pipeline segment (audio-only);
166
+ each transcriber's text is forced-aligned onto it independently, then the
167
+ pure fold merges per chunk: the `text_from` transcriber's alignment becomes
168
+ the authoritative text, every transcriber's chunk text + char range rides
169
+ `variants` (slice refs at commit). C4's "same skeleton, different text"
170
+ duplication is gone — agreement is stored once by construction.
171
+ """
172
+ source_path = str(source.get("source_path", ""))
173
+ seg_list = list(source.get("segments") or [])
174
+
175
+ comp, metas = build_alignment_composition(
176
+ seg_list, cfg.vad_capability, cfg.fa_capability, transcribers, force=cfg.force)
177
+ results: Dict[str, Any] = {}
178
+ if comp.nodes:
179
+ comp_id = await queue.submit_composition(comp)
180
+ crun = await queue.wait_for_composition(comp_id)
181
+ if crun.status != NodeState.completed:
182
+ failed = {nid: str(nr.error) for nid, nr in crun.node_runs.items()
183
+ if nr.state == NodeState.failed}
184
+ raise RuntimeError(
185
+ f"alignment composition {crun.status.value} for {source_path}: {failed}")
186
+ results = crun.results_by_node()
187
+
188
+ aligned: List[DecompSegment] = []
189
+ global_index = 0
190
+ for m in metas:
191
+ seg_start = m["seg_start"]
192
+ if m["skipped"]:
193
+ logger.warning(f"[src {source_index}] no transcriber text at {seg_start:.1f}s; "
194
+ f"skipping pipeline segment")
195
+ continue
196
+ vad_chunks = vad_chunks_from_result(results[m["vad_node"]])
197
+
198
+ # Per-transcriber fold over the SHARED skeleton.
199
+ per_t_segments: Dict[str, List[Any]] = {}
200
+ for t, fa_n in m["fa_nodes"].items():
201
+ text = m["texts"][t]
202
+ fa_words = fa_words_from_result(results[fa_n])
203
+ spans = map_fa_words_to_text(text, fa_words)
204
+ assignments = assign_words_to_chunks(fa_words, vad_chunks)
205
+ per_t_segments[t] = build_segments_from_alignment(
206
+ text=text, spans=spans, assignments=assignments,
207
+ num_chunks=len(vad_chunks), source_provider_id=t,
208
+ )
209
+ auth = per_t_segments.get(text_from)
210
+ if auth is not None:
211
+ for w in tier1_alignment_checks(auth, vad_chunks):
212
+ logger.warning(f"[src {source_index}] pseg @ {seg_start:.1f}s [{text_from}]: {w}")
213
+ else:
214
+ logger.warning(f"[src {source_index}] pseg @ {seg_start:.1f}s: authoritative "
215
+ f"transcriber {text_from!r} has no text here — layer-0 text empty")
216
+
217
+ for k, vc in enumerate(vad_chunks):
218
+ variants = []
219
+ for t in transcribers:
220
+ tsegs = per_t_segments.get(t)
221
+ if tsegs is None:
222
+ continue
223
+ ts = tsegs[k]
224
+ if ts.text:
225
+ variants.append(SegmentVariant(transcriber=t, text=ts.text,
226
+ start_char=ts.start_char, end_char=ts.end_char))
227
+ aligned.append(DecompSegment(
228
+ index=global_index,
229
+ text=(auth[k].text if auth is not None else ""),
230
+ start_time=seg_start + vc.start_time, end_time=seg_start + vc.end_time,
231
+ chunk_start=vc.start_time, chunk_end=vc.end_time,
232
+ vad_chunk_index=vc.index, pseg_index=m["pseg_index"],
233
+ variants=variants,
234
+ ))
235
+ global_index += 1
236
+ logger.info(f"[src {source_index}] pseg @ {seg_start:.1f}s -> "
237
+ f"{len(vad_chunks)} chunks, {len(m['fa_nodes'])} transcriber alignment(s)")
238
+ return source_path, aligned
239
+
240
+
241
+ def confirm_seam(
242
+ seam: str, # Seam label, e.g. "alignment-review"
243
+ summary_lines: List[str], # What the operator is being asked to accept
244
+ warnings: List[str], # Tier-1 warnings (logged prominently)
245
+ assume_yes: bool = False, # Headless mode: accept without prompting
246
+ ) -> bool: # True = proceed, False = operator aborted
247
+ """HITL approval seam in its cheapest viable form (log + optional CLI prompt).
248
+
249
+ Per-seam HITL-assist annotation (5 fields):
250
+ 1. signal: per-document summary + Tier-1 warnings
251
+ 2. deterministic pre-filter: tier1_alignment_checks (no AI)
252
+ 3. modality-bridge candidate: spectrogram / word-overlay review (future Tier 2)
253
+ 4. authoritative verifier: re-align or re-transcribe-and-compare (future Tier 3)
254
+ 5. flywheel capture: accept/abort decisions logged; durable capture is a
255
+ pass-2 seam-contract concern, not solved here
256
+
257
+ NOTE: input() blocks the event loop — acceptable because seams sit between
258
+ stages with no jobs in flight; the pass-2 seam contract needs an async shape.
259
+ """
260
+ for line in summary_lines:
261
+ logger.info(f"[{seam}] {line}")
262
+ for w in warnings:
263
+ logger.warning(f"[{seam}] {w}")
264
+ if assume_yes:
265
+ logger.info(f"[{seam}] auto-accepted (assume_yes)")
266
+ return True
267
+ reply = input(f"[{seam}] proceed? [Y/n] ").strip().lower()
268
+ accepted = reply in ("", "y", "yes")
269
+ logger.info(f"[{seam}] {'accepted' if accepted else 'ABORTED'} by operator")
270
+ return accepted
271
+
272
+
273
+ def collect_capability_info(
274
+ manager: CapabilityManager, # Manager holding the loaded capabilities
275
+ instance_ids: List[str], # Instance ids to record
276
+ ) -> Dict[str, Dict[str, Any]]: # instance_id -> {name, version, db_path, config_hash}
277
+ """Record capability identity + data-DB pointers for the run manifest (provenance).
278
+
279
+ Stage 5: also records each capability's EFFECTIVE config hash (the same
280
+ `compute_config_hash` the empirical store keys on) — the VAD config hash is
281
+ a Segment identity input. `db_path` prefers the effective config over the
282
+ manifest default (a --graph-db-path override must be what downstream cores
283
+ resolve; the D19 lesson). Stage 6 (0.2.1): the EFFECTIVE config dict is
284
+ recorded READABLY beside its hash (the I8 lesson).
285
+ """
286
+ info: Dict[str, Dict[str, Any]] = {}
287
+ for iid in instance_ids:
288
+ meta = (getattr(manager, "capabilities", {}) or {}).get(iid)
289
+ if meta is None:
290
+ continue
291
+ manifest = getattr(meta, "manifest", {}) or {}
292
+ current_config: Dict[str, Any] = {}
293
+ try:
294
+ proxy = manager.get_capability(iid)
295
+ if proxy is not None:
296
+ current_config = proxy.get_current_config() or {}
297
+ except Exception as e: # Best-effort: identity recording must not fail the run
298
+ logger.warning(f"collect_capability_info: get_current_config({iid}) failed: {e}")
299
+ info[iid] = {
300
+ "name": meta.name,
301
+ "version": getattr(meta, "version", None),
302
+ "db_path": current_config.get("db_path") or manifest.get("db_path"),
303
+ "config_hash": compute_config_hash(current_config),
304
+ "config": current_config,
305
+ }
306
+ return info
307
+
308
+
309
+ def _journal_run_event(
310
+ manager: CapabilityManager, # Manager owning the journal store
311
+ event_type: str, # SubstrateEventType value (run_started / run_finished / verify_outcome)
312
+ run_id: str, # This run's manifest id
313
+ actor: Optional[str], # Who/what initiated the run
314
+ payload: Dict[str, Any], # Run-level structured detail
315
+ ) -> None:
316
+ """Append a host-tier run event to the journal (CR-14 follow-up).
317
+
318
+ The cores are the trusted host writer class: RUN_STARTED/RUN_FINISHED
319
+ bracket the run (run manifest <-> journal linkage by run_id) and
320
+ VERIFY_OUTCOME makes skeptical-lens results durable rows (I14). No-op
321
+ when the manager has no journal store; append failures stay LOUD.
322
+ """
323
+ journal = getattr(manager, "journal_store", None)
324
+ if journal is None:
325
+ return
326
+ journal.append(JournalEvent(
327
+ event_type=event_type, run_id=run_id, actor=actor, payload=payload))
328
+
329
+
330
+ async def run_decomp(
331
+ manager: CapabilityManager, # Manager with VAD + FA + graph capabilities loaded
332
+ queue: JobQueue, # Started job queue
333
+ cfg: DecompConfig, # Run configuration
334
+ source_manifest_path: str, # Transcription run manifest to decompose
335
+ run_id: Optional[str] = None, # Override run id (default: generated)
336
+ actor: Optional[str] = None, # Who/what initiated (journal attribution; CLI default cli:<user>)
337
+ ) -> DecompManifest: # Manifest of the sources extended
338
+ """Extend every source in a transcription run manifest with its fine spine.
339
+
340
+ Stage 5 (decomp-as-extender): the graph ROOT must already exist — decomp
341
+ recomputes the deterministic root ids from the manifest, verifies the
342
+ Source node is present (loud error pointing at transcription emission
343
+ otherwise), aligns per transcriber, and attaches the fine spine under the
344
+ run's AudioRendition (PART_OF rendition) via the layer's idempotent
345
+ `extend_graph` (re-runs verify-collide). The fold is declared as a
346
+ `Derivation` event when segments were actually created.
347
+ """
348
+ run_id = run_id or new_run_id()
349
+ # CR-14 follow-up: queue-scoped run context — every job submitted in this
350
+ # run carries run_id/actor into its journal rows + worker diagnostics
351
+ # (run-manifest <-> journal linkage); the run itself is bracketed by
352
+ # RUN_STARTED/RUN_FINISHED host-tier rows.
353
+ queue.set_run_context(run_id=run_id, actor=actor)
354
+ _journal_run_event(manager, SubstrateEventType.RUN_STARTED.value, run_id, actor, {
355
+ "core": "cjm-transcript-decomp-core",
356
+ "source_manifest": str(source_manifest_path),
357
+ "graph_capability": cfg.graph_capability,
358
+ })
359
+ src = load_source_manifest(source_manifest_path)
360
+ src_cfg = src.get("config", {}) or {}
361
+ transcribers = list(src_cfg.get("transcriber_capabilities") or [])
362
+ if not transcribers and src_cfg.get("transcriber_capability"):
363
+ # pre-0.2.0 manifest: single transcriber under the old key
364
+ transcribers = [str(src_cfg["transcriber_capability"])]
365
+ if not transcribers:
366
+ raise RuntimeError("source manifest lists no transcribers")
367
+ text_from = cfg.text_from or (transcribers[0] if len(transcribers) == 1 else None)
368
+ if text_from is None:
369
+ raise RuntimeError(
370
+ f"multi-transcriber manifest ({transcribers}) requires --text-from "
371
+ "(the authoritative transcriber for layer-0 text)")
372
+ if text_from not in transcribers:
373
+ raise RuntimeError(f"--text-from {text_from!r} not among the manifest's transcribers {transcribers}")
374
+ src_capabilities = src.get("capabilities", {}) or {}
375
+
376
+ manifest = DecompManifest(
377
+ run_id=run_id, created_at=time.time(), config=cfg.to_dict(),
378
+ source_manifest=str(source_manifest_path),
379
+ source_format=src.get("format", ""), source_version=src.get("version", ""),
380
+ capabilities=collect_capability_info(manager, [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]),
381
+ )
382
+ vad_config_hash = str((manifest.capabilities.get(cfg.vad_capability) or {}).get("config_hash") or "")
383
+
384
+ sources = src.get("sources", []) or []
385
+ status = "completed"
386
+ try:
387
+ for i, source in enumerate(sources):
388
+ # Extender pre-check: the transcription-emitted root must exist.
389
+ roots = resolve_root_ids(source, src_capabilities)
390
+ root = await graph_task(queue, cfg.graph_capability, "get_node", node_id=roots["source"])
391
+ if root is None:
392
+ raise RuntimeError(
393
+ f"Source root {roots['source']} not found in the graph for "
394
+ f"{source.get('source_path')!r} — the graph begins at transcription: "
395
+ "re-run cjm-transcription-core with --graph-capability against this DB first")
396
+
397
+ source_path, aligned = await decompose_source(
398
+ queue, cfg, source, i, transcribers, text_from)
399
+ title = Path(source_path).stem or f"source-{i}"
400
+
401
+ empty = sum(1 for a in aligned if not a.text.strip())
402
+ warns = [f"{empty}/{len(aligned)} aligned segment(s) have empty layer-0 text"] if empty else []
403
+ if not confirm_seam("alignment-review",
404
+ [f"{title}: {len(aligned)} aligned segment(s), text_from={text_from}"],
405
+ warns, assume_yes=cfg.assume_yes):
406
+ logger.warning(f"run {run_id}: aborted at source {i} ({source_path})")
407
+ status = "aborted"
408
+ break
409
+
410
+ nodes, edges, ids = build_extension_payload(
411
+ source, src_capabilities, vad_config_hash, text_from, aligned)
412
+
413
+ if not confirm_seam("commit-review",
414
+ [f"{title}: extending Source {ids['source'][:8]}… with "
415
+ f"{len(ids['segments'])} segment node(s) + {len(edges)} edge(s) "
416
+ f"via {cfg.graph_capability}"],
417
+ [], assume_yes=cfg.assume_yes):
418
+ logger.warning(f"run {run_id}: commit declined at source {i} ({source_path})")
419
+ status = "aborted"
420
+ break
421
+
422
+ res = await extend_graph(queue, cfg.graph_capability, nodes, edges)
423
+ logger.info(f"[src {i}] extension: +{res.nodes_added} node(s) "
424
+ f"({res.nodes_verified} verified present), +{res.edges_added} edge(s) "
425
+ f"({res.edges_existing} existing)")
426
+ if res.nodes_added:
427
+ d = Derivation(
428
+ actor="host:cjm-transcript-decomp-core", method="alignment-fold/v1",
429
+ input_ids=ids["transcripts_used"], output_ids=[ids["source"]],
430
+ properties={"run_id": run_id, "segments": len(ids["segments"]),
431
+ "text_from": text_from},
432
+ )
433
+ dn, de = derivation_to_graph(d)
434
+ await extend_graph(queue, cfg.graph_capability, [dn], de)
435
+
436
+ # Fine spine hangs under the run's renditions — verify scopes there.
437
+ vr = await verify_source(queue, cfg.graph_capability, ids["source"], ids["renditions"])
438
+ if vr is None:
439
+ logger.error(f"[src {i}] verify: Source {ids['source']} NOT FOUND in graph")
440
+ else:
441
+ logger.info(f"[src {i}] verify: {'OK' if vr.ok else 'FAILED'} "
442
+ f"(asegs={vr.audio_segment_count}, renditions={vr.rendition_count}, "
443
+ f"segments={vr.segment_count}, "
444
+ f"src_starts={vr.source_starts_with}, aseg_next={vr.aseg_next_complete}, "
445
+ f"seg_next={vr.seg_next_complete}, part_of={vr.part_of_complete}, "
446
+ f"rend_starts={vr.rendition_starts_with_complete}, "
447
+ f"timing={vr.all_have_timing}, sources={vr.all_have_sources})")
448
+
449
+ # I14: verify outcomes are journal ROWS (absence of a log line is not
450
+ # green); the full check vector rides the payload.
451
+ _journal_run_event(manager, SubstrateEventType.VERIFY_OUTCOME.value, run_id, actor, {
452
+ "core": "cjm-transcript-decomp-core",
453
+ "source_node_id": ids["source"],
454
+ "found": vr is not None,
455
+ "ok": (vr.ok if vr is not None else False),
456
+ "checks": (asdict(vr) if vr is not None else None),
457
+ })
458
+
459
+ manifest.sources.append(DecompSourceRecord(
460
+ source_node_id=ids["source"], source_path=source_path, title=title,
461
+ segment_count=len(ids["segments"]), segment_ids=ids["segments"]))
462
+ except BaseException as e:
463
+ # The journal exists for exactly this row: a run that DIED records
464
+ # how far it got (failures stop being the unattributed case).
465
+ _journal_run_event(manager, SubstrateEventType.RUN_FINISHED.value, run_id, actor, {
466
+ "core": "cjm-transcript-decomp-core", "status": "failed", "error": repr(e),
467
+ "sources_completed": len(manifest.sources), "sources_total": len(sources),
468
+ })
469
+ raise
470
+ _journal_run_event(manager, SubstrateEventType.RUN_FINISHED.value, run_id, actor, {
471
+ "core": "cjm-transcript-decomp-core", "status": status,
472
+ "sources_completed": len(manifest.sources), "sources_total": len(sources),
473
+ "segments": sum(s.segment_count for s in manifest.sources),
474
+ })
475
+ return manifest