cjm-transcript-correction-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,783 @@
1
+ """The correction overlay's graph I/O: targeted (scale-shaped) reads of a committed spine via the graph-storage query action, construction of Correction / CorrectionSession nodes + CORRECTS / SUPERSEDES / DERIVED_FROM / REVIEWED edges, the in-core effective-spine projection (layer-0 + applied corrections), and commit through the job queue. Hand-rolled (revolution-1) = direct CR-18 spec material; append-only on layer-0 (never update/delete a Segment)."""
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+ from uuid import uuid4
6
+
7
+ from cjm_context_graph_layer.edits import (project_effective_spine as layer_project_effective_spine,
8
+ resolve_active, SpineEdit, SpineUnit)
9
+ from cjm_context_graph_layer.grammar import make_edge, OverlayRelations, SpineRelations
10
+ from cjm_context_graph_layer.ops import extend_graph, graph_task
11
+ from cjm_context_graph_primitives.graph import GraphNode
12
+ from cjm_context_graph_primitives.locators import locator_from_dict
13
+ from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
14
+ NodeQueryResult, OrderBy, PropertyPredicate,
15
+ RelationPredicate, SourcePredicate)
16
+ from cjm_substrate.core.queue import JobQueue, JobStatus
17
+ from cjm_transcript_correction_core.models import (Correction, CorrectionRelations,
18
+ CorrectionSession, SpineSegment)
19
+ from cjm_transcript_graph_schema.schema import TranscriptGraphLabels
20
+
21
+ # Stage 4: the typed query surface — importing the result classes IS the
22
+ # host-side wire registration (F8); the tuple keeps these SIDE-EFFECT imports
23
+ # referenced so the canonical emit cannot prune them. Stage 5 (CR-18): the
24
+ # layer owns the shared plumbing + the effective-view machinery this core
25
+ # hand-rolled at revolution 1 (C5/C11/C16 migrated).
26
+ _REGISTERED_WIRE_KINDS = (NodeQueryResult, EdgeQueryResult)
27
+
28
+
29
+ async def submit_and_wait(
30
+ queue: JobQueue, # Started job queue
31
+ instance_id: str, # Capability instance to invoke
32
+ *,
33
+ timeout: Optional[float] = None, # Seconds to wait; None = no limit
34
+ **kwargs, # Forwarded to the capability action
35
+ ) -> Any: # Completed job result payload
36
+ """Submit one capability job, wait for it, return its result (raise on failure).
37
+
38
+ (Restored as its own cell after the stage-2 field_of retirement removed
39
+ the shared cell that bundled both functions — the loop-back harness
40
+ caught the casualty; one-fn-per-cell prevents the recurrence.)
41
+ """
42
+ job_id = await queue.submit(instance_id, **kwargs)
43
+ job = await queue.wait_for_job(job_id, timeout=timeout)
44
+ if job.status != JobStatus.completed:
45
+ raise RuntimeError(f"{instance_id} job {job_id} {job.status}: {job.error}")
46
+ return job.result
47
+
48
+
49
+ # Targeted, scale-shaped reads of one SOURCE's fine spine. The fine spine hangs
50
+ # under an AudioRendition (raw | vocals | ...) under an AudioSegment under the
51
+ # Source, so scoping is: coarse read (AudioSegments) -> rendition resolve (which
52
+ # rendition's spine) -> batched far-end constraint over the rendition ids.
53
+
54
+ _SPINE_PROJECTION = ["index", "text", "start_time", "end_time", "text_from", "sources"] # + structural "id"
55
+
56
+
57
+ def _row_to_spine_segment(row: Dict[str, Any]) -> SpineSegment: # One projected row -> SpineSegment
58
+ """Build a SpineSegment from a projected row (audio anchor + per-transcriber slices)."""
59
+ sources = row.get("sources") or []
60
+ text_from = row.get("text_from")
61
+ audio = next((s for s in sources if (s.get("slice") or {}).get("kind") == "time"), None)
62
+ slices: List[Dict[str, Any]] = []
63
+ auth_hash = None
64
+ for s in sources:
65
+ sl = s.get("slice") or {}
66
+ if sl.get("kind") != "char":
67
+ continue
68
+ tid = (s.get("locator") or {}).get("node_id")
69
+ slices.append({"transcript": tid, "start": sl.get("start"), "end": sl.get("end"),
70
+ "content_hash": s.get("content_hash")})
71
+ if tid and tid == text_from:
72
+ auth_hash = s.get("content_hash")
73
+ idx = row.get("index")
74
+ return SpineSegment(
75
+ id=row["id"], index=int(idx) if idx is not None else -1,
76
+ text=row.get("text") or "", start_time=row.get("start_time"),
77
+ end_time=row.get("end_time"),
78
+ source_locator=(str(locator_from_dict(audio["locator"])) if audio and audio.get("locator") else None),
79
+ content_hash=auth_hash, text_from=text_from, text_slices=slices,
80
+ )
81
+
82
+
83
+ async def source_audio_segment_ids(
84
+ queue: JobQueue, # Started job queue
85
+ graph_id: str, # Graph-storage capability id
86
+ source_id: str, # Source node id
87
+ ) -> List[str]: # Ordered AudioSegment node ids under the Source
88
+ """The Source's coarse spine (one small typed read; ordered by index)."""
89
+ q = NodeQuery(label=TranscriptGraphLabels.AUDIO_SEGMENT,
90
+ related=RelationPredicate(SpineRelations.PART_OF, node_id=source_id),
91
+ order_by=OrderBy(prop="index"), project=["index"])
92
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
93
+ return [r["id"] for r in (res.rows or [])]
94
+
95
+
96
+ def _rendition_label(r: Dict[str, Any]) -> str: # Human-readable rendition label
97
+ """A rendition's selector/display label ("raw" or its preprocessing chain)."""
98
+ if r.get("is_raw"):
99
+ return "raw"
100
+ return str(r.get("preprocessing") or "|".join(r.get("chain") or []) or "raw")
101
+
102
+
103
+ async def resolve_source_renditions(
104
+ queue: JobQueue, # Started job queue
105
+ graph_id: str, # Graph-storage capability id
106
+ source_id: str, # Source node id
107
+ selector: Optional[str] = None, # Which rendition: "raw" | a preprocessing-descriptor substring | None = auto
108
+ ) -> List[str]: # The AudioRendition ids whose fine spine to operate on (one chain group)
109
+ """Pick the AudioRendition set whose fine Segment spine correction operates on.
110
+
111
+ The fine spine hangs under renditions (raw | vocals | ...) that COEXIST under
112
+ one AudioSegment. With ONE decomposed rendition (the common case) it is
113
+ selected automatically; multiple decomposed renditions REQUIRE an explicit
114
+ `selector` ("raw", or a substring of the preprocessing descriptor) — the
115
+ spines are never silently mixed. Returns the rendition ids of the chosen
116
+ chain group (one per AudioSegment), or [] when nothing is decomposed yet.
117
+ """
118
+ aseg_ids = await source_audio_segment_ids(queue, graph_id, source_id)
119
+ if not aseg_ids:
120
+ return []
121
+ rq = NodeQuery(label=TranscriptGraphLabels.AUDIO_RENDITION,
122
+ related=RelationPredicate(OverlayRelations.DERIVED_FROM, node_ids=aseg_ids),
123
+ project=["chain", "is_raw", "preprocessing"])
124
+ rres = await graph_task(queue, graph_id, "query_nodes", query=rq.to_dict())
125
+ rends = [{"id": r["id"], "chain": tuple(r.get("chain") or []),
126
+ "is_raw": bool(r.get("is_raw")), "preprocessing": r.get("preprocessing")}
127
+ for r in (rres.rows or [])]
128
+ if not rends:
129
+ return []
130
+ by_chain: Dict[tuple, List[Dict[str, Any]]] = {}
131
+ for r in rends:
132
+ by_chain.setdefault(r["chain"], []).append(r)
133
+ labels = sorted({_rendition_label(rs[0]) for rs in by_chain.values()})
134
+
135
+ if selector is not None:
136
+ sel = selector.strip().lower()
137
+ for rs in by_chain.values():
138
+ if (sel == "raw" and rs[0]["is_raw"]) or \
139
+ (rs[0].get("preprocessing") and sel in str(rs[0]["preprocessing"]).lower()):
140
+ return [r["id"] for r in rs]
141
+ raise ValueError(f"--rendition {selector!r} matches no rendition for source "
142
+ f"{source_id} (available: {labels})")
143
+
144
+ # Auto: the chain group that actually carries Segments.
145
+ populated = await _populated_rendition_ids(queue, graph_id, [r["id"] for r in rends])
146
+ populated_chains = {r["chain"] for r in rends if r["id"] in populated}
147
+ if not populated_chains:
148
+ return []
149
+ if len(populated_chains) == 1:
150
+ ck = next(iter(populated_chains))
151
+ return [r["id"] for r in by_chain[ck]]
152
+ pop_labels = sorted({_rendition_label(by_chain[c][0]) for c in populated_chains})
153
+ raise ValueError(f"source {source_id} has multiple decomposed renditions ({pop_labels}); "
154
+ "pass --rendition to choose which spine to correct")
155
+
156
+
157
+ async def _populated_rendition_ids(
158
+ queue: JobQueue, # Started job queue
159
+ graph_id: str, # Graph-storage capability id
160
+ rendition_ids: List[str], # Candidate rendition ids
161
+ ) -> set: # Subset that own >=1 fine Segment
162
+ """Which renditions actually carry a fine Segment spine (one batched read)."""
163
+ if not rendition_ids:
164
+ return set()
165
+ q = NodeQuery(label=TranscriptGraphLabels.SEGMENT,
166
+ related=RelationPredicate(SpineRelations.PART_OF, node_ids=list(rendition_ids)),
167
+ project=["rendition_id"])
168
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
169
+ return {r.get("rendition_id") for r in (res.rows or []) if r.get("rendition_id")}
170
+
171
+
172
+ def _spine_query(
173
+ rendition_ids: List[str], # The AudioRendition ids the spine hangs under
174
+ **overrides, # NodeQuery field overrides (where / count / limit / ...)
175
+ ) -> NodeQuery: # The source-spine read
176
+ """Segments PART_OF the chosen AudioRenditions (batched far-end), ordered by index."""
177
+ base: Dict[str, Any] = dict(
178
+ label=TranscriptGraphLabels.SEGMENT,
179
+ related=RelationPredicate(SpineRelations.PART_OF, node_ids=list(rendition_ids)),
180
+ order_by=OrderBy(prop="index"),
181
+ project=list(_SPINE_PROJECTION),
182
+ )
183
+ base.update(overrides)
184
+ return NodeQuery(**base)
185
+
186
+
187
+ async def load_source_segments(
188
+ queue: JobQueue, # Started job queue
189
+ graph_id: str, # Graph-storage capability id
190
+ source_id: str, # Source node id
191
+ limit: Optional[int] = None, # Optional page size
192
+ offset: Optional[int] = None, # Optional page offset
193
+ rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
194
+ ) -> List[SpineSegment]: # Ordered spine segments (by index)
195
+ """Load a Source's fine Segment spine under its chosen rendition (typed query surface)."""
196
+ rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
197
+ if not rendition_ids:
198
+ return []
199
+ q = _spine_query(rendition_ids, limit=limit, offset=int(offset or 0))
200
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
201
+ return [_row_to_spine_segment(r) for r in (res.rows or [])]
202
+
203
+
204
+ async def load_empty_segments(
205
+ queue: JobQueue, # Started job queue
206
+ graph_id: str, # Graph-storage capability id
207
+ source_id: str, # Source node id
208
+ rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
209
+ ) -> List[SpineSegment]: # Only the empty-text segments (server-side filtered)
210
+ """Load ONLY a Source's empty-text segments under its chosen rendition (D14 prune).
211
+
212
+ The evidenced OR case (text = '' OR text IS NULL) = TWO server-side-filtered
213
+ queries unioned client-side — compound boolean predicates stay deferred (P8);
214
+ both halves materialize ~10% of the spine, never the whole source.
215
+ """
216
+ rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
217
+ if not rendition_ids:
218
+ return []
219
+ r1 = await graph_task(queue, graph_id, "query_nodes", query=_spine_query(
220
+ rendition_ids, where=[PropertyPredicate("text", "eq", "")]).to_dict())
221
+ r2 = await graph_task(queue, graph_id, "query_nodes", query=_spine_query(
222
+ rendition_ids, where=[PropertyPredicate("text", "is_null")]).to_dict())
223
+ segs = [_row_to_spine_segment(r) for r in (r1.rows or []) + (r2.rows or [])]
224
+ segs.sort(key=lambda s: s.index)
225
+ return segs
226
+
227
+
228
+ async def count_source_segments(
229
+ queue: JobQueue, # Started job queue
230
+ graph_id: str, # Graph-storage capability id
231
+ source_id: str, # Source node id
232
+ rendition_selector: Optional[str] = None, # Which rendition spine (None = auto-select)
233
+ ) -> int: # Number of fine Segment nodes under the Source's chosen rendition
234
+ """Count a Source's segments server-side under its chosen rendition (typed count mode)."""
235
+ rendition_ids = await resolve_source_renditions(queue, graph_id, source_id, rendition_selector)
236
+ if not rendition_ids:
237
+ return 0
238
+ q = _spine_query(rendition_ids, order_by=None, project=None, count=True)
239
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
240
+ return int(res.count or 0)
241
+
242
+
243
+ def _edge(
244
+ source_id: str, # Origin node id
245
+ target_id: str, # Destination node id
246
+ relation_type: str, # Edge relation type
247
+ properties: Optional[Dict[str, Any]] = None, # Edge properties
248
+ ) -> Dict[str, Any]: # Edge wire dict
249
+ """Build an edge wire dict with a GENERATED id.
250
+
251
+ Used for REVIEWED markers only: review decisions are EVENTS (a re-decision
252
+ appends), so their edges keep generated ids. Structural overlay edges
253
+ (CORRECTS / SUPERSEDES / DERIVED_FROM) use the layer's deterministic
254
+ `make_edge` — unique per (source, target, relation) by construction.
255
+ """
256
+ return {"id": str(uuid4()), "source_id": source_id, "target_id": target_id,
257
+ "relation_type": relation_type, "properties": properties or {}}
258
+
259
+
260
+ def build_correction_node(
261
+ correction_type: str, # "text_content" | "punctuation" | "grouping" | "review"
262
+ session_id: str, # Owning session id
263
+ payload: Dict[str, Any], # Type-specific payload
264
+ actor: str = "human", # Actor
265
+ status: str = "applied", # Lifecycle status
266
+ canonical_form: Optional[str] = None, # Optional entity key
267
+ rationale: Optional[str] = None, # Optional note
268
+ ) -> Correction: # The Correction overlay node (not yet committed)
269
+ """Construct a Correction overlay node (pure; commit happens separately)."""
270
+ return Correction(
271
+ correction_type=correction_type, session_id=session_id, payload=payload,
272
+ actor=actor, status=status, canonical_form=canonical_form, rationale=rationale,
273
+ )
274
+
275
+
276
+ def build_prune_correction(
277
+ source_id: str, # Source being corrected
278
+ pruned: List[SpineSegment], # Empty layer-0 segments to prune
279
+ session_id: str, # Owning session id
280
+ actor: str = "human", # Actor
281
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
282
+ """Build one batch grouping Correction that prunes empty segments (D14).
283
+
284
+ Non-destructive: layer-0 nodes are NOT deleted; the Correction records the
285
+ pruned ids and DERIVED_FROM edges point at each pruned Segment. The effective
286
+ spine drops them at projection time (reversible by superseding this node).
287
+ The payload carries the layer's `prune` spine-edit op vocabulary.
288
+ """
289
+ payload = {
290
+ "operation": "prune_empty",
291
+ "source_id": source_id,
292
+ "pruned_segment_ids": [s.id for s in pruned],
293
+ "pruned_count": len(pruned),
294
+ }
295
+ node = build_correction_node("grouping", session_id, payload, actor=actor).to_graph_node()
296
+ edges = [make_edge(node.id, s.id, CorrectionRelations.DERIVED_FROM) for s in pruned]
297
+ return node.to_dict(), edges
298
+
299
+
300
+ async def commit_nodes_edges(
301
+ queue: JobQueue, # Started job queue
302
+ graph_id: str, # Graph-storage capability id
303
+ nodes: List[Dict[str, Any]], # Node wire dicts
304
+ edges: List[Dict[str, Any]], # Edge wire dicts
305
+ ) -> Dict[str, int]: # {"nodes": n, "edges": m} created counts
306
+ """Commit overlay nodes/edges through the layer's idempotent extend_graph.
307
+
308
+ Stage 5 (C5 plumbing migrated): the layer owns emit-if-absent +
309
+ verify-if-present; overlay nodes have generated ids so they always add,
310
+ but a replayed commit collides into a verified no-op instead of
311
+ duplicating structural edges.
312
+ """
313
+ res = await extend_graph(queue, graph_id, nodes, edges)
314
+ return {"nodes": res.nodes_added, "edges": res.edges_added}
315
+
316
+
317
+ async def start_session(
318
+ queue: JobQueue, # Started job queue
319
+ graph_id: str, # Graph-storage capability id
320
+ scope: List[str], # Source node ids in scope
321
+ ) -> CorrectionSession: # The committed CorrectionSession
322
+ """Create + commit a new CorrectionSession node."""
323
+ sess = CorrectionSession(scope=scope)
324
+ await commit_nodes_edges(queue, graph_id, [sess.to_graph_node().to_dict()], [])
325
+ return sess
326
+
327
+
328
+ async def get_session(
329
+ queue: JobQueue, # Started job queue
330
+ graph_id: str, # Graph-storage capability id
331
+ session_id: str, # CorrectionSession node id
332
+ ) -> Optional[Dict[str, Any]]: # The session node dict, or None
333
+ """Fetch a CorrectionSession node by id (resume/reopen) — typed get, dict shape preserved."""
334
+ node = await graph_task(queue, graph_id, "get_node", node_id=session_id)
335
+ if node is None:
336
+ return None
337
+ return node.to_dict() if isinstance(node, GraphNode) else node
338
+
339
+
340
+ async def set_session_status(
341
+ queue: JobQueue, # Started job queue
342
+ graph_id: str, # Graph-storage capability id
343
+ session_id: str, # CorrectionSession node id
344
+ status: str, # New status ("in_progress" | "completed" | "reopened")
345
+ ) -> None:
346
+ """Update a session's status + updated_at.
347
+
348
+ The ONLY update_node use in this core: a CorrectionSession is OVERLAY metadata
349
+ whose lifecycle is mutable. Layer-0 Segments + Corrections stay append-only.
350
+ """
351
+ await graph_task(queue, graph_id, "update_node", node_id=session_id,
352
+ properties={"status": status, "updated_at": time.time()})
353
+
354
+
355
+ async def record_review_markers(
356
+ queue: JobQueue, # Started job queue
357
+ graph_id: str, # Graph-storage capability id
358
+ session_id: str, # Owning session id
359
+ decisions: List[Tuple[str, str]], # (segment_id, decision) pairs
360
+ ) -> int: # Number of REVIEWED edges committed
361
+ """Persist per-(session, segment) review markers as REVIEWED edges."""
362
+ edges = [_edge(session_id, seg_id, CorrectionRelations.REVIEWED, {"decision": dec})
363
+ for seg_id, dec in decisions]
364
+ return (await commit_nodes_edges(queue, graph_id, [], edges))["edges"]
365
+
366
+
367
+ async def load_review_markers(
368
+ queue: JobQueue, # Started job queue
369
+ graph_id: str, # Graph-storage capability id
370
+ session_id: str, # Owning session id
371
+ ) -> Dict[str, str]: # segment_id -> decision for the session
372
+ """Load a session's review markers (typed edge projection over REVIEWED edges)."""
373
+ q = EdgeQuery(source_id=session_id, relation_type=CorrectionRelations.REVIEWED,
374
+ project=["decision"])
375
+ res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
376
+ return {r["target_id"]: r["decision"] for r in (res.rows or [])}
377
+
378
+
379
+ def _node_to_correction_dict(
380
+ node: Any, # GraphNode (typed task result) or its wire dict
381
+ ) -> Dict[str, Any]: # Correction properties + "id" (the pre-stage-4 row shape)
382
+ """Flatten a Correction node to its properties dict + id."""
383
+ d = node.to_dict() if isinstance(node, GraphNode) else dict(node)
384
+ props = dict(d.get("properties", {}) or {})
385
+ props["id"] = d["id"]
386
+ return props
387
+
388
+
389
+ async def find_corrections_for_session(
390
+ queue: JobQueue, # Started job queue
391
+ graph_id: str, # Graph-storage capability id
392
+ session_id: str, # Owning session id
393
+ ) -> List[Dict[str, Any]]: # Correction property dicts for the session
394
+ """List corrections recorded in a session (typed property filter)."""
395
+ q = NodeQuery(label="Correction",
396
+ where=[PropertyPredicate("session_id", "eq", session_id)])
397
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
398
+ return [_node_to_correction_dict(n) for n in (res.nodes or [])]
399
+
400
+
401
+ async def find_prior_corrections_by_hash(
402
+ queue: JobQueue, # Started job queue
403
+ graph_id: str, # Graph-storage capability id
404
+ content_hash: str, # SourceRef content hash to look up
405
+ ) -> List[Dict[str, Any]]: # Corrections whose CORRECTS target carried this hash
406
+ """Cross-transcript correction-cache lookup (targeted; the graph IS the lexicon).
407
+
408
+ THE stage-4 promotion landing: the raw two-table JOIN this function carried
409
+ (C-ledger site 6) became ONE typed far-end provenance constraint
410
+ (`RelationPredicate.node_source`, content-hash-primary per CR-19) — the
411
+ hottest review-path read is portable now.
412
+ """
413
+ q = NodeQuery(label="Correction",
414
+ related=RelationPredicate(
415
+ CorrectionRelations.CORRECTS,
416
+ node_source=SourcePredicate(content_hash=content_hash)))
417
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
418
+ return [_node_to_correction_dict(n) for n in (res.nodes or [])]
419
+
420
+
421
+ def corrections_to_edits(
422
+ corrections: List[Dict[str, Any]], # ACTIVE correction property dicts
423
+ ) -> List[SpineEdit]: # The layer's neutral spine-edit operations
424
+ """Map this core's Correction payloads onto the layer's spine-edit vocabulary.
425
+
426
+ The DOMAIN interpretation (correction_type + payload shapes) stays here;
427
+ the projection MECHANICS (ordering, latest-wins, prune/replace/boundary
428
+ semantics) are the layer's (CR-18: C11 migrated).
429
+ """
430
+ edits: List[SpineEdit] = []
431
+ for c in corrections:
432
+ if c.get("status") in ("superseded", "proposed"):
433
+ # superseded: legacy skip retained; proposed: awaiting a review
434
+ # verdict — a proposal never enters the effective view (DEC 7861d27e).
435
+ continue
436
+ ctype = c.get("correction_type")
437
+ payload = c.get("payload", {}) or {}
438
+ created = float(c.get("created_at") or 0.0)
439
+ if ctype == "grouping" and payload.get("operation") == "prune_empty":
440
+ edits.append(SpineEdit(edit_id=c["id"], op="prune",
441
+ targets=list(payload.get("pruned_segment_ids") or []),
442
+ created_at=created))
443
+ elif ctype == "grouping" and payload.get("operation") == "shift_boundary":
444
+ left = payload.get("boundary_after")
445
+ if left is not None:
446
+ edits.append(SpineEdit(edit_id=c["id"], op="boundary_shift",
447
+ targets=[t for t in (left, payload.get("right_segment_id")) if t],
448
+ payload={"boundary_after": left,
449
+ "text": payload.get("text", ""),
450
+ "direction": payload.get("direction", "push")},
451
+ created_at=created))
452
+ elif ctype == "text_content" and payload.get("operation") == "replace_text":
453
+ sid = payload.get("segment_id")
454
+ if sid is not None:
455
+ edits.append(SpineEdit(edit_id=c["id"], op="replace_text", targets=[sid],
456
+ payload={"text": payload.get("new_text", "")},
457
+ created_at=created))
458
+ return edits
459
+
460
+
461
+ def project_effective_spine(
462
+ segments: List[SpineSegment], # Ordered layer-0 spine
463
+ corrections: List[Dict[str, Any]], # Applied correction property dicts
464
+ ) -> List[SpineSegment]: # The effective spine after applying corrections
465
+ """Project the effective spine = layer-0 + applied corrections.
466
+
467
+ Stage 5: the projection MECHANICS live in `cjm-context-graph-layer`
468
+ (`project_effective_spine` over SpineUnits — prune, replace_text, and the
469
+ reserved boundary_shift, with created_at ordering + latest-wins); this
470
+ wrapper converts SpineSegments <-> SpineUnits and re-attaches the
471
+ segment metadata by id.
472
+ """
473
+ units = [SpineUnit(id=s.id, text=s.text) for s in segments]
474
+ out_units = layer_project_effective_spine(units, corrections_to_edits(corrections))
475
+ by_id = {u.id: u.text for u in out_units}
476
+ out: List[SpineSegment] = []
477
+ for s in segments:
478
+ if s.id not in by_id:
479
+ continue
480
+ if by_id[s.id] != s.text:
481
+ s = SpineSegment(id=s.id, index=s.index, text=by_id[s.id],
482
+ start_time=s.start_time, end_time=s.end_time,
483
+ source_locator=s.source_locator, content_hash=s.content_hash,
484
+ text_from=s.text_from, text_slices=s.text_slices)
485
+ out.append(s)
486
+ return out
487
+
488
+
489
+ def build_text_correction(
490
+ source_id: str, # Source the segment belongs to
491
+ segment_id: str, # Layer-0 Segment being corrected
492
+ new_text: str, # Corrected text
493
+ session_id: str, # Owning session id
494
+ old_text: Optional[str] = None, # Prior effective text (for the record)
495
+ supersedes_id: Optional[str] = None, # Prior Correction this one replaces (re-edit)
496
+ actor: str = "human", # Actor
497
+ canonical_form: Optional[str] = None, # Optional entity key (cross-transcript matching)
498
+ rationale: Optional[str] = None, # Optional note
499
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
500
+ """Build a text_content Correction + its CORRECTS (+ optional SUPERSEDES) edges.
501
+
502
+ Non-destructive: the layer-0 Segment is unchanged; the correction carries the
503
+ new text in its payload + a CORRECTS edge to the segment. A re-edit adds a
504
+ SUPERSEDES edge (new -> prior) so supersession is graph-native + append-only
505
+ (the prior Correction is never mutated; it is excluded from the effective view
506
+ because it is a SUPERSEDES target — the C16 semantics, layer-resolved).
507
+ """
508
+ payload = {"operation": "replace_text", "source_id": source_id,
509
+ "segment_id": segment_id, "new_text": new_text, "old_text": old_text}
510
+ node = build_correction_node("text_content", session_id, payload, actor=actor,
511
+ canonical_form=canonical_form, rationale=rationale).to_graph_node()
512
+ edges = [make_edge(node.id, segment_id, CorrectionRelations.CORRECTS)]
513
+ if supersedes_id:
514
+ edges.append(make_edge(node.id, supersedes_id, CorrectionRelations.SUPERSEDES))
515
+ return node.to_dict(), edges
516
+
517
+
518
+ async def commit_text_correction(
519
+ queue: JobQueue, # Started job queue
520
+ graph_id: str, # Graph-storage capability id
521
+ source_id: str, # Source the segment belongs to
522
+ segment_id: str, # Layer-0 Segment being corrected
523
+ new_text: str, # Corrected text
524
+ session_id: str, # Owning session id
525
+ old_text: Optional[str] = None, # Prior effective text
526
+ supersedes_id: Optional[str] = None, # Prior Correction to supersede (re-edit)
527
+ actor: str = "human", # Actor
528
+ canonical_form: Optional[str] = None, # Optional entity key
529
+ ) -> str: # The new Correction node id
530
+ """Commit a text_content correction (node + CORRECTS [+ SUPERSEDES]) + a REVIEWED marker."""
531
+ node, edges = build_text_correction(
532
+ source_id, segment_id, new_text, session_id, old_text=old_text,
533
+ supersedes_id=supersedes_id, actor=actor, canonical_form=canonical_form)
534
+ await commit_nodes_edges(queue, graph_id, [node], edges)
535
+ await record_review_markers(queue, graph_id, session_id, [(segment_id, "corrected")])
536
+ return node["id"]
537
+
538
+
539
+ def active_corrections(
540
+ corrections: List[Dict[str, Any]], # Corrections (e.g. from load_source_corrections)
541
+ superseded_ids: set, # Ids that are SUPERSEDES targets
542
+ ) -> List[Dict[str, Any]]: # Only the effective (non-superseded) corrections
543
+ """Filter to the effective correction set (the layer's resolve_active over a read superseded set)."""
544
+ active_ids = resolve_active([c.get("id") for c in corrections],
545
+ [("", t) for t in superseded_ids])
546
+ return [c for c in corrections if c.get("id") in active_ids]
547
+
548
+
549
+ async def _superseded_ids(
550
+ queue: JobQueue, # Started job queue
551
+ graph_id: str, # Graph-storage capability id
552
+ correction_ids: List[str], # Candidate correction ids
553
+ ) -> set: # Subset that are SUPERSEDES targets
554
+ """Which of the given corrections are SUPERSEDES targets (typed id-list batch)."""
555
+ if not correction_ids:
556
+ return set()
557
+ q = EdgeQuery(relation_type=CorrectionRelations.SUPERSEDES,
558
+ target_ids=list(correction_ids), project=[])
559
+ res = await graph_task(queue, graph_id, "query_edges", query=q.to_dict())
560
+ return {r["target_id"] for r in (res.rows or [])}
561
+
562
+
563
+ async def load_source_corrections(
564
+ queue: JobQueue, # Started job queue
565
+ graph_id: str, # Graph-storage capability id
566
+ source_id: str, # Source node id
567
+ ) -> Tuple[List[Dict[str, Any]], set]: # (all corrections for the source, superseded id set)
568
+ """Load every Correction targeting a Source (across sessions) + the superseded-id set.
569
+
570
+ Source-scoped (corrections carry payload.source_id — a dotted-path typed
571
+ predicate) so the effective view is a property of the SOURCE, not one
572
+ session — the persistence/resume/reopen requirement. Append-only:
573
+ supersession is read from SUPERSEDES edges, never a status mutation.
574
+ """
575
+ q = NodeQuery(label="Correction",
576
+ where=[PropertyPredicate("payload.source_id", "eq", source_id)])
577
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
578
+ corrections = [_node_to_correction_dict(n) for n in (res.nodes or [])]
579
+ superseded = await _superseded_ids(queue, graph_id, [c["id"] for c in corrections])
580
+ return corrections, superseded
581
+
582
+
583
+ async def find_active_text_corrections_batch(
584
+ queue: JobQueue, # Started job queue
585
+ graph_id: str, # Graph-storage capability id
586
+ segment_ids: List[str], # Segments to look up
587
+ ) -> Dict[str, Dict[str, Any]]: # segment_id -> active text correction
588
+ """Active text corrections for MANY segments in TWO round-trips (C17).
589
+
590
+ One far-end batch read (Corrections with CORRECTS edges into the id set;
591
+ `RelationPredicate.node_ids`) + one superseded-set read — replacing the
592
+ per-item lookup the review loop paid (a 1,275-item review would have been
593
+ 1,275 queries; now 2). Latest non-superseded correction per segment wins.
594
+ """
595
+ if not segment_ids:
596
+ return {}
597
+ q = NodeQuery(label="Correction",
598
+ where=[PropertyPredicate("correction_type", "eq", "text_content")],
599
+ related=RelationPredicate(CorrectionRelations.CORRECTS,
600
+ node_ids=list(segment_ids)))
601
+ res = await graph_task(queue, graph_id, "query_nodes", query=q.to_dict())
602
+ cands = [_node_to_correction_dict(n) for n in (res.nodes or [])]
603
+ superseded = await _superseded_ids(queue, graph_id, [c["id"] for c in cands])
604
+ out: Dict[str, Dict[str, Any]] = {}
605
+ for c in cands:
606
+ if c["id"] in superseded:
607
+ continue
608
+ sid = (c.get("payload") or {}).get("segment_id")
609
+ if sid is None:
610
+ continue
611
+ prev = out.get(sid)
612
+ if prev is None or c.get("created_at", 0.0) > prev.get("created_at", 0.0):
613
+ out[sid] = c
614
+ return out
615
+
616
+
617
+ async def find_active_text_correction(
618
+ queue: JobQueue, # Started job queue
619
+ graph_id: str, # Graph-storage capability id
620
+ segment_id: str, # Segment to look up
621
+ ) -> Optional[Dict[str, Any]]: # The current non-superseded text correction, or None
622
+ """Single-segment convenience over the batch read (cross-session; latest wins)."""
623
+ found = await find_active_text_corrections_batch(queue, graph_id, [segment_id])
624
+ return found.get(segment_id)
625
+
626
+
627
+ async def load_variant_texts(
628
+ queue: JobQueue, # Started job queue
629
+ graph_id: str, # Graph-storage capability id
630
+ segments: List[SpineSegment], # Spine segments (with text_slices)
631
+ ) -> Dict[str, Dict[str, str]]: # segment_id -> {transcriber: chunk text}
632
+ """Resolve per-transcriber chunk texts from the segments' CharSlice refs.
633
+
634
+ Stage 5: the cross-transcriber diff is INTRA-GRAPH — text is stored once
635
+ per transcriber at the coarse Transcript nodes; this fetches ALL referenced
636
+ Transcript nodes in ONE batched typed read and slices their text
637
+ client-side by each segment's char ranges. Replaces the retired
638
+ second-decomp-manifest positional join (C4/C14's shared-skeleton model).
639
+ """
640
+ transcript_ids = sorted({ts["transcript"] for s in segments
641
+ for ts in s.text_slices if ts.get("transcript")})
642
+ if not transcript_ids:
643
+ return {}
644
+ res = await graph_task(queue, graph_id, "query_nodes",
645
+ query=NodeQuery(ids=transcript_ids).to_dict())
646
+ tnodes: Dict[str, Dict[str, Any]] = {}
647
+ for n in (res.nodes or []):
648
+ d = n.to_dict() if isinstance(n, GraphNode) else dict(n)
649
+ tnodes[d["id"]] = d.get("properties", {}) or {}
650
+ out: Dict[str, Dict[str, str]] = {}
651
+ for s in segments:
652
+ per_t: Dict[str, str] = {}
653
+ for ts in s.text_slices:
654
+ props = tnodes.get(ts.get("transcript"))
655
+ if props is None or ts.get("start") is None or ts.get("end") is None:
656
+ continue
657
+ transcriber = str(props.get("transcriber") or ts["transcript"])
658
+ per_t[transcriber] = str(props.get("text") or "")[int(ts["start"]):int(ts["end"])]
659
+ if per_t:
660
+ out[s.id] = per_t
661
+ return out
662
+
663
+
664
+ def build_boundary_shift_correction(
665
+ source_id: str, # Source the boundary belongs to
666
+ left_segment_id: str, # Layer-0 Segment LEFT of the boundary (the layer's `boundary_after`)
667
+ right_segment_id: str, # Layer-0 Segment RIGHT of the boundary (recorded; layer re-derives from order)
668
+ text: str, # The exact text moved across the boundary (verbatim, whitespace included)
669
+ direction: str, # "push" (left tail -> right head) | "pull" (the mirror)
670
+ session_id: str, # Owning session id
671
+ supersedes_id: Optional[str] = None, # Prior Correction this one replaces (re-edit)
672
+ actor: str = "human", # Actor
673
+ rationale: Optional[str] = None, # Optional note
674
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
675
+ """Build a grouping Correction that moves text across one segment boundary.
676
+
677
+ The FA-misassignment gesture (an ALIGNMENT error, not a transcription
678
+ error): text sits on the wrong side of a boundary; 1:1 audio alignment is
679
+ preserved (segment count and positions never change). The payload carries
680
+ the layer's `boundary_shift` op vocabulary; the layer validates the moved
681
+ text VERBATIM at projection time and fails loudly on mismatch. CORRECTS
682
+ edges anchor BOTH boundary segments.
683
+ """
684
+ if direction not in ("push", "pull"):
685
+ raise ValueError(f"boundary-shift direction must be 'push' or 'pull', got {direction!r}")
686
+ payload = {"operation": "shift_boundary", "source_id": source_id,
687
+ "boundary_after": left_segment_id, "right_segment_id": right_segment_id,
688
+ "text": text, "direction": direction}
689
+ node = build_correction_node("grouping", session_id, payload, actor=actor,
690
+ rationale=rationale).to_graph_node()
691
+ edges = [make_edge(node.id, left_segment_id, CorrectionRelations.CORRECTS),
692
+ make_edge(node.id, right_segment_id, CorrectionRelations.CORRECTS)]
693
+ if supersedes_id:
694
+ edges.append(make_edge(node.id, supersedes_id, CorrectionRelations.SUPERSEDES))
695
+ return node.to_dict(), edges
696
+
697
+
698
+ def build_reject_review(
699
+ source_id: str, # Source the rejected correction belongs to (source-scoped loads)
700
+ rejected_id: str, # The prior Correction (typically a proposal) being rejected
701
+ session_id: str, # Owning session id
702
+ actor: str = "human", # Actor (the reviewer)
703
+ rationale: Optional[str] = None, # Optional note (why it was rejected)
704
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (review node dict, edge dicts)
705
+ """Build a review Correction that REJECTS a prior correction (reject-as-supersede).
706
+
707
+ An append-only VERDICT: rejection is a SUPERSEDES edge from a small
708
+ `review` node, never a status mutation — `resolve_active` then excludes
709
+ the rejected correction with the machinery supersession already uses, and
710
+ the verdict carries actor/rationale/timestamp. A review node maps to NO
711
+ spine edit (`corrections_to_edits` has no arm for it) so it can never
712
+ touch the effective view itself. The ACCEPT verdict is deferred until
713
+ agents actually propose; it rides this same node shape.
714
+ """
715
+ payload = {"operation": "reject", "source_id": source_id, "rejected_id": rejected_id}
716
+ node = build_correction_node("review", session_id, payload, actor=actor,
717
+ rationale=rationale).to_graph_node()
718
+ edges = [make_edge(node.id, rejected_id, CorrectionRelations.SUPERSEDES)]
719
+ return node.to_dict(), edges
720
+
721
+
722
+ async def commit_boundary_shift_correction(
723
+ queue: JobQueue, # Started job queue
724
+ graph_id: str, # Graph-storage capability id
725
+ source_id: str, # Source the boundary belongs to
726
+ left_segment_id: str, # Segment LEFT of the boundary
727
+ right_segment_id: str, # Segment RIGHT of the boundary
728
+ text: str, # Verbatim text moved across the boundary
729
+ direction: str, # "push" | "pull"
730
+ session_id: str, # Owning session id
731
+ supersedes_id: Optional[str] = None, # Prior Correction to supersede (re-edit)
732
+ actor: str = "human", # Actor
733
+ ) -> str: # The new Correction node id
734
+ """Commit a boundary-shift correction (node + CORRECTS x2 [+ SUPERSEDES]) + REVIEWED markers on both segments."""
735
+ node, edges = build_boundary_shift_correction(
736
+ source_id, left_segment_id, right_segment_id, text, direction, session_id,
737
+ supersedes_id=supersedes_id, actor=actor)
738
+ await commit_nodes_edges(queue, graph_id, [node], edges)
739
+ await record_review_markers(queue, graph_id, session_id,
740
+ [(left_segment_id, "corrected"), (right_segment_id, "corrected")])
741
+ return node["id"]
742
+
743
+
744
+ def build_prune_amendment(
745
+ prior: Dict[str, Any], # The ACTIVE prune Correction being amended (property dict + id)
746
+ unprune_ids: List[str], # Segment ids to REMOVE from the prune set (rescued positions)
747
+ session_id: str, # Owning session id
748
+ actor: str = "human", # Actor
749
+ rationale: Optional[str] = None, # Optional note
750
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: # (correction node dict, edge dicts)
751
+ """Build a grouping Correction that supersedes a prune with a REDUCED set (unprune).
752
+
753
+ The append-only inverse of build_prune_correction for single positions: a
754
+ boundary shift that gives text to a pruned segment RESCUES it (the
755
+ falsified-D14 class), and the segment must leave the prune set or the
756
+ layer's projection drops it — WITH its new text — from the effective view.
757
+ The amendment re-carries the prior payload minus the rescued ids plus a
758
+ SUPERSEDES edge; DERIVED_FROM edges re-anchor the still-pruned ids.
759
+ """
760
+ prior_payload = dict(prior.get("payload") or {})
761
+ remaining = [sid for sid in (prior_payload.get("pruned_segment_ids") or [])
762
+ if sid not in set(unprune_ids)]
763
+ payload = {"operation": "prune_empty", "source_id": prior_payload.get("source_id"),
764
+ "pruned_segment_ids": remaining, "pruned_count": len(remaining)}
765
+ node = build_correction_node("grouping", session_id, payload, actor=actor,
766
+ rationale=rationale).to_graph_node()
767
+ edges = [make_edge(node.id, sid, CorrectionRelations.DERIVED_FROM) for sid in remaining]
768
+ edges.append(make_edge(node.id, prior["id"], CorrectionRelations.SUPERSEDES))
769
+ return node.to_dict(), edges
770
+
771
+
772
+ async def commit_prune_amendment(
773
+ queue: JobQueue, # Started job queue
774
+ graph_id: str, # Graph-storage capability id
775
+ prior: Dict[str, Any], # The ACTIVE prune Correction being amended
776
+ unprune_ids: List[str], # Segment ids rescued from the prune set
777
+ session_id: str, # Owning session id
778
+ actor: str = "human", # Actor
779
+ ) -> Dict[str, Any]: # The amended correction as a property dict + id (local-echo ready)
780
+ """Commit an unprune amendment (node + DERIVED_FROM edges + SUPERSEDES)."""
781
+ node, edges = build_prune_amendment(prior, unprune_ids, session_id, actor=actor)
782
+ await commit_nodes_edges(queue, graph_id, [node], edges)
783
+ return _node_to_correction_dict(node)