cjm-transcript-decomp-core 0.0.6__tar.gz → 0.0.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {cjm_transcript_decomp_core-0.0.6/cjm_transcript_decomp_core.egg-info → cjm_transcript_decomp_core-0.0.10}/PKG-INFO +8 -4
  2. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/README.md +6 -2
  3. cjm_transcript_decomp_core-0.0.10/cjm_transcript_decomp_core/__init__.py +1 -0
  4. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/cli.py +116 -6
  5. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/graph.py +57 -2
  6. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/launch.py +5 -1
  7. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/models.py +6 -2
  8. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/pipeline.py +89 -21
  9. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/runs.py +100 -6
  10. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10/cjm_transcript_decomp_core.egg-info}/PKG-INFO +8 -4
  11. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core.egg-info/requires.txt +1 -1
  12. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/pyproject.toml +1 -1
  13. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_batch_logic.py +131 -0
  14. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_graph.py +32 -0
  15. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_models.py +7 -1
  16. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_pipeline.py +51 -0
  17. cjm_transcript_decomp_core-0.0.6/cjm_transcript_decomp_core/__init__.py +0 -1
  18. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/LICENSE +0 -0
  19. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/MANIFEST.in +0 -0
  20. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/alignment.py +0 -0
  21. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/discovery.py +0 -0
  22. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/segments.py +0 -0
  23. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core/state.py +0 -0
  24. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core.egg-info/SOURCES.txt +0 -0
  25. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core.egg-info/dependency_links.txt +0 -0
  26. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core.egg-info/entry_points.txt +0 -0
  27. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/cjm_transcript_decomp_core.egg-info/top_level.txt +0 -0
  28. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/setup.cfg +0 -0
  29. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_alignment.py +0 -0
  30. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_cli.py +0 -0
  31. {cjm_transcript_decomp_core-0.0.6 → cjm_transcript_decomp_core-0.0.10}/tests/test_segment_logic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-decomp-core
3
- Version: 0.0.6
3
+ Version: 0.0.10
4
4
  Summary: A frontend-agnostic core for the transcript decomposition workflow — composes isolated capability workers (forced alignment, VAD, graph storage) into a headless pipeline that decomposes transcription run manifests into a VAD-aligned context-graph spine with traceable provenance, and a CLI as its first driver.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.12
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: cjm_substrate>=0.0.61
13
+ Requires-Dist: cjm_substrate>=0.0.63
14
14
  Requires-Dist: pyyaml
15
15
  Requires-Dist: cjm_context_graph_primitives>=0.0.15
16
16
  Requires-Dist: cjm_capability_primitives>=0.0.11
@@ -26,7 +26,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
26
26
 
27
27
  ## Modules
28
28
 
29
- - **`cjm_transcript_decomp_core`**
29
+ - **`cjm_transcript_decomp_core.__init__`**
30
30
  - **`cjm_transcript_decomp_core.alignment`** — 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).
31
31
  - **`cjm_transcript_decomp_core.cli`** — The CLI driver — the decomposition core's first (and currently only) frontend.
32
32
  - **`cjm_transcript_decomp_core.discovery`** — Capability-role discovery by manifest surface match — the journaling-by-
@@ -53,6 +53,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
53
53
 
54
54
  ### `cjm_transcript_decomp_core.cli`
55
55
 
56
+ - `backfill_provenance_command` _function_ — Execute `backfill-provenance`: mint the Segment -> Transcript DERIVED_FROM
56
57
  - `build_parser` _function_ — Build the CLI parser (subcommands: run).
57
58
  - `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
58
59
  - `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
@@ -67,7 +68,9 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
67
68
 
68
69
  - `SourceVerification` _class_ — Skeptical-lens verification of one Source's fine-spine extension under a
69
70
  - `build_extension_payload` _function_ — Build the fine-spine EXTENSION payload (pure; no capability calls).
71
+ - `provenance_edges_from_segment_wire` _function_ — Derive a Segment's Transcript provenance edges from the node itself:
70
72
  - `resolve_root_ids` _function_ — Recompute the transcription-emitted root node ids from manifest data.
73
+ - `segment_provenance_edges` _function_ — Text provenance as EDGES (finding 89b16be6, the references-must-be-edges
71
74
  - `verify_source` _function_ — Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
72
75
 
73
76
  ### `cjm_transcript_decomp_core.launch`
@@ -102,6 +105,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
102
105
  - `event_spans_from_propset` _function_ — Load a proposal set BY POINTER and select its carve spans (respine trial
103
106
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
104
107
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
108
+ - `resolve_event_propsets` _function_ — Join each proposal set to ITS source by the set manifest's own source
105
109
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
106
110
  - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
107
111
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
@@ -140,4 +144,4 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
140
144
  ## Dependencies
141
145
 
142
146
  **Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`, `pyyaml`
143
- **Used by:** `cjm-transcript-decomp-qt`, `cjm-transcript-decomp-tui`
147
+ **Used by:** `cjm-transcript-decomp-qt`
@@ -6,7 +6,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
6
6
 
7
7
  ## Modules
8
8
 
9
- - **`cjm_transcript_decomp_core`**
9
+ - **`cjm_transcript_decomp_core.__init__`**
10
10
  - **`cjm_transcript_decomp_core.alignment`** — 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).
11
11
  - **`cjm_transcript_decomp_core.cli`** — The CLI driver — the decomposition core's first (and currently only) frontend.
12
12
  - **`cjm_transcript_decomp_core.discovery`** — Capability-role discovery by manifest surface match — the journaling-by-
@@ -33,6 +33,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
33
33
 
34
34
  ### `cjm_transcript_decomp_core.cli`
35
35
 
36
+ - `backfill_provenance_command` _function_ — Execute `backfill-provenance`: mint the Segment -> Transcript DERIVED_FROM
36
37
  - `build_parser` _function_ — Build the CLI parser (subcommands: run).
37
38
  - `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
38
39
  - `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
@@ -47,7 +48,9 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
47
48
 
48
49
  - `SourceVerification` _class_ — Skeptical-lens verification of one Source's fine-spine extension under a
49
50
  - `build_extension_payload` _function_ — Build the fine-spine EXTENSION payload (pure; no capability calls).
51
+ - `provenance_edges_from_segment_wire` _function_ — Derive a Segment's Transcript provenance edges from the node itself:
50
52
  - `resolve_root_ids` _function_ — Recompute the transcription-emitted root node ids from manifest data.
53
+ - `segment_provenance_edges` _function_ — Text provenance as EDGES (finding 89b16be6, the references-must-be-edges
51
54
  - `verify_source` _function_ — Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
52
55
 
53
56
  ### `cjm_transcript_decomp_core.launch`
@@ -82,6 +85,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
82
85
  - `event_spans_from_propset` _function_ — Load a proposal set BY POINTER and select its carve spans (respine trial
83
86
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
84
87
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
88
+ - `resolve_event_propsets` _function_ — Join each proposal set to ITS source by the set manifest's own source
85
89
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
86
90
  - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
87
91
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
@@ -120,4 +124,4 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
120
124
  ## Dependencies
121
125
 
122
126
  **Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`, `pyyaml`
123
- **Used by:** `cjm-transcript-decomp-qt`, `cjm-transcript-decomp-tui`
127
+ **Used by:** `cjm-transcript-decomp-qt`
@@ -0,0 +1 @@
1
+ __version__ = "0.0.10"
@@ -27,9 +27,13 @@ import os
27
27
  from pathlib import Path
28
28
  from typing import Any, Dict, List, Optional
29
29
 
30
+ from cjm_context_graph_layer.journal import journal_extend, sidecar_journal_path
31
+ from cjm_context_graph_layer.ops import graph_task
32
+ from cjm_context_graph_primitives.query import NodeQuery
30
33
  from cjm_substrate.core.manager import CapabilityManager
31
34
  from cjm_substrate.core.queue import JobQueue
32
35
  from cjm_substrate.core.workspace import resolve_workspace
36
+ from cjm_transcript_decomp_core.graph import provenance_edges_from_segment_wire
33
37
  from cjm_transcript_decomp_core.models import DecompConfig
34
38
  from cjm_transcript_decomp_core.pipeline import load_source_manifest, run_decomp
35
39
 
@@ -78,9 +82,12 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
78
82
  help="Run the post-FA event-carve stage (respine trial DEC 6cc10fb7): model "
79
83
  "event spans from --event-propset become gaps between chunks "
80
84
  "(cut-don't-label); typically paired with --no-sentence-split")
81
- run.add_argument("--event-propset", default=None,
82
- help="ProposalSetManifest pointer (manifest json or its set dir); "
83
- "REQUIRED with --event-split")
85
+ run.add_argument("--event-propset", nargs="+", default=None, metavar="POINTER",
86
+ help="ProposalSetManifest pointer(s) (manifest json or its set dir); "
87
+ "REQUIRED with --event-split. ONE per source: each set carves "
88
+ "the source its own manifest names (content hash, else path) — "
89
+ "a multi-source run takes one pointer per source, any order; "
90
+ "an uncovered source or a stray pointer refuses loudly")
84
91
  run.add_argument("--event-classes", nargs="+", default=["inhale"],
85
92
  help="Proposal classes that carve (default: inhale; word-bearing "
86
93
  "classes must never cut)")
@@ -104,6 +111,29 @@ def build_parser() -> argparse.ArgumentParser: # Configured CLI parser
104
111
  run.add_argument("--actor", default=None,
105
112
  help="Journal attribution for who/what initiated this run (default: cli:<username>)")
106
113
  run.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
114
+
115
+ # ---- backfill-provenance: Segment -> Transcript DERIVED_FROM edges (finding 89b16be6) ----
116
+ bp = sub.add_parser(
117
+ "backfill-provenance",
118
+ help="Mint Segment -> Transcript DERIVED_FROM edges for Segments already on the graph "
119
+ "(text provenance as an edge, not only text_from metadata); journaled per source "
120
+ "under spine-extension, idempotent on re-run")
121
+ bp.add_argument("--source", default=None,
122
+ help="Restrict to one Source node id (prefix ok; default: every Segment)")
123
+ bp.add_argument("--page-size", type=int, default=2000,
124
+ help="Segments per query page (default 2000)")
125
+ bp.add_argument("--manifests-dir", default=".cjm/manifests", help="Capability manifests directory")
126
+ bp.add_argument("--graph-capability", default="cjm-capability-graph-sqlite",
127
+ help="Graph-storage capability name")
128
+ bp.add_argument("--graph-db-path", default=None,
129
+ help="Graph db path (default: the workspace capability config)")
130
+ bp.add_argument("--workspace", default=None,
131
+ help="Workspace root (default: CJM_WORKSPACE env, else upward walk from cwd)")
132
+ bp.add_argument("--actor", default=None,
133
+ help="Journal attribution (default: cli:<username>)")
134
+ bp.add_argument("--dry-run", action="store_true",
135
+ help="Count the edges per source; touch neither graph nor journal")
136
+ bp.add_argument("-v", "--verbose", action="store_true", help="DEBUG-level logging")
107
137
  return parser
108
138
 
109
139
 
@@ -168,12 +198,16 @@ async def run_command(
168
198
  seg_capability=args.seg_capability,
169
199
  respine=args.respine,
170
200
  event_split=args.event_split,
171
- event_propset=(args.event_propset or ""),
201
+ # One pointer per source (0.2.6): the single form stays the config
202
+ # snapshot's `event_propset` so 0.2.5 consumers read it unchanged.
203
+ event_propset=(args.event_propset[0] if args.event_propset
204
+ and len(args.event_propset) == 1 else ""),
205
+ event_propsets=list(args.event_propset or []),
172
206
  event_classes=list(args.event_classes),
173
207
  word_rescue=args.word_rescue,
174
208
  )
175
- if cfg.event_split and not cfg.event_propset:
176
- raise SystemExit("error: --event-split requires --event-propset")
209
+ if cfg.event_split and not cfg.event_propsets:
210
+ raise SystemExit("error: --event-split requires --event-propset (one per source)")
177
211
 
178
212
  # CR-7 GPU subtree attribution is opt-in: --sysmon-capability threads the monitor
179
213
  # name into BOTH the manager and the queue; the monitor loads FIRST so GPU
@@ -256,4 +290,80 @@ def main(
256
290
  )
257
291
  if args.command == "run":
258
292
  return asyncio.run(run_command(args))
293
+ if args.command == "backfill-provenance":
294
+ return asyncio.run(backfill_provenance_command(args))
259
295
  raise SystemExit(f"unknown command: {args.command}")
296
+
297
+
298
+ async def backfill_provenance_command(
299
+ args: argparse.Namespace, # Parsed CLI arguments for the `backfill-provenance` subcommand
300
+ ) -> int: # Process exit code
301
+ """Execute `backfill-provenance`: mint the Segment -> Transcript DERIVED_FROM
302
+ edges (finding 89b16be6) for Segments already on the graph, deriving each
303
+ segment's edges from its own refs (`provenance_edges_from_segment_wire`,
304
+ the same derivation a fresh decomposition uses). Journaled per SOURCE under
305
+ the existing `spine-extension` verb (edges-only wires; the replay handler
306
+ is already registered, and `journal_extend`'s added-delta trim keeps a
307
+ re-run silent), args carry act=provenance-backfill so the ops read as what
308
+ they are. Pages the Segment table so a 100k-segment graph never sits in one
309
+ query; --source narrows to one Source id (prefix ok); --dry-run counts."""
310
+ ws = resolve_workspace(explicit=getattr(args, "workspace", None))
311
+ if ws is not None:
312
+ os.environ["CJM_WORKSPACE"] = str(ws.root)
313
+ manager = CapabilityManager(search_paths=[Path(args.manifests_dir)])
314
+ configs = ({args.graph_capability: {"db_path": args.graph_db_path}}
315
+ if args.graph_db_path else None)
316
+ load_capabilities(manager, [args.graph_capability], configs=configs)
317
+ effective = args.graph_db_path or (
318
+ (manager.instances[args.graph_capability].config or {}).get("db_path"))
319
+ if not effective:
320
+ raise SystemExit("no graph db path: pass --graph-db-path, or persist one on the "
321
+ f"{args.graph_capability} instance in the active workspace's config store")
322
+ journal_path = sidecar_journal_path(str(effective))
323
+ actor = args.actor or f"cli:{getpass.getuser()}"
324
+ queue = JobQueue(deps=manager)
325
+ await queue.start()
326
+ try:
327
+ per_source: Dict[str, List[Dict[str, Any]]] = {}
328
+ seg_counts: Dict[str, int] = {}
329
+ offset = 0
330
+ while True:
331
+ nq = NodeQuery(label="Segment", project=["text_from", "source_id", "sources"],
332
+ limit=args.page_size, offset=offset)
333
+ res = await graph_task(queue, args.graph_capability, "query_nodes", query=nq.to_dict())
334
+ rows = list(res.rows or [])
335
+ for row in rows:
336
+ sid = row.get("source_id") or "?"
337
+ if args.source and not sid.startswith(args.source):
338
+ continue
339
+ seg_counts[sid] = seg_counts.get(sid, 0) + 1
340
+ per_source.setdefault(sid, []).extend(provenance_edges_from_segment_wire(row))
341
+ if len(rows) < args.page_size:
342
+ break
343
+ offset += len(rows)
344
+ total_edges = sum(len(v) for v in per_source.values())
345
+ if args.dry_run:
346
+ for sid in sorted(per_source):
347
+ print(f"{sid[:8]} segments={seg_counts[sid]} edges={len(per_source[sid])}")
348
+ print(f"dry run: {len(per_source)} sources, {sum(seg_counts.values())} segments, "
349
+ f"{total_edges} DERIVED_FROM edges (nothing written)")
350
+ return 0
351
+ added = 0
352
+ for sid in sorted(per_source):
353
+ edges = per_source[sid]
354
+ if not edges:
355
+ continue
356
+ r = await journal_extend(queue, args.graph_capability, [], edges,
357
+ journal_path=journal_path, verb="spine-extension",
358
+ actor=actor,
359
+ args={"act": "provenance-backfill", "source_id": sid,
360
+ "segments": seg_counts[sid], "edges": len(edges)})
361
+ added += r.edges_added
362
+ print(f"{sid[:8]} segments={seg_counts[sid]} edges={len(edges)} added={r.edges_added}")
363
+ finally:
364
+ await queue.stop()
365
+ manager.unload_capability(args.graph_capability)
366
+ print(f"backfilled DERIVED_FROM edges: {added} added of {total_edges} derived "
367
+ f"across {len(per_source)} sources (actor {actor})")
368
+ print(f"journal: {journal_path}")
369
+ return 0
@@ -3,7 +3,8 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Any, Dict, List, Optional, Tuple
5
5
 
6
- from cjm_context_graph_layer.grammar import grouped_spine_edges, SpineRelations
6
+ from cjm_context_graph_layer.grammar import (grouped_spine_edges, make_edge, OverlayRelations,
7
+ SpineRelations)
7
8
  from cjm_context_graph_layer.ops import graph_task
8
9
  from cjm_context_graph_primitives.graph import GraphNode
9
10
  from cjm_context_graph_primitives.provenance import SourceRef
@@ -80,7 +81,9 @@ def build_extension_payload(
80
81
  transcriber variant; `text_from` is recorded per segment as provenance
81
82
  (which Transcript the layer-0 text came from), never as global config. Edges
82
83
  come from `grouped_spine_edges`: PART_OF to the OWNING AudioRendition,
83
- STARTS_WITH per rendition, NEXT chained source-wide across coarse boundaries.
84
+ STARTS_WITH per rendition, NEXT chained source-wide across coarse boundaries —
85
+ plus one DERIVED_FROM per sliced Transcript (text provenance as an edge,
86
+ finding 89b16be6; role=text_from marks the authoritative one).
84
87
  """
85
88
  roots = resolve_root_ids(source_entry, capabilities_info)
86
89
  source_id = roots["source"]
@@ -114,6 +117,13 @@ def build_extension_payload(
114
117
 
115
118
  # The fine spine hangs under each segment's RENDITION (PART_OF rendition).
116
119
  edges = grouped_spine_edges([(asegs[i]["rendition"], groups[i]) for i in sorted(groups)])
120
+ # Text provenance is an EDGE, not only a property (finding 89b16be6): each
121
+ # Segment DERIVED_FROM every Transcript it slices (role=text_from on the
122
+ # authoritative one), so Segments are reachable from their Transcript by
123
+ # traversal the way they already were from their AudioRendition. Derived
124
+ # from the node's own refs — the same derivation the backfill verb uses.
125
+ for node in nodes:
126
+ edges.extend(provenance_edges_from_segment_wire(node))
117
127
  ids = {"source": source_id, "segments": seg_ids,
118
128
  "audio_segments": [a["audio_segment"] for a in asegs],
119
129
  "renditions": [a["rendition"] for a in asegs],
@@ -273,3 +283,48 @@ async def verify_source(
273
283
  all_have_sources=missing_sources == 0,
274
284
  source_locators=sorted(locators),
275
285
  )
286
+
287
+
288
+ def segment_provenance_edges(
289
+ segment_id: str, # The Segment node id
290
+ transcript_ids: List[str], # Transcript node ids the segment slices text from (any order; de-duplicated)
291
+ text_from: Optional[str] = None, # The authoritative Transcript id (None when the segment carries no text)
292
+ ) -> List[Dict[str, Any]]: # DERIVED_FROM edge wires, Segment -> Transcript, one per distinct transcript
293
+ """Text provenance as EDGES (finding 89b16be6, the references-must-be-edges
294
+ instance): a Segment's text comes from Transcript nodes, and until now that
295
+ link lived only in `text_from` metadata + the CharSlice source refs, so a
296
+ Segment was unreachable from its Transcript by traversal (the only
297
+ traceability edge went to the AudioRendition). One DERIVED_FROM per sliced
298
+ Transcript; the authoritative one carries role=text_from, the rest
299
+ role=variant. Ids are deterministic from the triple, so a backfill over
300
+ existing Segments and a fresh decomposition converge on the same edges."""
301
+ seen: set = set()
302
+ edges: List[Dict[str, Any]] = []
303
+ for tid in transcript_ids:
304
+ if not tid or tid in seen:
305
+ continue
306
+ seen.add(tid)
307
+ role = "text_from" if tid == text_from else "variant"
308
+ edges.append(make_edge(segment_id, tid, OverlayRelations.DERIVED_FROM, {"role": role}))
309
+ return edges
310
+
311
+
312
+ def provenance_edges_from_segment_wire(
313
+ node: Dict[str, Any], # A Segment node wire (or projected row): {"id", "properties"?, "sources", "text_from"?}
314
+ ) -> List[Dict[str, Any]]: # The segment's DERIVED_FROM edges, derived from its own refs
315
+ """Derive a Segment's Transcript provenance edges from the node itself:
316
+ every `char`-sliced source ref's graph-node locator is a Transcript the
317
+ segment slices text from, and `text_from` (a property on a wire, a
318
+ projected field on a query row) names the authoritative one. ONE derivation
319
+ for both callers — `build_extension_payload` at decomposition time and the
320
+ `backfill-provenance` verb over Segments already on the graph — so the
321
+ backfill reproduces exactly what a fresh run would emit."""
322
+ props = node.get("properties") or {}
323
+ text_from = props.get("text_from", node.get("text_from"))
324
+ tids: List[str] = []
325
+ for ref in node.get("sources") or []:
326
+ sl = ref.get("slice") or {}
327
+ loc = ref.get("locator") or {}
328
+ if sl.get("kind") == "char" and loc.get("node_id"):
329
+ tids.append(loc["node_id"])
330
+ return segment_provenance_edges(node["id"], tids, text_from)
@@ -158,8 +158,12 @@ def batch_argv(
158
158
  # classes render explicitly — the printed argv is the full contract.
159
159
  # The GROUP's resolved propset wins (per-source picker, DEC ae450551);
160
160
  # the CLI flag is the scripted path's pin.
161
+ # A group's propset is ONE pointer per source (0.2.6): a str for a
162
+ # single-source group, a list/tuple for a multi-source run.
163
+ ps = batch.get("event_propset") or args.event_propset
164
+ pointers = [ps] if isinstance(ps, str) else list(ps or [])
161
165
  argv += ["--event-split",
162
- "--event-propset", batch.get("event_propset") or args.event_propset,
166
+ "--event-propset", *pointers,
163
167
  "--event-classes", *args.event_classes]
164
168
  if args.actor:
165
169
  argv += ["--actor", args.actor]
@@ -122,7 +122,8 @@ class DecompConfig:
122
122
  seg_capability: str = "cjm-capability-pysbd" # Sentence-segmentation capability id (B.5; loaded only when sentence_split)
123
123
  respine: bool = False # Mint a FRESH skeleton under the SAME config (DEC 9241564f): the run id joins the identity input, so the new spine never collides with the config-identical one (post-upgrade re-runs, finding e8458f6e)
124
124
  event_split: bool = False # Run the post-FA event-carve stage (respine trial DEC 6cc10fb7): model event spans from `event_propset` become gaps between chunks (cut-don't-label); composable with sentence_split, but the trial runs it INSTEAD
125
- event_propset: str = "" # ProposalSetManifest pointer (manifest json or its set dir) — the model-cut authority consumed BY POINTER; REQUIRED when event_split
125
+ event_propset: str = "" # ProposalSetManifest pointer (manifest json or its set dir) — the model-cut authority consumed BY POINTER; the SINGLE-source form (kept for config-snapshot compatibility; REQUIRED when event_split and event_propsets is empty)
126
+ event_propsets: List[str] = field(default_factory=list) # Per-source ProposalSetManifest pointers (0.2.6, multi-source runs): each set joins ITS source by the manifest's own source binding (content hash, else path) — resolve_event_propsets refuses an uncovered source or a stray pointer loudly
126
127
  event_classes: List[str] = field(default_factory=lambda: ["inhale"]) # Proposal classes that carve (word-bearing classes like hesitation-marker must never cut)
127
128
  word_rescue: bool = True # Run the post-carve word-rescue stage (96edc646 verdict bc7ece7b): authoritative FA words stranded outside every chunk get chunks minted — DEFAULT-ON (mis-homing is silent data corruption; opting out hides it)
128
129
 
@@ -141,6 +142,9 @@ class DecompSourceRecord:
141
142
  title: str # Source display title
142
143
  segment_count: int # Number of fine Segment nodes committed
143
144
  segment_ids: List[str] = field(default_factory=list) # Graph Segment node ids, in order
145
+ skeleton_config_hash: str = "" # THIS source's Segment identity input (0.2.6): equals the run-level value unless the event stage carved per source
146
+ event_propset_id: str = "" # The ProposalSetManifest THIS source's carve consumed (0.2.6; per-source propset -> skeleton chain join key)
147
+ event_propset: str = "" # Recorded pointer of that set (0.2.6)
144
148
 
145
149
  def to_dict(self) -> Dict[str, Any]: # Plain-dict form
146
150
  """Serialize to a plain dict."""
@@ -169,7 +173,7 @@ class DecompManifest:
169
173
  event_propset: str = "" # Recorded path of the consumed ProposalSetManifest (0.2.4; the pointer)
170
174
 
171
175
  FORMAT: str = field(default="cjm-transcript-decomp-core/run-manifest", repr=False) # Format tag
172
- VERSION: str = field(default="0.2.5", repr=False) # Schema version (0.2.5: word-rescue policy recorded; 0.2.4: event-carve respine — propset pointer + id + event policy recorded; 0.2.3: capability-driven sentence split)
176
+ VERSION: str = field(default="0.2.6", repr=False) # Schema version (0.2.6: per-source skeleton hash + consumed propset on each source record — multi-source event carve; the run-level fields hold the value only when every source shares it; 0.2.5: word-rescue policy recorded; 0.2.4: event-carve respine — propset pointer + id + event policy recorded; 0.2.3: capability-driven sentence split)
173
177
 
174
178
  def to_dict(self) -> Dict[str, Any]: # Plain-dict form for JSON serialization
175
179
  """Serialize to a plain dict with nested sources."""
@@ -481,27 +481,47 @@ async def run_decomp(
481
481
  # (the set IS the cut authority) and the pointer + id land in the manifest
482
482
  # (propset -> skeleton chain, DEC 16159e09 manifest-kinds-chainable).
483
483
  event_policy = EVENT_SPLIT_POLICY if cfg.event_split else None
484
- event_spans: Optional[List[Tuple[float, float]]] = None
484
+ # Per-source carve authority (manifest 0.2.6): every source resolves ITS
485
+ # set by the set manifest's own source binding (resolve_event_propsets —
486
+ # the single-pointer config form is the one-element list), and each
487
+ # source's set id joins THAT source's skeleton identity. The run-level
488
+ # fields hold a value only when every source shares it, so single-source
489
+ # runs are byte-identical to 0.2.5 and multi-source runs never pretend
490
+ # one authority carved them all.
491
+ run_sources = src.get("sources", []) or []
492
+ per_source_propsets: List[Tuple[Dict[str, Any], List[Tuple[float, float]], str]] = []
485
493
  if cfg.event_split:
486
- if not cfg.event_propset:
494
+ pointers = list(cfg.event_propsets) or ([cfg.event_propset] if cfg.event_propset else [])
495
+ if not pointers:
487
496
  raise RuntimeError("event_split requires event_propset (the ProposalSetManifest pointer)")
488
- propset_manifest, event_spans = event_spans_from_propset(cfg.event_propset, cfg.event_classes)
489
- manifest.event_propset_id = str(propset_manifest.get("proposal_set_id") or "")
490
- manifest.event_propset = str(cfg.event_propset)
491
- logger.info(f"run {run_id}: event-carve consuming {manifest.event_propset_id} "
492
- f"({len(event_spans)} span(s) across classes {cfg.event_classes})")
497
+ per_source_propsets = resolve_event_propsets(pointers, run_sources, cfg.event_classes)
498
+ set_ids = sorted({str(m.get("proposal_set_id") or "") for m, _, _ in per_source_propsets})
499
+ manifest.event_propset_id = set_ids[0] if len(set_ids) == 1 else ""
500
+ manifest.event_propset = per_source_propsets[0][2] if len(set_ids) == 1 else ""
501
+ for i, (m, spans, _ptr) in enumerate(per_source_propsets):
502
+ logger.info(f"run {run_id}: event-carve source {i} consuming "
503
+ f"{m.get('proposal_set_id')} ({len(spans)} span(s) across "
504
+ f"classes {cfg.event_classes})")
493
505
  word_rescue_policy = WORD_RESCUE_POLICY if cfg.word_rescue else None
494
- skeleton_config_hash = compute_skeleton_hash(
495
- vad_config_hash, split_policy=split_policy,
496
- split_min_chunk_s=cfg.split_min_chunk_s,
497
- seg_capability=cfg.seg_capability, seg_config_hash=seg_config_hash,
498
- respine_token=run_id if cfg.respine else None,
499
- event_policy=event_policy,
500
- event_propset_id=(manifest.event_propset_id if cfg.event_split else ""),
501
- event_classes=(cfg.event_classes if cfg.event_split else None),
502
- text_from_capability=str(text_from or ""),
503
- text_from_config_hash=str((src_capabilities.get(text_from) or {}).get("config_hash") or ""),
504
- word_rescue_policy=word_rescue_policy)
506
+
507
+ def _skeleton_hash(event_propset_id: str) -> str:
508
+ return compute_skeleton_hash(
509
+ vad_config_hash, split_policy=split_policy,
510
+ split_min_chunk_s=cfg.split_min_chunk_s,
511
+ seg_capability=cfg.seg_capability, seg_config_hash=seg_config_hash,
512
+ respine_token=run_id if cfg.respine else None,
513
+ event_policy=event_policy,
514
+ event_propset_id=(event_propset_id if cfg.event_split else ""),
515
+ event_classes=(cfg.event_classes if cfg.event_split else None),
516
+ text_from_capability=str(text_from or ""),
517
+ text_from_config_hash=str((src_capabilities.get(text_from) or {}).get("config_hash") or ""),
518
+ word_rescue_policy=word_rescue_policy)
519
+ source_skeletons = ([_skeleton_hash(str(m.get("proposal_set_id") or ""))
520
+ for m, _, _ in per_source_propsets] if cfg.event_split
521
+ else [_skeleton_hash("")] * len(run_sources))
522
+ distinct_skeletons = sorted(set(source_skeletons))
523
+ skeleton_config_hash = (distinct_skeletons[0] if len(distinct_skeletons) == 1
524
+ else "" if distinct_skeletons else _skeleton_hash(""))
505
525
  manifest.skeleton_config_hash = skeleton_config_hash
506
526
  manifest.split_policy = split_policy
507
527
  manifest.event_split_policy = event_policy
@@ -526,7 +546,7 @@ async def run_decomp(
526
546
 
527
547
  source_path, aligned = await decompose_source(
528
548
  queue, cfg, source, i, transcribers, text_from,
529
- event_spans=event_spans)
549
+ event_spans=(per_source_propsets[i][1] if cfg.event_split else None))
530
550
  title = Path(source_path).stem or f"source-{i}"
531
551
 
532
552
  empty = sum(1 for a in aligned if not a.text.strip())
@@ -543,7 +563,7 @@ async def run_decomp(
543
563
  policy_label = "+".join(
544
564
  p for p in (split_policy, event_policy, word_rescue_policy) if p) or None
545
565
  nodes, edges, ids = build_extension_payload(
546
- source, src_capabilities, skeleton_config_hash, text_from, aligned,
566
+ source, src_capabilities, source_skeletons[i], text_from, aligned,
547
567
  split_policy=policy_label)
548
568
 
549
569
  if not confirm_seam("commit-review",
@@ -605,7 +625,11 @@ async def run_decomp(
605
625
 
606
626
  manifest.sources.append(DecompSourceRecord(
607
627
  source_node_id=ids["source"], source_path=source_path, title=title,
608
- segment_count=len(ids["segments"]), segment_ids=ids["segments"]))
628
+ segment_count=len(ids["segments"]), segment_ids=ids["segments"],
629
+ skeleton_config_hash=source_skeletons[i],
630
+ event_propset_id=(str(per_source_propsets[i][0].get("proposal_set_id") or "")
631
+ if cfg.event_split else ""),
632
+ event_propset=(per_source_propsets[i][2] if cfg.event_split else "")))
609
633
  except BaseException as e:
610
634
  # The journal exists for exactly this row: a run that DIED records
611
635
  # how far it got (failures stop being the unattributed case).
@@ -749,3 +773,47 @@ def event_spans_from_propset(
749
773
  spans.append((s, e))
750
774
  spans.sort()
751
775
  return manifest, spans
776
+
777
+
778
+ def resolve_event_propsets(
779
+ pointers: List[Union[str, Path]], # ProposalSetManifest pointers (manifest json or set dir), any order
780
+ sources: List[Dict[str, Any]], # The transcription run's sources, manifest order
781
+ classes: List[str], # Proposal classes that carve
782
+ ) -> List[Tuple[Dict[str, Any], List[Tuple[float, float]], str]]: # Per source, manifest order: (set manifest, carve spans, the pointer as given)
783
+ """Join each proposal set to ITS source by the set manifest's own source
784
+ binding (multi-source event carve, manifest 0.2.6): a set matches a run
785
+ source when its source.content_hash equals the source's content_hash,
786
+ else when its source.path equals the source's source_path. One set
787
+ carves ONE source, so every refusal is LOUD: a source no pointer covers
788
+ (propose over it first), a pointer claiming none of the run's sources
789
+ (a wrong pick), two pointers claiming one source (ambiguous authority).
790
+ A lone pointer over a lone source with NEITHER key matching is refused
791
+ too — that is exactly the silent-wrong-spans class the batch guard
792
+ existed for (a set whose manifest names a different media file)."""
793
+ loaded = [(str(p),) + event_spans_from_propset(p, classes) for p in pointers]
794
+ out: List[Tuple[Dict[str, Any], List[Tuple[float, float]], str]] = []
795
+ claimed: Dict[str, int] = {}
796
+ for i, src in enumerate(sources):
797
+ h = str(src.get("content_hash") or "")
798
+ path = str(src.get("source_path") or "")
799
+ hits = [(ptr, m, spans) for ptr, m, spans in loaded
800
+ if (h and str((m.get("source") or {}).get("content_hash") or "") == h)
801
+ or (path and str((m.get("source") or {}).get("path") or "") == path)]
802
+ name = Path(path).name if path else (h or f"source-{i}")
803
+ if not hits:
804
+ raise RuntimeError(
805
+ f"event-split: no proposal set covers source {i} ({name}) — "
806
+ f"propose over it first (pointers given: {[p for p, _, _ in loaded]})")
807
+ if len(hits) > 1:
808
+ raise RuntimeError(
809
+ f"event-split: {len(hits)} proposal sets claim source {i} ({name}): "
810
+ f"{[p for p, _, _ in hits]} — pass exactly one set per source")
811
+ ptr, m, spans = hits[0]
812
+ claimed[ptr] = i
813
+ out.append((m, spans, ptr))
814
+ stray = [ptr for ptr, _, _ in loaded if ptr not in claimed]
815
+ if stray:
816
+ raise RuntimeError(
817
+ f"event-split: proposal set(s) {stray} bind to none of this run's "
818
+ f"{len(sources)} source(s) — a set carves only the source its manifest names")
819
+ return out
@@ -9,10 +9,12 @@ tag — the manifest-as-interchange contract (CR-20) — is what separates
9
9
  transcription runs from decomp runs living in one directory."""
10
10
 
11
11
  import json
12
+ import time
12
13
  from pathlib import Path
13
14
  from typing import Any, Dict, List, Optional, Tuple
14
15
 
15
16
  from cjm_substrate.core.workspace import resolve_recorded_tree
17
+ from cjm_substrate.utils.lifecycle import partition_lifecycle
16
18
 
17
19
 
18
20
  def _load_manifests(
@@ -186,7 +188,8 @@ class PropsetIndex:
186
188
 
187
189
  def __init__(self, proposals_dir: str = "proposals"): # Workspace proposals/ (else cwd-relative)
188
190
  self.proposals_dir = Path(proposals_dir)
189
- self.sets: List[Dict[str, Any]] = [] # Manifest dicts, newest first (+ "_path")
191
+ self.sets: List[Dict[str, Any]] = [] # ACTIVE manifest dicts, newest first (+ "_path", "_lifecycle")
192
+ self.archived: List[Dict[str, Any]] = [] # Archived sets (lifecycle sidecar, b20cb911) — off every ring, still on disk
190
193
 
191
194
  def load(self) -> int: # Number of proposal sets loaded
192
195
  """(Re)read every readable proposal-set manifest, newest first (the
@@ -206,8 +209,11 @@ class PropsetIndex:
206
209
  m["_path"] = str(f)
207
210
  rows.append(m)
208
211
  rows.sort(key=lambda m: float(m.get("created_at") or 0.0), reverse=True)
209
- self.sets = rows
210
- return len(rows)
212
+ # Lifecycle (b20cb911, DEC 14678fc9): an archived set leaves every
213
+ # ring and default (for_source reads `sets`) but stays on disk under
214
+ # `archived` — the sidecar seam in cjm_substrate.utils.lifecycle.
215
+ self.sets, self.archived = partition_lifecycle(rows)
216
+ return len(self.sets)
211
217
 
212
218
  def for_source(
213
219
  self,
@@ -251,7 +257,8 @@ class TrainingRunIndex:
251
257
 
252
258
  def __init__(self, training_runs_dir: str = "training-runs"): # Workspace training-runs/ (else cwd-relative)
253
259
  self.training_runs_dir = Path(training_runs_dir)
254
- self.runs: List[Dict[str, Any]] = [] # Manifest dicts, newest first (+ "_path")
260
+ self.runs: List[Dict[str, Any]] = [] # ACTIVE manifest dicts, newest first (+ "_path", "_lifecycle")
261
+ self.archived: List[Dict[str, Any]] = [] # Archived runs (lifecycle sidecar, b20cb911) — off the ladder, shown on demand
255
262
 
256
263
  def load(self) -> int: # Number of training runs loaded
257
264
  """(Re)read every readable training-run manifest, newest first (run
@@ -271,8 +278,11 @@ class TrainingRunIndex:
271
278
  m["_path"] = str(f)
272
279
  rows.append(m)
273
280
  rows.sort(key=lambda m: str(m.get("run_id") or ""), reverse=True)
274
- self.runs = rows
275
- return len(rows)
281
+ # Lifecycle (b20cb911, DEC 14678fc9): an archived run leaves the
282
+ # ladder and the picker's default list but stays on disk under
283
+ # `archived` — the m picker's h toggle lists it dimmed, a/x act on it.
284
+ self.runs, self.archived = partition_lifecycle(rows)
285
+ return len(self.runs)
276
286
 
277
287
  def resolve(
278
288
  self,
@@ -294,3 +304,87 @@ class TrainingRunIndex:
294
304
  """Footer text for the two-step confirm: run id tail + trained classes."""
295
305
  classes = ",".join(m.get("classes") or []) or "?"
296
306
  return f"{str(m.get('run_id') or '?')[-8:]} [{classes}]"
307
+
308
+ def resolve_pick(
309
+ self,
310
+ pick: Optional[str] = None, # In-session picker choice: a run's _path or run id (tail ok)
311
+ pin: Optional[str] = None, # Workspace-marker pin (the standing default)
312
+ ) -> Tuple[Optional[Dict[str, Any]], str]: # (run or None, the reason the confirm names)
313
+ """The propose-model ladder (work item a13ebc66): PICK > PIN > NEWEST.
314
+ A picker choice made in this sitting outranks the workspace marker's
315
+ flywheel.event_training_run pin — the pin demotes from the only
316
+ control to the fallback default, so swapping to a test run and back
317
+ no longer means hand-editing cjm-workspace.yaml (and the yaml's
318
+ provenance comments are never rewritten). Every miss is LOUD (None +
319
+ the reason): a pick whose run vanished from disk, or a pin matching
320
+ nothing, never silently falls through to a different model (the
321
+ b9717422 field-failure class the register retired overlay-v1 for)."""
322
+ if pick:
323
+ for m in self.runs:
324
+ rid = str(m.get("run_id") or "")
325
+ if m.get("_path") == pick or rid == pick or rid.endswith(pick):
326
+ return m, "picked"
327
+ gone = self._archived_match(pick)
328
+ if gone is not None:
329
+ return None, (f"picked run …{str(gone.get('run_id') or '?')[-8:]} "
330
+ f"is ARCHIVED — m picks another (h shows archived)")
331
+ return None, f"picked run {pick!r} no longer on disk"
332
+ if pin:
333
+ run = self.resolve(pin)
334
+ if run is not None:
335
+ return run, "workspace pin"
336
+ if self._archived_match(pin) is not None:
337
+ return None, (f"training-run pin {pin!r} matches an ARCHIVED "
338
+ f"run — m picks another, or unarchive it")
339
+ return None, (f"training-run pin {pin!r} matches nothing under "
340
+ f"{self.training_runs_dir}")
341
+ if self.runs:
342
+ return self.runs[0], "newest run, no pin"
343
+ return None, f"no training runs under {self.training_runs_dir}"
344
+
345
+ def _archived_match(
346
+ self,
347
+ handle: str, # A run's _path, run id, or id tail
348
+ ) -> Optional[Dict[str, Any]]: # The ARCHIVED run the handle names, or None
349
+ """The loud-miss lookup: a pick or pin that resolves to nothing in
350
+ `runs` may name a run that was ARCHIVED (b20cb911) — the reason
351
+ says so instead of 'no longer on disk' / 'matches nothing'."""
352
+ for m in self.archived:
353
+ rid = str(m.get("run_id") or "")
354
+ if m.get("_path") == handle or rid == handle or rid.endswith(handle):
355
+ return m
356
+ return None
357
+
358
+ @staticmethod
359
+ def describe(m: Dict[str, Any]) -> str: # One picker-row line of manifest facts
360
+ """A picker row's facts, read off the manifest alone (capability-
361
+ generic): run id · base model · [classes] · the headline eval
362
+ (macro AUROC when the holdout block carries it, else the first
363
+ metric) · consumed dataset tail · mint date."""
364
+ parts = [str(m.get("run_id") or "?")]
365
+ base = str((m.get("base_model") or {}).get("model_id")
366
+ or (m.get("config") or {}).get("base_model_id") or "")
367
+ if base:
368
+ segs = base.rstrip("/").split("/")
369
+ # a hub id ends in the model name; a local checkpoint path ends
370
+ # in a weights FILE — name its directory instead
371
+ ext = segs[-1].rsplit(".", 1)[-1].lower()
372
+ parts.append(segs[-2] if len(segs) > 1 and ext in
373
+ ("bin", "ckpt", "pt", "pth", "safetensors")
374
+ else segs[-1])
375
+ parts.append("[" + (",".join(m.get("classes") or []) or "?") + "]")
376
+ ev = m.get("eval") or {}
377
+ macro = ((ev.get("holdout") or {}).get("auroc") or {}).get("macro")
378
+ metrics = ev.get("metrics") or {}
379
+ if isinstance(macro, (int, float)):
380
+ parts.append(f"auroc {macro:.3f}")
381
+ elif metrics:
382
+ k, v = next(iter(metrics.items()))
383
+ parts.append(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}")
384
+ ds = str(m.get("dataset_id") or "")
385
+ if ds:
386
+ parts.append(f"ds …{ds[-8:]}")
387
+ created = m.get("created_at")
388
+ if created:
389
+ parts.append(time.strftime("%Y-%m-%d", time.localtime(float(created))))
390
+ return " · ".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-decomp-core
3
- Version: 0.0.6
3
+ Version: 0.0.10
4
4
  Summary: A frontend-agnostic core for the transcript decomposition workflow — composes isolated capability workers (forced alignment, VAD, graph storage) into a headless pipeline that decomposes transcription run manifests into a VAD-aligned context-graph spine with traceable provenance, and a CLI as its first driver.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.12
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: cjm_substrate>=0.0.61
13
+ Requires-Dist: cjm_substrate>=0.0.63
14
14
  Requires-Dist: pyyaml
15
15
  Requires-Dist: cjm_context_graph_primitives>=0.0.15
16
16
  Requires-Dist: cjm_capability_primitives>=0.0.11
@@ -26,7 +26,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
26
26
 
27
27
  ## Modules
28
28
 
29
- - **`cjm_transcript_decomp_core`**
29
+ - **`cjm_transcript_decomp_core.__init__`**
30
30
  - **`cjm_transcript_decomp_core.alignment`** — 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).
31
31
  - **`cjm_transcript_decomp_core.cli`** — The CLI driver — the decomposition core's first (and currently only) frontend.
32
32
  - **`cjm_transcript_decomp_core.discovery`** — Capability-role discovery by manifest surface match — the journaling-by-
@@ -53,6 +53,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
53
53
 
54
54
  ### `cjm_transcript_decomp_core.cli`
55
55
 
56
+ - `backfill_provenance_command` _function_ — Execute `backfill-provenance`: mint the Segment -> Transcript DERIVED_FROM
56
57
  - `build_parser` _function_ — Build the CLI parser (subcommands: run).
57
58
  - `load_capabilities` _function_ — Discover manifests + load each requested capability (default instance).
58
59
  - `main` _function_ — CLI entry point (console script: `cjm-transcript-decomp-core`).
@@ -67,7 +68,9 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
67
68
 
68
69
  - `SourceVerification` _class_ — Skeptical-lens verification of one Source's fine-spine extension under a
69
70
  - `build_extension_payload` _function_ — Build the fine-spine EXTENSION payload (pure; no capability calls).
71
+ - `provenance_edges_from_segment_wire` _function_ — Derive a Segment's Transcript provenance edges from the node itself:
70
72
  - `resolve_root_ids` _function_ — Recompute the transcription-emitted root node ids from manifest data.
73
+ - `segment_provenance_edges` _function_ — Text provenance as EDGES (finding 89b16be6, the references-must-be-edges
71
74
  - `verify_source` _function_ — Verify a Source's committed extension via server-side AGGREGATES (D13/D19).
72
75
 
73
76
  ### `cjm_transcript_decomp_core.launch`
@@ -102,6 +105,7 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
102
105
  - `event_spans_from_propset` _function_ — Load a proposal set BY POINTER and select its carve spans (respine trial
103
106
  - `fa_words_from_result` _function_ — Normalize a typed forced-alignment result into FA words (pure; stage 3).
104
107
  - `load_source_manifest` _function_ — Load + lightly validate a transcription-core run manifest.
108
+ - `resolve_event_propsets` _function_ — Join each proposal set to ITS source by the set manifest's own source
105
109
  - `run_decomp` _function_ — Extend every source in a transcription run manifest with its fine spine.
106
110
  - `sentence_spans_from_result` _function_ — Normalize a typed sentence-segmentation result into char-span tuples (pure; B.5).
107
111
  - `submit_and_wait` _function_ — Submit one capability job, wait for it, and return its result (raise on failure).
@@ -140,4 +144,4 @@ A frontend-agnostic core for the transcript decomposition workflow — composes
140
144
  ## Dependencies
141
145
 
142
146
  **Depends on:** `cjm-capability-primitives`, `cjm-context-graph-layer`, `cjm-context-graph-primitives`, `cjm-substrate`, `cjm-transcript-graph-schema`, `pyyaml`
143
- **Used by:** `cjm-transcript-decomp-qt`, `cjm-transcript-decomp-tui`
147
+ **Used by:** `cjm-transcript-decomp-qt`
@@ -1,4 +1,4 @@
1
- cjm_substrate>=0.0.61
1
+ cjm_substrate>=0.0.63
2
2
  pyyaml
3
3
  cjm_context_graph_primitives>=0.0.15
4
4
  cjm_capability_primitives>=0.0.11
@@ -14,7 +14,7 @@ classifiers = [
14
14
  "Programming Language :: Python :: 3",
15
15
  "Programming Language :: Python :: 3 :: Only",
16
16
  ]
17
- dependencies = ['cjm_substrate>=0.0.61', 'pyyaml', 'cjm_context_graph_primitives>=0.0.15', 'cjm_capability_primitives>=0.0.11', 'cjm-context-graph-layer>=0.0.11', 'cjm-transcript-graph-schema>=0.0.7']
17
+ dependencies = ['cjm_substrate>=0.0.63', 'pyyaml', 'cjm_context_graph_primitives>=0.0.15', 'cjm_capability_primitives>=0.0.11', 'cjm-context-graph-layer>=0.0.11', 'cjm-transcript-graph-schema>=0.0.7']
18
18
 
19
19
  [project.urls]
20
20
  Repository = "https://github.com/cj-mills/cjm-transcript-decomp-core"
@@ -293,3 +293,134 @@ def test_training_run_index_discovery_and_resolve(tmp_path):
293
293
  # Empty dir: no runs, loud None
294
294
  empty = TrainingRunIndex(str(tmp_path / "nowhere"))
295
295
  assert empty.load() == 0 and empty.resolve() is None
296
+
297
+
298
+ def test_training_run_resolve_pick_ladder_and_describe(tmp_path):
299
+ """Work item a13ebc66 (the propose training-run picker): the model
300
+ ladder is PICK > PIN > NEWEST with the reason named alongside, and every
301
+ miss is a loud None (a vanished pick, a pin matching nothing) — never a
302
+ silent fall-through to a different model. describe() reads a picker
303
+ row off the manifest alone: id · base · [classes] · headline eval ·
304
+ dataset tail · date."""
305
+ from cjm_transcript_decomp_core.runs import TrainingRunIndex
306
+ d = tmp_path / "training-runs"
307
+
308
+ def _run(run_id, classes, extra=None):
309
+ rd = d / run_id
310
+ rd.mkdir(parents=True)
311
+ m = {"format": TrainingRunIndex.FORMAT, "run_id": run_id, "classes": classes}
312
+ m.update(extra or {})
313
+ (rd / "manifest.json").write_text(json.dumps(m))
314
+
315
+ _run("trainrun_20260731_002436_6f803b12", ["speech", "inhale"],
316
+ {"base_model": {"model_id": "pyannote/segmentation-3.0"},
317
+ "dataset_id": "dataset_20260730_e53b8daa", "created_at": 1785000000.0,
318
+ "eval": {"holdout": {"auroc": {"macro": 0.99659}},
319
+ "metrics": {"loss/val": 0.0634}}})
320
+ _run("trainrun_20260805_104926_73000552", ["click", "inhale"],
321
+ {"eval": {"metrics": {"loss/val": 0.0812}}})
322
+ idx = TrainingRunIndex(str(d))
323
+ assert idx.load() == 2
324
+ # No pick, no pin: newest is only a default, and the reason says so
325
+ run, why = idx.resolve_pick(None, None)
326
+ assert run["run_id"].endswith("73000552") and why == "newest run, no pin"
327
+ # The pin is the standing default over recency
328
+ run, why = idx.resolve_pick(None, "6f803b12")
329
+ assert run["run_id"].endswith("6f803b12") and why == "workspace pin"
330
+ # A pick outranks the pin — by _path, full id, or tail
331
+ older = idx.runs[1]
332
+ for pick in (older["_path"], older["run_id"], "6f803b12"):
333
+ run, why = idx.resolve_pick(pick, "73000552")
334
+ assert run["run_id"].endswith("6f803b12") and why == "picked"
335
+ run, why = idx.resolve_pick("73000552", "6f803b12")
336
+ assert run["run_id"].endswith("73000552") and why == "picked"
337
+ # Misses are LOUD: a vanished pick never falls through to the pin
338
+ run, why = idx.resolve_pick("deadbeef", "6f803b12")
339
+ assert run is None and "deadbeef" in why and "no longer on disk" in why
340
+ run, why = idx.resolve_pick(None, "deadbeef")
341
+ assert run is None and "matches nothing" in why
342
+ empty = TrainingRunIndex(str(tmp_path / "nowhere"))
343
+ empty.load()
344
+ assert empty.resolve_pick(None, None) == (None, f"no training runs under {empty.training_runs_dir}")
345
+ # describe(): the row facts a picker paints
346
+ line = TrainingRunIndex.describe(older)
347
+ assert line.startswith("trainrun_20260731_002436_6f803b12 · segmentation-3.0 · [speech,inhale]")
348
+ assert "auroc 0.997" in line and "ds …e53b8daa" in line and "2026-07-" in line
349
+ # No holdout block: the first metric is the headline; no base/ds: elided
350
+ line = TrainingRunIndex.describe(idx.runs[0])
351
+ assert "loss/val 0.081" in line and "ds …" not in line and "segmentation" not in line
352
+
353
+
354
+ def test_batch_argv_renders_one_pointer_per_source():
355
+ """Multi-source event carve (decomp manifest 0.2.6): a group's resolved
356
+ propset may be a LIST/TUPLE (one pointer per source of a multi-source
357
+ run) — batch_argv renders every pointer under one --event-propset, in
358
+ order, and the single-source str form renders exactly as before."""
359
+ args = resolve_split_flags(build_parser().parse_args(
360
+ ["--event-split", "--event-classes", "inhale"]))
361
+ multi = batch_argv({"text_from": None, "graph_db_path": None,
362
+ "event_propset": ("/sets/p_src0", "/sets/p_src1"),
363
+ "manifests": ["ab.json"]}, args, None)
364
+ i = multi.index("--event-propset")
365
+ assert multi[i + 1:i + 3] == ["/sets/p_src0", "/sets/p_src1"]
366
+ assert multi[i + 3] == "--event-classes"
367
+ single = batch_argv({"text_from": None, "graph_db_path": None,
368
+ "event_propset": "/sets/p_only",
369
+ "manifests": ["a.json"]}, args, None)
370
+ j = single.index("--event-propset")
371
+ assert single[j + 1] == "/sets/p_only" and single[j + 2] == "--event-classes"
372
+
373
+
374
+ def test_lifecycle_archived_artifacts_leave_pickers_and_misses_are_loud(tmp_path):
375
+ """Work item b20cb911 (DEC 14678fc9): an archived training run / proposal
376
+ set (a lifecycle.json sidecar beside the manifest, the substrate seam)
377
+ leaves the index's default list — the ladder never resolves to it and
378
+ for_source skips it — but stays reachable via `archived`, and a pick or
379
+ pin that lands on one is a LOUD miss naming the archive, never a silent
380
+ fall-through to another model."""
381
+ from cjm_substrate.utils.lifecycle import ArtifactLifecycle
382
+ from cjm_transcript_decomp_core.runs import PropsetIndex, TrainingRunIndex
383
+ trs = tmp_path / "training-runs"
384
+ for rid in ("trainrun_1_6f803b12", "trainrun_2_fa3035c6"):
385
+ (trs / rid).mkdir(parents=True)
386
+ (trs / rid / "manifest.json").write_text(json.dumps(
387
+ {"format": TrainingRunIndex.FORMAT, "run_id": rid, "classes": ["speech"]}))
388
+ idx = TrainingRunIndex(str(trs))
389
+ assert idx.load() == 2 and idx.archived == []
390
+ ArtifactLifecycle(trs / "trainrun_2_fa3035c6").archive(reason="test run")
391
+ assert idx.load() == 1
392
+ assert [m["run_id"][-8:] for m in idx.runs] == ["6f803b12"]
393
+ assert [m["run_id"][-8:] for m in idx.archived] == ["fa3035c6"]
394
+ assert idx.runs[0]["_lifecycle"] == "active"
395
+ assert idx.archived[0]["_lifecycle"] == "archived"
396
+ # newest-no-pin skips the archived head; a pin or pick on it is loud
397
+ run, why = idx.resolve_pick(None, None)
398
+ assert run["run_id"].endswith("6f803b12") and why == "newest run, no pin"
399
+ run, why = idx.resolve_pick(None, "fa3035c6")
400
+ assert run is None and "ARCHIVED" in why and "fa3035c6" in why
401
+ run, why = idx.resolve_pick(idx.archived[0]["_path"], "6f803b12")
402
+ assert run is None and "ARCHIVED" in why and "fa3035c6" in why
403
+ run, why = idx.resolve_pick(None, "deadbeef")
404
+ assert run is None and "matches nothing" in why
405
+ # unarchive restores it as the newest default
406
+ ArtifactLifecycle(trs / "trainrun_2_fa3035c6").unarchive()
407
+ assert idx.load() == 2
408
+ assert idx.resolve_pick(None, None)[0]["run_id"].endswith("fa3035c6")
409
+ # proposal sets: the archived set leaves its source's ring, the older
410
+ # generation heads it again, and `archived` keeps it inspectable
411
+ props = tmp_path / "proposals"
412
+ for pid, created in (("propset_1_aaaaaaaa", 1.0), ("propset_2_bbbbbbbb", 2.0)):
413
+ (props / pid).mkdir(parents=True)
414
+ (props / pid / "manifest.json").write_text(json.dumps(
415
+ {"format": PropsetIndex.FORMAT, "proposal_set_id": pid,
416
+ "created_at": created, "counts": {"inhale": 1},
417
+ "source": {"content_hash": "sha256:xx", "path": "/x.wav"}}))
418
+ pidx = PropsetIndex(str(props))
419
+ assert pidx.load() == 2
420
+ assert [m["proposal_set_id"][-8:] for m in pidx.for_source("sha256:xx")] \
421
+ == ["bbbbbbbb", "aaaaaaaa"]
422
+ ArtifactLifecycle(props / "propset_2_bbbbbbbb").archive()
423
+ assert pidx.load() == 1
424
+ assert [m["proposal_set_id"][-8:] for m in pidx.for_source("sha256:xx")] \
425
+ == ["aaaaaaaa"]
426
+ assert [m["proposal_set_id"][-8:] for m in pidx.archived] == ["bbbbbbbb"]
@@ -123,3 +123,35 @@ def test_parallel_spines_disjoint_ids_and_split_metadata():
123
123
  assert all("split_policy" not in n["properties"] for n in base_nodes)
124
124
  assert all(n["properties"]["skeleton_hash"] == "sha256:skel-split" for n in split_nodes)
125
125
  assert all(n["properties"]["split_policy"] == "sentence-split/v1" for n in split_nodes)
126
+
127
+
128
+ def test_segment_transcript_provenance_edges():
129
+ # finding 89b16be6: a Segment's text provenance is an EDGE, not only metadata —
130
+ # DERIVED_FROM per sliced Transcript, the authoritative one role=text_from, the
131
+ # others role=variant; an empty (audio-only) segment derives no text edge; and the
132
+ # wire-derived form (the backfill's input) reproduces the builder's edges exactly.
133
+ from cjm_transcript_decomp_core.graph import (provenance_edges_from_segment_wire,
134
+ segment_provenance_edges)
135
+ roots = resolve_root_ids(SOURCE_ENTRY, CAPABILITIES)
136
+ tv = roots["audio_segments"][0]["transcripts"]["voxtral"]
137
+ tw = roots["audio_segments"][0]["transcripts"]["whisper"]
138
+ nodes, edges, ids = build_extension_payload(SOURCE_ENTRY, CAPABILITIES, "sha256:vad", "voxtral", _segments())
139
+ derived = [e for e in edges if e["relation_type"] == "DERIVED_FROM"]
140
+ # seg 0 slices voxtral + whisper (2 edges), seg 1 voxtral only (1), seg 2 empty (0)
141
+ assert len(derived) == 3
142
+ by_src = {}
143
+ for e in derived:
144
+ by_src.setdefault(e["source_id"], []).append(e)
145
+ s0 = {e["target_id"]: e["properties"]["role"] for e in by_src[ids["segments"][0]]}
146
+ assert s0 == {tv: "text_from", tw: "variant"}
147
+ assert [(e["target_id"], e["properties"]["role"]) for e in by_src[ids["segments"][1]]] == [(tv, "text_from")]
148
+ assert ids["segments"][2] not in by_src
149
+ # Deterministic ids from the triple: a rebuilt edge collides into the same id.
150
+ again = segment_provenance_edges(ids["segments"][0], [tw, tv], tv)
151
+ assert {e["id"] for e in again} == {e["id"] for e in by_src[ids["segments"][0]]}
152
+ # The backfill derivation (from the node's own refs) == the builder's edges.
153
+ for n in nodes:
154
+ assert provenance_edges_from_segment_wire(n) == by_src.get(n["id"], [])
155
+ # A projected query row carries text_from as a field, not under properties.
156
+ row = {"id": nodes[0]["id"], "text_from": tv, "sources": nodes[0]["sources"]}
157
+ assert provenance_edges_from_segment_wire(row) == by_src[nodes[0]["id"]]
@@ -1,6 +1,8 @@
1
1
  """Tests for cjm_transcript_decomp_core.models — decomposition data shapes.
2
2
 
3
3
  Projected from the models notebook's smoke-check cell at the golden-reference flip."""
4
+ import re
5
+
4
6
  from cjm_transcript_decomp_core.models import (
5
7
  DecompManifest,
6
8
  DecompSegment,
@@ -38,7 +40,11 @@ def test_manifest_shape_and_run_id():
38
40
  m = DecompManifest(run_id="r", created_at=0.0, config={}, source_manifest="/tmp/s.json")
39
41
  md = m.to_dict()
40
42
  assert md["format"] == "cjm-transcript-decomp-core/run-manifest"
41
- assert md["version"] == "0.2.5" and md["sources"] == []
43
+ # Compare to the dataclass default, not a literal: the literal pin went RED on the first
44
+ # run after every manifest bump (0.2.3 -> 0.2.6), training agents to ignore a red test
45
+ # (housekeeping 042b7805). The shape check below keeps the version a real semver.
46
+ assert md["version"] == m.VERSION and md["sources"] == []
47
+ assert re.fullmatch(r"\d+\.\d+\.\d+", m.VERSION)
42
48
  # 0.2.2: skeleton identity rides the manifest (empty/None until run_decomp fills them)
43
49
  assert md["skeleton_config_hash"] == "" and md["split_policy"] is None
44
50
  # 0.2.4: event-carve chain fields (empty/None until an event_split run fills them)
@@ -194,3 +194,54 @@ def test_event_carve_identity_and_propset_loading(tmp_path):
194
194
  import pytest
195
195
  with pytest.raises(RuntimeError):
196
196
  event_spans_from_propset(tmp_path / "missing", ["inhale"])
197
+
198
+
199
+ def test_resolve_event_propsets_joins_each_set_to_its_source(tmp_path):
200
+ """Multi-source event carve (manifest 0.2.6): resolve_event_propsets joins
201
+ each proposal set to ITS source by the set manifest's own binding —
202
+ content hash first, path as the fallback — in any pointer order, and
203
+ every refusal is loud: an uncovered source, a stray pointer, two sets
204
+ claiming one source, and a lone mismatched pointer over a lone source
205
+ (the silent-wrong-spans class)."""
206
+ import json
207
+ import pytest
208
+ from cjm_transcript_decomp_core.pipeline import resolve_event_propsets
209
+
210
+ def _set(name, source, spans):
211
+ d = tmp_path / name
212
+ d.mkdir()
213
+ (d / "manifest.json").write_text(json.dumps({
214
+ "format": "cjm-capability-pyannote/proposal-set-manifest",
215
+ "proposal_set_id": name, "source": source,
216
+ "files": {"proposals": "proposals.jsonl"}}))
217
+ (d / "proposals.jsonl").write_text("\n".join(
218
+ json.dumps({"label": "inhale", "tier": 1, "start_time": s, "end_time": e})
219
+ for s, e in spans))
220
+ return str(d)
221
+
222
+ a = _set("propset_a", {"content_hash": "sha256:aaa", "path": "/media/a.wav"}, [(1.0, 1.5)])
223
+ b = _set("propset_b", {"content_hash": "sha256:bbb", "path": "/media/b.wav"}, [(2.0, 2.5), (3.0, 3.2)])
224
+ c_path_only = _set("propset_c", {"path": "/media/c.wav"}, [(4.0, 4.1)])
225
+ sources = [{"source_path": "/media/a.wav", "content_hash": "sha256:aaa"},
226
+ {"source_path": "/media/b.wav", "content_hash": "sha256:bbb"},
227
+ {"source_path": "/media/c.wav", "content_hash": "sha256:ccc"}]
228
+ # any pointer order; hash join for a/b, path fallback for c
229
+ out = resolve_event_propsets([c_path_only, b, a], sources, ["inhale"])
230
+ assert [m["proposal_set_id"] for m, _, _ in out] == ["propset_a", "propset_b", "propset_c"]
231
+ assert [spans for _, spans, _ in out] == [[(1.0, 1.5)], [(2.0, 2.5), (3.0, 3.2)], [(4.0, 4.1)]]
232
+ assert [ptr for _, _, ptr in out] == [a, b, c_path_only]
233
+ # single-source, single pointer: the 0.2.5 shape, unchanged
234
+ assert resolve_event_propsets([a], sources[:1], ["inhale"])[0][0]["proposal_set_id"] == "propset_a"
235
+ # uncovered source
236
+ with pytest.raises(RuntimeError, match="no proposal set covers source 2"):
237
+ resolve_event_propsets([a, b], sources, ["inhale"])
238
+ # stray pointer (binds to none of the run's sources)
239
+ with pytest.raises(RuntimeError, match="bind to none"):
240
+ resolve_event_propsets([a, b], sources[:1], ["inhale"])
241
+ # two sets claiming one source
242
+ a2 = _set("propset_a2", {"content_hash": "sha256:aaa", "path": "/media/a.wav"}, [(1.2, 1.4)])
243
+ with pytest.raises(RuntimeError, match="2 proposal sets claim source 0"):
244
+ resolve_event_propsets([a, a2], sources[:1], ["inhale"])
245
+ # lone mismatched pointer over a lone source is NOT tolerated
246
+ with pytest.raises(RuntimeError, match="no proposal set covers source 0"):
247
+ resolve_event_propsets([b], sources[:1], ["inhale"])
@@ -1 +0,0 @@
1
- __version__ = "0.0.6"