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 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,166 @@
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
+
3
+ import re
4
+ from typing import Dict, List, Optional, Tuple
5
+
6
+ from cjm_transcript_decomp_core.models import FAWord, TextSegment, VADChunk
7
+
8
+ # Strip punctuation for comparison (matches what FA models strip).
9
+ _PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
10
+
11
+
12
+ def _strip_punct(
13
+ text: str, # Text to normalize
14
+ ) -> str: # Text with punctuation removed
15
+ """Strip punctuation from text for comparison with FA output."""
16
+ return _PUNCT_RE.sub("", text)
17
+
18
+
19
+ def map_fa_words_to_text(
20
+ text: str, # Original text with punctuation
21
+ fa_items: List[FAWord], # FA word-level alignment results
22
+ ) -> List[Tuple[int, int]]: # (start_char, end_char) spans into the original text
23
+ """Map forced-alignment words back to character spans in the original text.
24
+
25
+ Walks the original text, matching each FA word (punctuation-stripped) against
26
+ original-text tokens; returns character offset pairs for each FA word.
27
+ """
28
+ spans = []
29
+ pos = 0 # Current position in original text
30
+
31
+ for item in fa_items:
32
+ fa_word = item.text.lower()
33
+
34
+ # Skip whitespace to find next token start
35
+ while pos < len(text) and text[pos].isspace():
36
+ pos += 1
37
+ if pos >= len(text):
38
+ break
39
+
40
+ # Find the end of the current token (run of non-space characters)
41
+ token_start = pos
42
+ token_end = pos
43
+ while token_end < len(text) and not text[token_end].isspace():
44
+ token_end += 1
45
+
46
+ orig_token = text[token_start:token_end]
47
+ stripped_token = _strip_punct(orig_token).lower()
48
+
49
+ if stripped_token == fa_word:
50
+ spans.append((token_start, token_end))
51
+ pos = token_end
52
+ else:
53
+ # Multi-token FA words (e.g. "p.m." -> "pm") or punctuation-split tokens:
54
+ # consume up to 3 extra tokens until the stripped concatenation matches.
55
+ concat = stripped_token
56
+ scan_end = token_end
57
+ matched = False
58
+ for _ in range(3):
59
+ if concat.lower() == fa_word:
60
+ spans.append((token_start, scan_end))
61
+ pos = scan_end
62
+ matched = True
63
+ break
64
+ while scan_end < len(text) and text[scan_end].isspace():
65
+ scan_end += 1
66
+ if scan_end >= len(text):
67
+ break
68
+ next_start = scan_end
69
+ while scan_end < len(text) and not text[scan_end].isspace():
70
+ scan_end += 1
71
+ concat += _strip_punct(text[next_start:scan_end])
72
+
73
+ if not matched:
74
+ if concat.lower() == fa_word:
75
+ spans.append((token_start, scan_end))
76
+ pos = scan_end
77
+ else:
78
+ # Fallback: take the single token and move on (handles
79
+ # insertions/deletions between transcript and FA output).
80
+ spans.append((token_start, token_end))
81
+ pos = token_end
82
+
83
+ return spans
84
+
85
+
86
+ def assign_words_to_chunks(
87
+ fa_items: List[FAWord], # FA word-level alignment results
88
+ vad_chunks: List[VADChunk], # VAD chunks with start/end times
89
+ ) -> List[int]: # Chunk index for each FA word
90
+ """Assign each FA word to a VAD chunk by timestamp overlap.
91
+
92
+ Words whose start_time falls within a chunk's [start, end] are assigned to
93
+ that chunk; words in silence gaps go to the nearest chunk by time proximity.
94
+ """
95
+ if not vad_chunks:
96
+ return [0] * len(fa_items)
97
+
98
+ assignments = []
99
+ for item in fa_items:
100
+ t = item.start_time
101
+ best_idx = 0
102
+ best_dist = float("inf")
103
+ for i, chunk in enumerate(vad_chunks):
104
+ if chunk.start_time <= t <= chunk.end_time:
105
+ best_idx = i
106
+ break
107
+ dist = min(abs(t - chunk.start_time), abs(t - chunk.end_time))
108
+ if dist < best_dist:
109
+ best_dist = dist
110
+ best_idx = i
111
+ assignments.append(best_idx)
112
+ return assignments
113
+
114
+
115
+ def build_segments_from_alignment(
116
+ text: str, # Original text with punctuation
117
+ spans: List[Tuple[int, int]], # Character spans from map_fa_words_to_text
118
+ assignments: List[int], # Chunk index per word from assign_words_to_chunks
119
+ num_chunks: int, # Total number of VAD chunks
120
+ source_id: Optional[str] = None, # Source row id for traceability
121
+ source_provider_id: Optional[str] = None, # Source provider identifier
122
+ ) -> List[TextSegment]: # One segment per VAD chunk
123
+ """Build a TextSegment per VAD chunk by grouping words by chunk assignment.
124
+
125
+ Each chunk's text is the original (punctuated) slice from the first to the
126
+ last word assigned to it; chunks with no words become empty segments.
127
+ """
128
+ chunk_spans: Dict[int, List[Tuple[int, int]]] = {}
129
+ for span, chunk_idx in zip(spans, assignments):
130
+ chunk_spans.setdefault(chunk_idx, []).append(span)
131
+
132
+ segments = []
133
+ for chunk_idx in range(num_chunks):
134
+ word_spans = chunk_spans.get(chunk_idx, [])
135
+ if word_spans:
136
+ seg_start = word_spans[0][0]
137
+ seg_end = word_spans[-1][1]
138
+ seg_text = text[seg_start:seg_end].strip()
139
+ else:
140
+ seg_text = ""
141
+ seg_start = None
142
+ seg_end = None
143
+ segments.append(TextSegment(
144
+ index=chunk_idx, text=seg_text,
145
+ source_id=source_id, source_provider_id=source_provider_id,
146
+ start_char=seg_start, end_char=seg_end,
147
+ ))
148
+ return segments
149
+
150
+
151
+ def tier1_alignment_checks(
152
+ segments: List[TextSegment], # Segments produced by build_segments_from_alignment
153
+ vad_chunks: List[VADChunk], # The VAD chunks they were aligned to
154
+ ) -> List[str]: # Human-readable warnings (empty = all clear)
155
+ """Tier-1 deterministic pre-filters for the alignment-review seam (no AI)."""
156
+ warnings: List[str] = []
157
+ if len(segments) != len(vad_chunks):
158
+ warnings.append(
159
+ f"segment/chunk count mismatch: {len(segments)} vs {len(vad_chunks)}"
160
+ )
161
+ empty = sum(1 for s in segments if not s.text.strip())
162
+ if empty:
163
+ warnings.append(
164
+ f"{empty}/{len(segments)} segment(s) have EMPTY text (VAD chunk with no aligned words)"
165
+ )
166
+ return warnings
@@ -0,0 +1,155 @@
1
+ """The CLI driver — the decomposition core's first (and currently only) frontend.
2
+
3
+ Ships in-package as the `cjm-transcript-decomp-core` console script so the driver
4
+ can never skew from the core. GUI presentation drivers come later and consume the
5
+ same `pipeline` module; they never reimplement it (CLI-first / headless-core
6
+ principle).
7
+
8
+ Prerequisite runtime (once, from the repo root):
9
+
10
+ cjm-ctl --cjm-config cjm.yaml setup-runtime
11
+ cjm-ctl --cjm-config cjm.yaml install-all --capabilities capabilities_test.yaml --force
12
+
13
+ Then decompose a transcription-core run manifest:
14
+
15
+ cjm-transcript-decomp-core run path/to/transcription-run.json --yes
16
+ # GPU runs: opt into CR-7 GPU subtree attribution
17
+ cjm-transcript-decomp-core run run.json --yes --sysmon-capability cjm-capability-monitor-nvidia
18
+ """
19
+
20
+ import argparse
21
+ import asyncio
22
+ import getpass
23
+ import logging
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ from cjm_substrate.core.manager import CapabilityManager
28
+ from cjm_substrate.core.queue import JobQueue
29
+ from cjm_transcript_decomp_core.models import DecompConfig
30
+ from cjm_transcript_decomp_core.pipeline import load_source_manifest, run_decomp
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
36
+ """Build the CLI parser (subcommands: run)."""
37
+ parser = argparse.ArgumentParser(
38
+ prog="cjm-transcript-decomp-core",
39
+ description="Headless transcript decomposition: VAD + forced alignment -> fine-spine graph extension.",
40
+ )
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ run = sub.add_parser("run", help="Extend a transcription-emitted graph root with the fine spine")
44
+ run.add_argument("manifest", help="Transcription-core run manifest JSON (the source to decompose)")
45
+ run.add_argument("--manifests-dir", default=".cjm/manifests", help="Capability manifests directory")
46
+ run.add_argument("--vad-capability", default="cjm-capability-silero-vad", help="VAD capability name")
47
+ run.add_argument("--fa-capability", default="cjm-capability-qwen3-forced-aligner", help="Forced-alignment capability name")
48
+ run.add_argument("--graph-capability", default="cjm-capability-graph-sqlite", help="Graph-storage capability name")
49
+ run.add_argument("--graph-db-path", default=None,
50
+ help="Explicit graph DB path override (F10; scratch-graph loop-backs; default: the capability's configured db_path)")
51
+ run.add_argument("--text-from", default=None,
52
+ help="Authoritative transcriber for layer-0 text (the ACCURACY model; "
53
+ "required when the manifest carries multiple transcribers; "
54
+ "default: the manifest's sole transcriber)")
55
+ run.add_argument("--sysmon-capability", default=None, help="monitor capability for GPU subtree attribution (CR-7); loaded first; default: no monitor")
56
+ run.add_argument("--language", default="English", help="Forced-alignment language")
57
+ run.add_argument("--force", action="store_true", help="Bypass capability-side caches (VAD + FA)")
58
+ run.add_argument("-y", "--yes", action="store_true", help="Auto-accept HITL seams (headless mode)")
59
+ run.add_argument("--output", default=None, help="Decomp-manifest output path (default: runs/<run_id>.json)")
60
+ run.add_argument("--actor", default=None,
61
+ help="Journal attribution for who/what initiated this run (default: cli:<username>)")
62
+ run.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
63
+ return parser
64
+
65
+
66
+ def load_capabilities(
67
+ manager: CapabilityManager, # Freshly constructed manager
68
+ instance_ids: List[str], # Capability names to load (default instances), in order
69
+ configs: Optional[Dict[str, Dict[str, Any]]] = None, # Per-capability config overrides (caller-wins, C8)
70
+ ) -> None:
71
+ """Discover manifests + load each requested capability (default instance)."""
72
+ manager.discover_manifests()
73
+ discovered = {m.name: m for m in manager.discovered}
74
+ for iid in instance_ids:
75
+ meta = discovered.get(iid)
76
+ if meta is None:
77
+ raise SystemExit(
78
+ f"capability {iid!r} not found in manifests "
79
+ f"(discovered: {sorted(discovered)}) — run cjm-ctl install-all first"
80
+ )
81
+ if not manager.load_capability(meta, config=(configs or {}).get(iid)):
82
+ raise SystemExit(f"failed to load capability {iid!r}")
83
+ logger.info(f"loaded {iid}")
84
+
85
+
86
+ async def run_command(
87
+ args: argparse.Namespace, # Parsed CLI arguments for the `run` subcommand
88
+ ) -> int: # Process exit code (0 = all sources extended + verified)
89
+ """Execute the `run` subcommand: extend a transcription-emitted root with the fine spine."""
90
+ manifest_path = str(Path(args.manifest).resolve())
91
+ if not Path(manifest_path).exists():
92
+ raise SystemExit(f"source manifest not found: {manifest_path}")
93
+
94
+ cfg = DecompConfig(
95
+ vad_capability=args.vad_capability,
96
+ fa_capability=args.fa_capability,
97
+ graph_capability=args.graph_capability,
98
+ text_from=args.text_from,
99
+ language=args.language,
100
+ force=args.force,
101
+ assume_yes=args.yes,
102
+ )
103
+
104
+ # CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
105
+ # name into BOTH the manager and the queue; the monitor loads FIRST so GPU
106
+ # capabilities' samples record gpu_memory_mb_peak (voxtral-vllm e2e pattern).
107
+ manager = CapabilityManager(
108
+ search_paths=[Path(args.manifests_dir)],
109
+ sysmon_capability_name=args.sysmon_capability,
110
+ )
111
+ instance_ids = [cfg.vad_capability, cfg.fa_capability, cfg.graph_capability]
112
+ load_order = ([args.sysmon_capability] if args.sysmon_capability else []) + instance_ids
113
+ # F10: --graph-db-path threads a caller-wins config into the graph load
114
+ # (the C8 pattern correction-core already used; scratch-graph loop-backs).
115
+ configs = ({cfg.graph_capability: {"db_path": args.graph_db_path}}
116
+ if args.graph_db_path else None)
117
+ load_capabilities(manager, load_order, configs=configs)
118
+
119
+ queue = JobQueue(deps=manager, sysmon_capability_name=args.sysmon_capability)
120
+ await queue.start()
121
+ try:
122
+ # CR-14 follow-up: actor attribution (operator identity by default;
123
+ # agents/services pass --actor explicitly).
124
+ actor = args.actor or f"cli:{getpass.getuser()}"
125
+ manifest = await run_decomp(manager, queue, cfg, manifest_path, actor=actor)
126
+ finally:
127
+ await queue.stop()
128
+ for iid in reversed(load_order): # Reverse load order; the monitor unloads last
129
+ try:
130
+ manager.unload_capability(iid)
131
+ except Exception as e: # Best-effort teardown; never mask the run's outcome
132
+ logger.warning(f"unload {iid} failed: {e}")
133
+
134
+ out = Path(args.output) if args.output else Path("runs") / f"{manifest.run_id}.json"
135
+ manifest.save(out)
136
+ n_manifest_sources = len(load_source_manifest(manifest_path).get("sources", []) or [])
137
+ n_sources = len(manifest.sources)
138
+ n_segs = sum(s.segment_count for s in manifest.sources)
139
+ print(f"decomp manifest: {out}")
140
+ print(f"sources extended: {n_sources}/{n_manifest_sources} segment nodes: {n_segs}")
141
+ return 0 if n_sources == n_manifest_sources else 1
142
+
143
+
144
+ def main(
145
+ argv: Optional[List[str]] = None, # Argument list override (None = sys.argv)
146
+ ) -> int: # Process exit code
147
+ """CLI entry point (console script: `cjm-transcript-decomp-core`)."""
148
+ args = build_parser().parse_args(argv)
149
+ logging.basicConfig(
150
+ level=logging.DEBUG if args.verbose else logging.INFO,
151
+ format="%(asctime)s [%(levelname)s] %(name)s :: %(message)s",
152
+ )
153
+ if args.command == "run":
154
+ return asyncio.run(run_command(args))
155
+ raise SystemExit(f"unknown command: {args.command}")
@@ -0,0 +1,245 @@
1
+ """Graph-spine EXTENSION + skeptical-lens verification (stage 5, CR-18 revolution 2). Decomp no longer creates a Document: it RECOMPUTES the transcription-emitted root's deterministic node ids from the consumed manifest (no search), verifies the root exists, and attaches the fine Segment spine under the existing AudioSegment nodes — PART_OF to the owning rendition, STARTS_WITH per rendition (the coarse-seam jump anchor), source-wide NEXT. Each Segment carries the audio TimeSlice ref plus per-transcriber CharSlice refs into the Transcript nodes (the D4/P10 framing, finally expressible). Commit goes through the layer's idempotent extend_graph."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from cjm_context_graph_layer.grammar import grouped_spine_edges, SpineRelations
7
+ from cjm_context_graph_layer.ops import graph_task
8
+ from cjm_context_graph_primitives.graph import GraphNode
9
+ from cjm_context_graph_primitives.provenance import SourceRef
10
+ from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
11
+ NodeQueryResult, OrderBy, PropertyPredicate,
12
+ RelationPredicate)
13
+ from cjm_substrate.core.queue import JobQueue
14
+ from cjm_transcript_decomp_core.models import DecompSegment
15
+ from cjm_transcript_graph_schema.schema import (audio_rendition_node_id, audio_segment_node_id,
16
+ SegmentNode, source_node_id, transcript_node_id,
17
+ TranscriptGraphLabels, TranscriptSliceRef)
18
+
19
+ # Stage 4: typed query expressions + results — importing the result classes IS
20
+ # the host-side wire registration (F8); the tuple keeps these SIDE-EFFECT
21
+ # imports referenced so the canonical emit cannot prune them. Stage 5: the
22
+ # graph-aware layer owns the shared plumbing (graph_task is THE shared copy
23
+ # now) and the fractal spine grammar; the audio-transcript schema owns the
24
+ # node nouns + deterministic identity tuples. cjm_graph_domains is GONE
25
+ # (Document dissolved).
26
+ _REGISTERED_WIRE_KINDS = (NodeQueryResult, EdgeQueryResult)
27
+
28
+
29
+ def resolve_root_ids(
30
+ source_entry: Dict[str, Any], # One source entry from the transcription manifest (0.3.0)
31
+ capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block (config hashes)
32
+ ) -> Dict[str, Any]: # {"source", "chain", "audio_segments": [{audio_segment, rendition, start, end, model_input_hash, transcripts}]}
33
+ """Recompute the transcription-emitted root node ids from manifest data.
34
+
35
+ Deterministic identity makes the extender lookup a RECOMPUTATION, not a
36
+ search: Source = content hash; AudioSegment = (source, boundary range);
37
+ AudioRendition = (audio segment, preprocessing chain) — the per-source
38
+ `chain` ([] = raw convert-only) carried by the 0.3.0 manifest; Transcript =
39
+ (rendition, transcriber, config_hash). No stored-id coupling between the
40
+ cores. A pre-0.3.0 manifest (no `chain`) recomputes the raw rendition ids
41
+ (empty chain), which is correct for any non-preprocessed run.
42
+ """
43
+ content_hash = str(source_entry.get("content_hash") or "")
44
+ if not content_hash:
45
+ raise ValueError(
46
+ f"source {source_entry.get('source_path')!r} has no content_hash — "
47
+ "re-run transcription on the 0.3.0 manifest schema")
48
+ source_id = source_node_id(content_hash)
49
+ chain = list(source_entry.get("chain") or []) # [] = raw convert-only rendition
50
+ asegs: List[Dict[str, Any]] = []
51
+ for pseg in source_entry.get("segments") or []:
52
+ start, end = float(pseg.get("start", 0.0)), float(pseg.get("end", 0.0))
53
+ aseg_id = audio_segment_node_id(source_id, start, end)
54
+ rendition_id = audio_rendition_node_id(aseg_id, chain)
55
+ transcripts = {
56
+ t: transcript_node_id(rendition_id, t, str((capabilities_info.get(t) or {}).get("config_hash") or ""))
57
+ for t in (pseg.get("transcripts") or {})
58
+ }
59
+ asegs.append({"audio_segment": aseg_id, "rendition": rendition_id,
60
+ "start": start, "end": end,
61
+ "model_input_hash": str(pseg.get("model_input_hash") or ""),
62
+ "transcripts": transcripts})
63
+ return {"source": source_id, "chain": chain, "audio_segments": asegs}
64
+
65
+
66
+ def build_extension_payload(
67
+ source_entry: Dict[str, Any], # One source entry from the transcription manifest
68
+ capabilities_info: Dict[str, Dict[str, Any]], # The transcription manifest's capabilities block
69
+ vad_config_hash: str, # THIS run's VAD capability config hash (skeleton identity input)
70
+ text_from: str, # Authoritative transcriber (layer-0 text designation)
71
+ segments: List[DecompSegment], # Ordered aligned segments (per-transcriber variants attached)
72
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]: # (nodes, edges, ids)
73
+ """Build the fine-spine EXTENSION payload (pure; no capability calls).
74
+
75
+ Segment identity is audio-side (audio RENDITION, VAD config, chunk range) —
76
+ shared across transcribers by construction, distinct per rendition (vocals
77
+ isolation can yield different VAD chunking than raw). Each Segment carries
78
+ the audio `TimeSlice` ref into its rendition + one `CharSlice` ref per
79
+ transcriber variant; `text_from` is recorded per segment as provenance
80
+ (which Transcript the layer-0 text came from), never as global config. Edges
81
+ come from `grouped_spine_edges`: PART_OF to the OWNING AudioRendition,
82
+ STARTS_WITH per rendition, NEXT chained source-wide across coarse boundaries.
83
+ """
84
+ roots = resolve_root_ids(source_entry, capabilities_info)
85
+ source_id = roots["source"]
86
+ asegs = roots["audio_segments"]
87
+
88
+ nodes: List[Dict[str, Any]] = []
89
+ seg_ids: List[str] = []
90
+ groups: Dict[int, List[str]] = {}
91
+ used_transcripts: set = set()
92
+ for seg in segments:
93
+ a = asegs[seg.pseg_index]
94
+ slices: List[TranscriptSliceRef] = []
95
+ for var in seg.variants:
96
+ tid = a["transcripts"].get(var.transcriber)
97
+ if tid is None or not var.text or var.start_char is None or var.end_char is None:
98
+ continue
99
+ slices.append(TranscriptSliceRef(transcript=tid, start_char=var.start_char,
100
+ end_char=var.end_char, text=var.text))
101
+ used_transcripts.add(tid)
102
+ node = SegmentNode(
103
+ rendition=a["rendition"], vad_config_hash=vad_config_hash,
104
+ chunk_start=seg.chunk_start, chunk_end=seg.chunk_end,
105
+ index=seg.index, start_time=seg.start_time, end_time=seg.end_time,
106
+ text=seg.text, audio_hash=a["model_input_hash"], source=source_id,
107
+ text_from=(a["transcripts"].get(text_from) if seg.text.strip() else None),
108
+ text_slices=slices,
109
+ )
110
+ nodes.append(node.to_graph_node())
111
+ seg_ids.append(node.id)
112
+ groups.setdefault(seg.pseg_index, []).append(node.id)
113
+
114
+ # The fine spine hangs under each segment's RENDITION (PART_OF rendition).
115
+ edges = grouped_spine_edges([(asegs[i]["rendition"], groups[i]) for i in sorted(groups)])
116
+ ids = {"source": source_id, "segments": seg_ids,
117
+ "audio_segments": [a["audio_segment"] for a in asegs],
118
+ "renditions": [a["rendition"] for a in asegs],
119
+ "transcripts_used": sorted(used_transcripts)}
120
+ return nodes, edges, ids
121
+
122
+
123
+ @dataclass
124
+ class SourceVerification:
125
+ """Skeptical-lens verification of one Source's fine-spine extension under a
126
+ specific rendition set, computed by querying the graph directly (never
127
+ trusting run state)."""
128
+ source_id: str # Verified Source node id
129
+ title: str # Source title (read back from the graph)
130
+ audio_segment_count: int # AudioSegment nodes found under the Source (coarse spine; rendition-independent)
131
+ rendition_count: int # AudioRendition nodes the fine spine was scoped to (the run's extended renditions)
132
+ segment_count: int # Fine Segment nodes found under those renditions
133
+ source_starts_with: bool # Exactly 1 STARTS_WITH Source -> first AudioSegment
134
+ aseg_next_complete: bool # Coarse NEXT count == audio_segment_count - 1
135
+ seg_next_complete: bool # Fine NEXT count == segment_count - 1 (source-wide chain)
136
+ part_of_complete: bool # Fine PART_OF count == segment_count (Segment -> rendition)
137
+ rendition_starts_with_complete: bool # STARTS_WITH from renditions == #renditions owning >=1 Segment
138
+ all_have_timing: bool # Every Segment has start_time + end_time
139
+ all_have_sources: bool # Every Segment has >=1 SourceRef (the audio ref at minimum)
140
+ source_locators: List[str] = field(default_factory=list) # Distinct provenance locator URIs
141
+
142
+ @property
143
+ def ok(self) -> bool: # True when every structural check passes
144
+ """All structural checks pass."""
145
+ return (self.source_starts_with and self.aseg_next_complete
146
+ and self.seg_next_complete and self.part_of_complete
147
+ and self.rendition_starts_with_complete and self.all_have_timing
148
+ and self.all_have_sources)
149
+
150
+
151
+ async def verify_source(
152
+ queue: JobQueue, # Started job queue
153
+ graph_id: str, # Graph-storage capability id
154
+ source_id: str, # Source node id to verify
155
+ rendition_ids: List[str], # The AudioRendition ids the run extended (the fine spine is scoped to these)
156
+ ) -> Optional[SourceVerification]: # Result, or None if the Source is not found
157
+ """Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
158
+
159
+ Two-layer verification for the Source-rooted schema: the coarse spine
160
+ (Source → AudioSegments) is checked with id-list edge counts (rendition-
161
+ independent); the fine spine hangs under the run's AudioRendition set, so it
162
+ is scoped through the batched far-end constraint
163
+ `RelationPredicate("PART_OF", node_ids=rendition_ids)` (the C17 batch shape) —
164
+ never a whole-neighborhood materialization, and never mixing a sibling
165
+ rendition's spine (raw vs vocals coexist under the same AudioSegments). One
166
+ bounded projection pass (sources + rendition_id) yields missing-sources,
167
+ distinct locators, AND the per-rendition ownership set for the STARTS_WITH
168
+ check.
169
+ """
170
+ src = await graph_task(queue, graph_id, "get_node", node_id=source_id)
171
+ if src is None:
172
+ return None
173
+ props = src.properties if isinstance(src, GraphNode) else ((src or {}).get("properties") or {})
174
+
175
+ async def _ecount(**kw):
176
+ q = EdgeQuery(count=True, **kw)
177
+ res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
178
+ return int(res.count or 0)
179
+
180
+ # Coarse layer: ordered AudioSegment ids under the Source (rendition-independent).
181
+ aq = NodeQuery(label=TranscriptGraphLabels.AUDIO_SEGMENT,
182
+ related=RelationPredicate(SpineRelations.PART_OF, node_id=source_id),
183
+ order_by=OrderBy("index"), project=["index"])
184
+ ares = await graph_task(queue, graph_id, "query_nodes", query=aq.to_dict())
185
+ aseg_ids = [r["id"] for r in (ares.rows or [])]
186
+ n_asegs = len(aseg_ids)
187
+ rendition_ids = list(rendition_ids or [])
188
+ if not aseg_ids or not rendition_ids:
189
+ return SourceVerification(
190
+ source_id=source_id, title=props.get("title", "Untitled"),
191
+ audio_segment_count=n_asegs, rendition_count=len(rendition_ids),
192
+ segment_count=0, source_starts_with=False,
193
+ aseg_next_complete=False, seg_next_complete=False, part_of_complete=False,
194
+ rendition_starts_with_complete=False, all_have_timing=False, all_have_sources=False)
195
+
196
+ src_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_id=source_id)
197
+ aseg_next = await _ecount(relation_type=SpineRelations.NEXT, source_ids=aseg_ids)
198
+
199
+ # Fine layer, scoped via the batched far-end constraint onto THIS run's renditions.
200
+ part_of_rend = RelationPredicate(SpineRelations.PART_OF, node_ids=rendition_ids)
201
+
202
+ async def _ncount(**kw):
203
+ q = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend, count=True, **kw)
204
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
205
+ return int(res.count or 0)
206
+
207
+ n_segs = await _ncount()
208
+ seg_next = await _ecount(relation_type=SpineRelations.NEXT, source_related=part_of_rend)
209
+ seg_part_of = await _ecount(relation_type=SpineRelations.PART_OF, target_ids=rendition_ids)
210
+ rend_starts = await _ecount(relation_type=SpineRelations.STARTS_WITH, source_ids=rendition_ids)
211
+ missing_start = await _ncount(where=[PropertyPredicate("start_time", "is_null")])
212
+ missing_end = await _ncount(where=[PropertyPredicate("end_time", "is_null")])
213
+
214
+ # Bounded projection: sources + owning rendition in ONE pass.
215
+ sq = NodeQuery(label=TranscriptGraphLabels.SEGMENT, related=part_of_rend,
216
+ project=["sources", "rendition_id"])
217
+ res = await graph_task(queue, graph_id, "query_nodes", query=sq.to_dict())
218
+ missing_sources = 0
219
+ locators = set()
220
+ owning_renditions = set()
221
+ for r in (res.rows or []):
222
+ sources = r.get("sources") or []
223
+ if not sources:
224
+ missing_sources += 1
225
+ for s in sources:
226
+ ref = SourceRef.from_dict(s) if isinstance(s, dict) else s
227
+ locators.add(ref.locator.to_uri())
228
+ if r.get("rendition_id"):
229
+ owning_renditions.add(r["rendition_id"])
230
+
231
+ return SourceVerification(
232
+ source_id=source_id,
233
+ title=props.get("title", "Untitled"),
234
+ audio_segment_count=n_asegs,
235
+ rendition_count=len(rendition_ids),
236
+ segment_count=n_segs,
237
+ source_starts_with=src_starts == 1,
238
+ aseg_next_complete=aseg_next == max(0, n_asegs - 1),
239
+ seg_next_complete=seg_next == max(0, n_segs - 1),
240
+ part_of_complete=seg_part_of == n_segs,
241
+ rendition_starts_with_complete=rend_starts == len(owning_renditions),
242
+ all_have_timing=(missing_start == 0 and missing_end == 0),
243
+ all_have_sources=missing_sources == 0,
244
+ source_locators=sorted(locators),
245
+ )