cjm-transcript-graph-schema 0.0.8__tar.gz → 0.0.12__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 (15) hide show
  1. {cjm_transcript_graph_schema-0.0.8/cjm_transcript_graph_schema.egg-info → cjm_transcript_graph_schema-0.0.12}/PKG-INFO +7 -3
  2. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/README.md +6 -2
  3. cjm_transcript_graph_schema-0.0.12/cjm_transcript_graph_schema/__init__.py +1 -0
  4. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/cjm_transcript_graph_schema/schema.py +146 -9
  5. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12/cjm_transcript_graph_schema.egg-info}/PKG-INFO +7 -3
  6. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/tests/test_schema.py +122 -8
  7. cjm_transcript_graph_schema-0.0.8/cjm_transcript_graph_schema/__init__.py +0 -1
  8. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/LICENSE +0 -0
  9. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/MANIFEST.in +0 -0
  10. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/cjm_transcript_graph_schema.egg-info/SOURCES.txt +0 -0
  11. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/cjm_transcript_graph_schema.egg-info/dependency_links.txt +0 -0
  12. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/cjm_transcript_graph_schema.egg-info/requires.txt +0 -0
  13. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/cjm_transcript_graph_schema.egg-info/top_level.txt +0 -0
  14. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/pyproject.toml +0 -0
  15. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.12}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-graph-schema
3
- Version: 0.0.8
3
+ Version: 0.0.12
4
4
  Summary: Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -23,7 +23,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
23
23
 
24
24
  ## Modules
25
25
 
26
- - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
26
+ - **`cjm_transcript_graph_schema.__init__`**
27
+ - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition, and with the intrinsic Collection layer ABOVE Source (book/series/course; proposed-by-tool / confirmed-by-actor, order optional) captured at transcription hand-off. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
27
28
 
28
29
  ## API
29
30
 
@@ -32,6 +33,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
32
33
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
33
34
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
34
35
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
36
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
35
37
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
36
38
  - `SourceNode` _class_ — The provenance root: one ingested media file.
37
39
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -41,6 +43,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
41
43
  - `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
42
44
  - `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
43
45
  - `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
46
+ - `external_config_hash` _function_ — The config hash an external landing carries where a capability's effective
47
+ - `external_transcriber_name` _function_ — Transcriber name for an EXTERNAL landing (ruling 9ffce5f7 (1)): the model id
44
48
  - `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
45
49
  - `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
46
50
  - `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
@@ -49,4 +53,4 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
49
53
  ## Dependencies
50
54
 
51
55
  **Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
52
- **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
56
+ **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-decomp-core`, `cjm-transcription-core`, `cjm-transcription-qt`, `cjm-workflow-hub-qt`
@@ -6,7 +6,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
6
6
 
7
7
  ## Modules
8
8
 
9
- - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
9
+ - **`cjm_transcript_graph_schema.__init__`**
10
+ - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition, and with the intrinsic Collection layer ABOVE Source (book/series/course; proposed-by-tool / confirmed-by-actor, order optional) captured at transcription hand-off. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
10
11
 
11
12
  ## API
12
13
 
@@ -15,6 +16,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
15
16
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
16
17
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
17
18
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
19
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
18
20
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
19
21
  - `SourceNode` _class_ — The provenance root: one ingested media file.
20
22
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -24,6 +26,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
24
26
  - `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
25
27
  - `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
26
28
  - `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
29
+ - `external_config_hash` _function_ — The config hash an external landing carries where a capability's effective
30
+ - `external_transcriber_name` _function_ — Transcriber name for an EXTERNAL landing (ruling 9ffce5f7 (1)): the model id
27
31
  - `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
28
32
  - `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
29
33
  - `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
@@ -32,4 +36,4 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
32
36
  ## Dependencies
33
37
 
34
38
  **Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
35
- **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
39
+ **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-decomp-core`, `cjm-transcription-core`, `cjm-transcription-qt`, `cjm-workflow-hub-qt`
@@ -0,0 +1 @@
1
+ __version__ = "0.0.12"
@@ -14,17 +14,20 @@ from cjm_context_graph_primitives.slices import CharSlice, FullContent, TimeSlic
14
14
 
15
15
  class TranscriptGraphLabels:
16
16
  """Node labels of the audio-transcript layer schema."""
17
+
17
18
  SOURCE = "Source" # Provenance root: one ingested media file
18
19
  AUDIO_SEGMENT = "AudioSegment" # Coarse ~5-min spine member: a boundary range of the Source (an audio FACT; rendition-independent)
19
20
  AUDIO_RENDITION = "AudioRendition" # A model-input rendition OF an AudioSegment (raw convert | vocals | future enhancement); OWNS the model-input WAV
20
21
  TRANSCRIPT = "Transcript" # Per-transcriber text variant of one AudioRendition
21
22
  SEGMENT = "Segment" # Fine spine member: one VAD chunk (immutable audio + correctable text), per-rendition
22
23
  COLLECTION = "Collection" # Intrinsic collection (book / series / course): the fractal spine layer ABOVE Source
24
+ REFERENCE = "Reference" # Human-added resource link attached to a Source (publisher page / author post / related notes / cited work): Source -HAS_REFERENCE-> Reference (ruling a7ca900d (3), item ae103970)
23
25
 
24
26
  @classmethod
25
27
  def all(cls) -> list: # All schema labels
26
28
  """All schema labels."""
27
- return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT, cls.COLLECTION]
29
+ return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT, cls.COLLECTION,
30
+ cls.REFERENCE]
28
31
 
29
32
 
30
33
  def source_node_id(
@@ -78,23 +81,68 @@ def transcript_node_id(
78
81
  return derive_node_id("transcript", rendition_id, transcriber, config_hash)
79
82
 
80
83
 
84
+ EXTERNAL_TRANSCRIBER_MARKER = "/manual" # Suffix naming an operator-landed (pasted) transcript producer
85
+
86
+
87
+ def external_transcriber_name(
88
+ model_id: str, # The external model that produced the text (e.g. "gemini-2.5-pro")
89
+ ) -> str: # The transcriber name an external landing files under
90
+ """Transcriber name for an EXTERNAL landing (ruling 9ffce5f7 (1)): the model id
91
+ plus a manual marker, so a pasted transcript is a THIRD TRANSCRIBER beside the
92
+ local capabilities — decomp folds it per chunk like any other variant, and the
93
+ marker keeps it distinguishable from a capability run of the same model."""
94
+ mid = (model_id or "").strip()
95
+ if not mid:
96
+ raise ValueError("external_transcriber_name: model_id is required")
97
+ return mid + EXTERNAL_TRANSCRIBER_MARKER
98
+
99
+
100
+ def external_config_hash(
101
+ model_id: str, # The external model id
102
+ prompt_hash: str, # Hash of the prompt template the operator used ("" = no prompt recorded)
103
+ ) -> str: # "sha256:<hex>" — the config-hash slot of an external landing
104
+ """The config hash an external landing carries where a capability's effective
105
+ config hash goes: sha256 over (model id, prompt hash). The prompt is DATA
106
+ (f304d31d), so a different prompt template is a different variant — and the
107
+ Transcript identity (rendition, transcriber, config_hash) stays recomputable
108
+ from the derived manifest alone."""
109
+ import hashlib
110
+ h = hashlib.sha256()
111
+ h.update((model_id or "").strip().encode("utf-8"))
112
+ h.update(b"\n")
113
+ h.update((prompt_hash or "").strip().encode("utf-8"))
114
+ return "sha256:" + h.hexdigest()
115
+
116
+
81
117
  def segment_node_id(
82
118
  rendition_id: str, # Owning AudioRendition node id
83
- vad_config_hash: str, # VAD capability config hash (skeleton identity input)
119
+ vad_config_hash: str, # Skeleton config hash (identity input: VAD config, + split policy when a split stage ran)
84
120
  chunk_start: float, # VAD chunk start (chunk-local seconds within the rendition WAV)
85
121
  chunk_end: float, # VAD chunk end (chunk-local seconds)
122
+ identity_salt: Optional[str] = None, # Respine salt (0b4d5cfa (3)): joins the id ONLY; None = the spine's original derivation
86
123
  ) -> str: # Deterministic Segment node id
87
124
  """Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
88
125
  range) — so the skeleton's ids are SHARED across transcribers by
89
126
  construction (C4 "store agreement once" falls out of identity design). Keyed
90
127
  on the RENDITION: each rendition has its own fine spine (vocals isolation can
91
- yield different VAD chunk boundaries than raw)."""
128
+ yield different VAD chunk boundaries than raw).
129
+
130
+ A CHUNK RESPINE (0b4d5cfa) re-derives one coarse chunk into the LIVE spine
131
+ from a landed external transcript: its segments keep the live skeleton hash
132
+ (every picker groups spines by it) and fork identity through `identity_salt`
133
+ = the landed Transcript's node id, so the same landing twice collides into
134
+ a no-op and a different landing mints fresh segments. None (the default)
135
+ reproduces every pre-salt id unchanged."""
136
+ if identity_salt:
137
+ return derive_node_id("segment", rendition_id, vad_config_hash, chunk_start, chunk_end,
138
+ identity_salt)
92
139
  return derive_node_id("segment", rendition_id, vad_config_hash, chunk_start, chunk_end)
93
140
 
94
141
 
95
142
  @dataclass
96
143
  class SourceNode:
97
144
  """The provenance root: one ingested media file."""
145
+
98
146
  content_hash: str # Content hash over the file ("algo:hexdigest"; the identity input)
99
147
  path: str # Original media path (location, may dangle; identity is the hash)
100
148
  title: Optional[str] = None # Display title; None = path stem
@@ -133,6 +181,7 @@ class AudioSegmentNode:
133
181
  content hash); the structural chain to the Source rides the PART_OF edge +
134
182
  the start/end properties. `segment_path` (the source-codec cut) stays here as
135
183
  a rendition-independent archival pointer."""
184
+
136
185
  source: str # Owning Source node id
137
186
  index: int # Position in the coarse spine (0-based)
138
187
  start: float # Boundary start (source-coordinate seconds)
@@ -176,6 +225,7 @@ class AudioRenditionNode:
176
225
  nodes. Connects UP to its AudioSegment by DERIVED_FROM (it is derived from the
177
226
  segment's audio); the production act is recorded separately by a CR-18
178
227
  `Derivation` event."""
228
+
179
229
  audio_segment: str # Owning AudioSegment node id
180
230
  model_input_path: str # The 16kHz mono WAV consumed by transcription/VAD/FA
181
231
  model_input_hash: str # Content hash over that WAV (the rendition's identity-of-record artifact)
@@ -213,6 +263,7 @@ class AudioRenditionNode:
213
263
  class TranscriptNode:
214
264
  """One transcriber's text for one AudioRendition (per-transcriber variants at
215
265
  the coarse layer — cross-transcriber divergence lives here, C4/C14)."""
266
+
216
267
  rendition: str # Owning AudioRendition node id
217
268
  transcriber: str # Transcriber capability name
218
269
  config_hash: str # Transcriber config hash
@@ -220,6 +271,8 @@ class TranscriptNode:
220
271
  audio_hash: str # Content hash of the consumed model-input WAV (the rendition's)
221
272
  metadata: Dict[str, Any] = field(default_factory=dict) # Transcriber-reported metadata
222
273
  asserted_at: Optional[float] = None # Derivation timestamp; None = now
274
+ actor: Optional[str] = None # Attribution actor; None = "capability:<transcriber>" (an EXTERNAL landing names the operator, 9ffce5f7 (1))
275
+ method: str = "transcribe" # Attribution method ("transcribe" for a capability run; "external-landing" for pasted text)
223
276
 
224
277
  @property
225
278
  def id(self) -> str: # Deterministic node id
@@ -236,7 +289,7 @@ class TranscriptNode:
236
289
  }
237
290
  if self.metadata:
238
291
  props["metadata"] = dict(self.metadata)
239
- props.update(attribution(f"capability:{self.transcriber}", method="transcribe",
292
+ props.update(attribution(self.actor or f"capability:{self.transcriber}", method=self.method,
240
293
  asserted_at=self.asserted_at))
241
294
  return {
242
295
  "id": self.id,
@@ -251,12 +304,25 @@ class TranscriptNode:
251
304
  """DERIVED_FROM edge: this Transcript derives from its AudioRendition."""
252
305
  return make_edge(self.id, self.rendition, OverlayRelations.DERIVED_FROM)
253
306
 
307
+ def supersedes_edge(
308
+ self,
309
+ prior_transcript_id: str, # The Transcript variant this one replaces (same rendition + transcriber, an older config)
310
+ ) -> Dict[str, Any]: # Edge wire dict
311
+ """SUPERSEDES edge: this variant replaces a prior one for the same (rendition,
312
+ transcriber) — a chunk-targeted re-run under a new config, or a re-landed
313
+ external transcript (ruling 910f3692 (2)). The prior node STAYS as append-only
314
+ history; readers treat a variant with an incoming SUPERSEDES edge as NOT LIVE."""
315
+ if prior_transcript_id == self.id:
316
+ raise ValueError("supersedes_edge: a Transcript cannot supersede itself")
317
+ return make_edge(self.id, prior_transcript_id, OverlayRelations.SUPERSEDES)
318
+
254
319
 
255
320
  @dataclass
256
321
  class TranscriptSliceRef:
257
322
  """One per-transcriber char-range reference for a fine Segment: where this
258
323
  segment's text lives inside a Transcript node's text (Thread-1
259
324
  slices-until-promoted — variants are slices, never duplicated fine text)."""
325
+
260
326
  transcript: str # Transcript node id
261
327
  start_char: int # Slice start into the Transcript's text
262
328
  end_char: int # Slice end
@@ -273,14 +339,24 @@ class TranscriptSliceRef:
273
339
  class SegmentNode:
274
340
  """Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
275
341
  text (the immutable-audio/mutable-text spine seam). PART_OF its AudioRendition
276
- (each rendition has its own fine spine).
342
+ (each rendition has its own fine spine). Parallel SPINES coexist under one
343
+ rendition (sentence-split, DEC f1024568): the skeleton hash feeds every node
344
+ id AND rides the queryable `skeleton_hash` prop, so spine-scoped reads never
345
+ mix coexisting skeletons (pre-split nodes lack the prop = the legacy spine).
277
346
 
278
347
  Layer-0 `text` is the ACCURACY transcriber's alignment; the designation is
279
348
  per-segment provenance, not global config (`text_from` names the
280
349
  authoritative Transcript; every transcriber's char range rides
281
- `text_slices`, the authoritative one included)."""
350
+ `text_slices`, the authoritative one included).
351
+
352
+ A CHUNK RESPINE (0b4d5cfa) replaces one coarse chunk's segments INSIDE the
353
+ live spine: the new segments carry `identity_salt` (joins the id only —
354
+ `skeleton_hash` stays the live spine's, so no reader splits the spine),
355
+ the old ones are stamped `superseded_by` (the respine op id) and leave the
356
+ live view — readers filter `superseded_by is null`; nothing is deleted."""
357
+
282
358
  rendition: str # Owning AudioRendition node id
283
- vad_config_hash: str # VAD config hash (skeleton identity input)
359
+ vad_config_hash: str # Skeleton config hash (identity input: VAD config, + split policy when a split stage ran)
284
360
  chunk_start: float # VAD chunk start (chunk-local seconds within the rendition WAV)
285
361
  chunk_end: float # VAD chunk end (chunk-local seconds)
286
362
  index: int # Source-wide fine-spine index (0-based)
@@ -290,13 +366,18 @@ class SegmentNode:
290
366
  audio_hash: str = "" # Content hash of the owning rendition's model-input WAV
291
367
  source: Optional[str] = None # Source node id (convenience for direct filters)
292
368
  text_from: Optional[str] = None # Authoritative Transcript node id (None when text is empty)
369
+ split_policy: Optional[str] = None # Split policy+version that refined the skeleton (None = raw VAD chunks)
293
370
  text_slices: List[TranscriptSliceRef] = field(default_factory=list) # All per-transcriber slice refs
371
+ identity_salt: Optional[str] = None # Respine salt (the landed Transcript id): joins the id ONLY (0b4d5cfa (3)); None = original derivation
372
+ superseded_by: Optional[str] = None # Respine op id that retired this segment from the live view (stamped post-hoc; None = live)
294
373
 
295
374
  @property
296
375
  def id(self) -> str: # Deterministic node id
297
- """Deterministic node id (audio-side identity; shared across transcribers, per-rendition)."""
376
+ """Deterministic node id (audio-side identity; shared across transcribers, per-rendition;
377
+ forked by `identity_salt` on a chunk respine)."""
298
378
  return segment_node_id(self.rendition, self.vad_config_hash,
299
- self.chunk_start, self.chunk_end)
379
+ self.chunk_start, self.chunk_end,
380
+ identity_salt=self.identity_salt)
300
381
 
301
382
  def to_graph_node(self) -> Dict[str, Any]: # Node wire dict
302
383
  """Build the Segment node wire dict (audio ref + per-transcriber text slice refs)."""
@@ -308,11 +389,18 @@ class SegmentNode:
308
389
  "chunk_start": self.chunk_start,
309
390
  "chunk_end": self.chunk_end,
310
391
  "rendition_id": self.rendition,
392
+ "skeleton_hash": self.vad_config_hash,
311
393
  }
394
+ if self.split_policy:
395
+ props["split_policy"] = self.split_policy
312
396
  if self.source:
313
397
  props["source_id"] = self.source
314
398
  if self.text_from:
315
399
  props["text_from"] = self.text_from
400
+ if self.identity_salt:
401
+ props["identity_salt"] = self.identity_salt
402
+ if self.superseded_by:
403
+ props["superseded_by"] = self.superseded_by
316
404
  sources = [SourceRef(locator=GraphNodeRef(node_id=self.rendition),
317
405
  content_hash=self.audio_hash,
318
406
  slice=TimeSlice(start=self.chunk_start, end=self.chunk_end)).to_dict()]
@@ -325,6 +413,54 @@ class SegmentNode:
325
413
  }
326
414
 
327
415
 
416
+ RESPINE_OP_VERB = "chunk-respine" # The journaled spine-fact op that lands a chunk respine (0b4d5cfa (4))
417
+ SOURCE_RESPINED_CHUNKS_PROP = "respined_chunks" # Source prop: the list of RespinedChunkEntry dicts, append-only
418
+ SEGMENT_SUPERSEDED_BY_PROP = "superseded_by" # Segment prop stamped on the chunk's old segments (live readers filter it null)
419
+
420
+
421
+ @dataclass
422
+ class RespinedChunkEntry:
423
+ """One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
424
+ `chunk-respine` op appends to the Source's `respined_chunks` list — WHICH
425
+ coarse chunk, WHICH landed Transcript re-derived it (= the new segments'
426
+ identity salt), under WHICH decomp run, when, and what the op did to the
427
+ dependents (stranded ids on explicit say-so; carried = the corrections the
428
+ chunk-scoped transfer re-homed by time: accepted event inserts, speaker
429
+ assignments). The op id is what `superseded_by` on the old segments names."""
430
+
431
+ audio_segment: str # AudioSegment node id (the coarse chunk)
432
+ transcript: str # The landed Transcript node id (= identity_salt of the new segments)
433
+ run: str # The decomp run id the re-derivation ran under
434
+ op_id: str # The chunk-respine op id (stamped as superseded_by)
435
+ ts: float # Wall-clock seconds of the landing
436
+ old_segments: List[str] = field(default_factory=list) # The superseded segment ids, index order
437
+ new_segments: List[str] = field(default_factory=list) # The replacement segment ids, index order
438
+ stranded: List[str] = field(default_factory=list) # Dependent Correction ids that stopped projecting (on say-so)
439
+ carried: List[str] = field(default_factory=list) # Fresh Correction ids the transfer minted onto the new segments
440
+ straddles: List[str] = field(default_factory=list) # New segment ids left unassigned because they straddle two donor speakers (4a7ec4f8 (3))
441
+
442
+ def to_dict(self) -> Dict[str, Any]: # The wire entry
443
+ """The entry as it rides the Source prop (plain JSON, list order preserved)."""
444
+ return {"audio_segment": self.audio_segment, "transcript": self.transcript,
445
+ "run": self.run, "op_id": self.op_id, "ts": self.ts,
446
+ "old_segments": list(self.old_segments), "new_segments": list(self.new_segments),
447
+ "stranded": list(self.stranded), "carried": list(self.carried),
448
+ "straddles": list(self.straddles)}
449
+
450
+ @classmethod
451
+ def from_dict(cls, d: Dict[str, Any]) -> "RespinedChunkEntry": # Parsed entry
452
+ """Read an entry back off the Source prop (missing lists read empty)."""
453
+ return cls(audio_segment=str(d.get("audio_segment") or ""),
454
+ transcript=str(d.get("transcript") or ""),
455
+ run=str(d.get("run") or ""), op_id=str(d.get("op_id") or ""),
456
+ ts=float(d.get("ts") or 0.0),
457
+ old_segments=[str(s) for s in (d.get("old_segments") or [])],
458
+ new_segments=[str(s) for s in (d.get("new_segments") or [])],
459
+ stranded=[str(s) for s in (d.get("stranded") or [])],
460
+ carried=[str(s) for s in (d.get("carried") or [])],
461
+ straddles=[str(s) for s in (d.get("straddles") or [])])
462
+
463
+
328
464
  def normalize_collection_title(
329
465
  title: str, # Raw title (folder name, user input, ...)
330
466
  ) -> str: # Normalized identity form
@@ -364,6 +500,7 @@ class CollectionNode:
364
500
  rename, refile out of, or dissolve. root_kind=asserted — a collection is
365
501
  a grouping ASSERTION about ingested content, not ingested content itself.
366
502
  Collection PART_OF Collection nesting is grammar-free (no v0 tooling)."""
503
+
367
504
  title: str # Display title (identity input via normalize_collection_title)
368
505
  status: str = "proposed" # "proposed" (tool inference) | "confirmed" (human act)
369
506
  actor: str = "human" # Who declared it (attribution; e.g. "human", "tui:transcription", "cli:transcribe")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-transcript-graph-schema
3
- Version: 0.0.8
3
+ Version: 0.0.12
4
4
  Summary: Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcript, and Segment nodes with deterministic identity tuples and graph-node mapping, shared by the transcription, decomposition, and correction workflow cores.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -23,7 +23,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
23
23
 
24
24
  ## Modules
25
25
 
26
- - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
26
+ - **`cjm_transcript_graph_schema.__init__`**
27
+ - **`cjm_transcript_graph_schema.schema`** — The audio-transcript layer schema (where-graph-begins locked layer schema): Source -> AudioSegment(coarse boundary) -> AudioRendition(model-input: raw | vocals | ...) -> Transcript(per-transcriber variants) emitted by transcription, extended with the fine Segment spine (per-rendition) by decomposition, and with the intrinsic Collection layer ABOVE Source (book/series/course; proposed-by-tool / confirmed-by-actor, order optional) captured at transcription hand-off. Deterministic identity tuples per the stage-5 ratified rule; the AudioRendition node lets raw + preprocessed model-inputs of one boundary coexist in one graph. Document from the pre-CR-18 era dissolves into Source.
27
28
 
28
29
  ## API
29
30
 
@@ -32,6 +33,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
32
33
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
33
34
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
34
35
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
36
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
35
37
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
36
38
  - `SourceNode` _class_ — The provenance root: one ingested media file.
37
39
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -41,6 +43,8 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
41
43
  - `audio_segment_node_id` _function_ — AudioSegment identity = (source, boundary range).
42
44
  - `collection_edges` _function_ — Membership edges for a collection (ae3464fc: ORDER IS OPTIONAL).
43
45
  - `collection_node_id` _function_ — Collection identity = the NORMALIZED TITLE (ratified fork, hub-session
46
+ - `external_config_hash` _function_ — The config hash an external landing carries where a capability's effective
47
+ - `external_transcriber_name` _function_ — Transcriber name for an EXTERNAL landing (ruling 9ffce5f7 (1)): the model id
44
48
  - `normalize_collection_title` _function_ — Normalize a collection title for identity derivation.
45
49
  - `segment_node_id` _function_ — Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
46
50
  - `source_node_id` _function_ — Source identity = the ingested file's content hash (Thread-1 ingested-root identity).
@@ -49,4 +53,4 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
49
53
  ## Dependencies
50
54
 
51
55
  **Depends on:** `cjm-context-graph-layer`, `cjm-context-graph-primitives`
52
- **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-correction-tui`, `cjm-transcript-decomp-core`, `cjm-transcription-core`
56
+ **Used by:** `cjm-context-graph-layer`, `cjm-transcript-correction-core`, `cjm-transcript-decomp-core`, `cjm-transcription-core`, `cjm-transcription-qt`, `cjm-workflow-hub-qt`
@@ -1,16 +1,15 @@
1
1
  """Tests for cjm_transcript_graph_schema.schema — identity tuples + wire shapes.
2
2
 
3
3
  Projected from the schema notebook's four test cells at the c25780e8 flip."""
4
+
4
5
  from cjm_context_graph_primitives.provenance import SourceRef
5
- from cjm_transcript_graph_schema.schema import (AudioRenditionNode, AudioSegmentNode,
6
- CollectionNode, SegmentNode, SourceNode,
7
- TranscriptNode, TranscriptSliceRef,
8
- audio_rendition_node_id,
9
- audio_segment_node_id,
6
+ from cjm_transcript_graph_schema.schema import (audio_rendition_node_id, audio_segment_node_id,
7
+ AudioRenditionNode, AudioSegmentNode,
10
8
  collection_edges, collection_node_id,
11
- normalize_collection_title,
12
- segment_node_id, source_node_id,
13
- transcript_node_id)
9
+ CollectionNode, normalize_collection_title,
10
+ segment_node_id, SegmentNode, source_node_id,
11
+ SourceNode, transcript_node_id, TranscriptNode,
12
+ TranscriptSliceRef)
14
13
 
15
14
  SID = source_node_id("sha256:abc")
16
15
  A1 = audio_segment_node_id(SID, 0.0, 300.2)
@@ -174,3 +173,118 @@ def test_collection_identity_wire_shape_and_edges():
174
173
  # late-added member attaches to the SAME node without touching the chain
175
174
  late = collection_edges(cid, [source_node_id("sha256:ch2.5")])
176
175
  assert late[0]["relation_type"] == "PART_OF" and late[0]["target_id"] == cid
176
+
177
+
178
+ def test_segment_skeleton_hash_prop_and_split_policy():
179
+ # Every new commit records its skeleton identity input as a QUERYABLE prop
180
+ # (spine-scoped reads filter on it; legacy nodes lack it entirely).
181
+ seg = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
182
+ chunk_end=2.0, index=0, start_time=1.0, end_time=2.0, text="hi")
183
+ props = seg.to_graph_node()["properties"]
184
+ assert props["skeleton_hash"] == "sha256:skel"
185
+ assert "split_policy" not in props, "no split stage -> no split_policy prop"
186
+
187
+ split = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel2", chunk_start=1.0,
188
+ chunk_end=1.6, index=0, start_time=1.0, end_time=1.6, text="hi",
189
+ split_policy="sentence-split/v1")
190
+ sprops = split.to_graph_node()["properties"]
191
+ assert sprops["skeleton_hash"] == "sha256:skel2"
192
+ assert sprops["split_policy"] == "sentence-split/v1"
193
+
194
+ # split_policy is METADATA, never an identity input — the id stays a pure
195
+ # function of (rendition, skeleton hash, chunk range).
196
+ a = SegmentNode(rendition=R_RAW, vad_config_hash="h", chunk_start=1.0, chunk_end=2.0,
197
+ index=0, start_time=1.0, end_time=2.0)
198
+ b = SegmentNode(rendition=R_RAW, vad_config_hash="h", chunk_start=1.0, chunk_end=2.0,
199
+ index=0, start_time=1.0, end_time=2.0, split_policy="sentence-split/v1")
200
+ assert a.id == b.id
201
+ # ...while the skeleton hash itself DOES fork identity (parallel spines
202
+ # coexist because no node id is shared across skeletons).
203
+ c = SegmentNode(rendition=R_RAW, vad_config_hash="h2", chunk_start=1.0, chunk_end=2.0,
204
+ index=0, start_time=1.0, end_time=2.0)
205
+ assert c.id != a.id
206
+
207
+
208
+ def test_transcript_supersedes_edge_external_identity_and_operator_attribution():
209
+ """cf0b91d6 / ruling 910f3692: a re-run's new variant SUPERSEDES the prior variant for
210
+ the same (rendition, transcriber) with a deterministic edge id and refuses a self-edge;
211
+ an EXTERNAL landing files under `<model id>/manual` with a config hash derived from
212
+ (model id, prompt hash) — a different prompt is a different variant — and carries the
213
+ OPERATOR as actor (default attribution stays capability:<transcriber>/transcribe)."""
214
+ import pytest
215
+ from cjm_transcript_graph_schema.schema import (EXTERNAL_TRANSCRIBER_MARKER, external_config_hash,
216
+ external_transcriber_name)
217
+
218
+ old = TranscriptNode(rendition=R_RAW, transcriber="voxtral", config_hash="cfg-old",
219
+ text="some some some", audio_hash="sha256:wav0")
220
+ new = TranscriptNode(rendition=R_RAW, transcriber="voxtral", config_hash="cfg-new",
221
+ text="the good prefix", audio_hash="sha256:wav0")
222
+ assert old.id != new.id
223
+ e = new.supersedes_edge(old.id)
224
+ assert (e["source_id"], e["target_id"], e["relation_type"]) == (new.id, old.id, "SUPERSEDES")
225
+ assert e["id"] == new.supersedes_edge(old.id)["id"], "deterministic edge id"
226
+ with pytest.raises(ValueError):
227
+ new.supersedes_edge(new.id)
228
+ # External landing identity.
229
+ name = external_transcriber_name("gemini-2.5-pro")
230
+ assert name == "gemini-2.5-pro" + EXTERNAL_TRANSCRIBER_MARKER == "gemini-2.5-pro/manual"
231
+ with pytest.raises(ValueError):
232
+ external_transcriber_name(" ")
233
+ h1 = external_config_hash("gemini-2.5-pro", "sha256:prompt-a")
234
+ h2 = external_config_hash("gemini-2.5-pro", "sha256:prompt-b")
235
+ assert h1.startswith("sha256:") and h1 != h2
236
+ assert h1 == external_config_hash("gemini-2.5-pro", "sha256:prompt-a"), "deterministic"
237
+ ext = TranscriptNode(rendition=R_RAW, transcriber=name, config_hash=h1, text="NCCL, not nickel",
238
+ audio_hash="sha256:wav0", actor="human:operator", method="external-landing")
239
+ en = ext.to_graph_node()
240
+ assert en["id"] == transcript_node_id(R_RAW, name, h1)
241
+ assert en["properties"]["actor"] == "human:operator" and en["properties"]["method"] == "external-landing"
242
+ # The default attribution is unchanged for capability runs.
243
+ assert new.to_graph_node()["properties"]["actor"] == "capability:voxtral"
244
+ assert new.to_graph_node()["properties"]["method"] == "transcribe"
245
+
246
+
247
+ def test_segment_identity_salt_and_superseded_by_respine_props():
248
+ """0b4d5cfa (3)/(4), schema 0.0.11: a chunk respine forks Segment identity through
249
+ `identity_salt` ONLY — the skeleton_hash prop stays the live spine's, so no reader
250
+ splits the spine; the salt is the landed Transcript id, so the same landing twice
251
+ collides into the same id (idempotent) and a different landing mints fresh ids.
252
+ `superseded_by` rides as a prop only when set (live segments carry none)."""
253
+ from cjm_transcript_graph_schema.schema import (RESPINE_OP_VERB, SEGMENT_SUPERSEDED_BY_PROP,
254
+ SOURCE_RESPINED_CHUNKS_PROP, RespinedChunkEntry)
255
+
256
+ live = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
257
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi")
258
+ t_a = transcript_node_id(R_RAW, "gemini-3.8-flash/manual", "sha256:cfg-a")
259
+ t_b = transcript_node_id(R_RAW, "gemini-3.8-flash/manual", "sha256:cfg-b")
260
+ salted = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
261
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
262
+ identity_salt=t_a)
263
+ again = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
264
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
265
+ identity_salt=t_a)
266
+ other = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
267
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
268
+ identity_salt=t_b)
269
+ assert live.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0), "pre-salt ids unchanged"
270
+ assert live.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0, identity_salt=None)
271
+ assert salted.id != live.id and salted.id == again.id and other.id != salted.id
272
+ assert salted.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0, identity_salt=t_a)
273
+ lp, sp = live.to_graph_node()["properties"], salted.to_graph_node()["properties"]
274
+ assert sp["skeleton_hash"] == lp["skeleton_hash"] == "sha256:skel", "the salt never touches the hash"
275
+ assert sp["identity_salt"] == t_a and "identity_salt" not in lp
276
+ assert SEGMENT_SUPERSEDED_BY_PROP not in lp and SEGMENT_SUPERSEDED_BY_PROP not in sp
277
+ old = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
278
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
279
+ superseded_by="op-1")
280
+ assert old.id == live.id, "superseded_by is a stamp, never identity"
281
+ assert old.to_graph_node()["properties"][SEGMENT_SUPERSEDED_BY_PROP] == "op-1"
282
+ # The Source-side record round-trips (missing lists read empty).
283
+ assert (RESPINE_OP_VERB, SOURCE_RESPINED_CHUNKS_PROP) == ("chunk-respine", "respined_chunks")
284
+ entry = RespinedChunkEntry(audio_segment=A1, transcript=t_a, run="run-1", op_id="op-1",
285
+ ts=1.5, old_segments=[live.id], new_segments=[salted.id],
286
+ stranded=["c1"], carried=["c2"], straddles=[salted.id])
287
+ d = entry.to_dict()
288
+ assert RespinedChunkEntry.from_dict(d) == entry
289
+ assert RespinedChunkEntry.from_dict({"audio_segment": A1, "transcript": t_a,
290
+ "run": "r", "op_id": "o", "ts": 2}).stranded == []
@@ -1 +0,0 @@
1
- __version__ = "0.0.8"