cjm-transcript-graph-schema 0.0.8__tar.gz → 0.0.11__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.11}/PKG-INFO +2 -1
  2. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/README.md +1 -0
  3. cjm_transcript_graph_schema-0.0.11/cjm_transcript_graph_schema/__init__.py +1 -0
  4. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/cjm_transcript_graph_schema/schema.py +137 -9
  5. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11/cjm_transcript_graph_schema.egg-info}/PKG-INFO +2 -1
  6. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/tests/test_schema.py +115 -0
  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.11}/LICENSE +0 -0
  9. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/MANIFEST.in +0 -0
  10. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/cjm_transcript_graph_schema.egg-info/SOURCES.txt +0 -0
  11. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/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.11}/cjm_transcript_graph_schema.egg-info/requires.txt +0 -0
  13. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/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.11}/pyproject.toml +0 -0
  15. {cjm_transcript_graph_schema-0.0.8 → cjm_transcript_graph_schema-0.0.11}/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.11
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
@@ -32,6 +32,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
32
32
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
33
33
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
34
34
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
35
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
35
36
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
36
37
  - `SourceNode` _class_ — The provenance root: one ingested media file.
37
38
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -15,6 +15,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
15
15
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
16
16
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
17
17
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
18
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
18
19
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
19
20
  - `SourceNode` _class_ — The provenance root: one ingested media file.
20
21
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.11"
@@ -20,11 +20,13 @@ class TranscriptGraphLabels:
20
20
  TRANSCRIPT = "Transcript" # Per-transcriber text variant of one AudioRendition
21
21
  SEGMENT = "Segment" # Fine spine member: one VAD chunk (immutable audio + correctable text), per-rendition
22
22
  COLLECTION = "Collection" # Intrinsic collection (book / series / course): the fractal spine layer ABOVE Source
23
+ 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
24
 
24
25
  @classmethod
25
26
  def all(cls) -> list: # All schema labels
26
27
  """All schema labels."""
27
- return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT, cls.COLLECTION]
28
+ return [cls.SOURCE, cls.AUDIO_SEGMENT, cls.AUDIO_RENDITION, cls.TRANSCRIPT, cls.SEGMENT, cls.COLLECTION,
29
+ cls.REFERENCE]
28
30
 
29
31
 
30
32
  def source_node_id(
@@ -78,17 +80,61 @@ def transcript_node_id(
78
80
  return derive_node_id("transcript", rendition_id, transcriber, config_hash)
79
81
 
80
82
 
83
+ EXTERNAL_TRANSCRIBER_MARKER = "/manual" # Suffix naming an operator-landed (pasted) transcript producer
84
+
85
+
86
+ def external_transcriber_name(
87
+ model_id: str, # The external model that produced the text (e.g. "gemini-2.5-pro")
88
+ ) -> str: # The transcriber name an external landing files under
89
+ """Transcriber name for an EXTERNAL landing (ruling 9ffce5f7 (1)): the model id
90
+ plus a manual marker, so a pasted transcript is a THIRD TRANSCRIBER beside the
91
+ local capabilities — decomp folds it per chunk like any other variant, and the
92
+ marker keeps it distinguishable from a capability run of the same model."""
93
+ mid = (model_id or "").strip()
94
+ if not mid:
95
+ raise ValueError("external_transcriber_name: model_id is required")
96
+ return mid + EXTERNAL_TRANSCRIBER_MARKER
97
+
98
+
99
+ def external_config_hash(
100
+ model_id: str, # The external model id
101
+ prompt_hash: str, # Hash of the prompt template the operator used ("" = no prompt recorded)
102
+ ) -> str: # "sha256:<hex>" — the config-hash slot of an external landing
103
+ """The config hash an external landing carries where a capability's effective
104
+ config hash goes: sha256 over (model id, prompt hash). The prompt is DATA
105
+ (f304d31d), so a different prompt template is a different variant — and the
106
+ Transcript identity (rendition, transcriber, config_hash) stays recomputable
107
+ from the derived manifest alone."""
108
+ import hashlib
109
+ h = hashlib.sha256()
110
+ h.update((model_id or "").strip().encode("utf-8"))
111
+ h.update(b"\n")
112
+ h.update((prompt_hash or "").strip().encode("utf-8"))
113
+ return "sha256:" + h.hexdigest()
114
+
115
+
81
116
  def segment_node_id(
82
117
  rendition_id: str, # Owning AudioRendition node id
83
- vad_config_hash: str, # VAD capability config hash (skeleton identity input)
118
+ vad_config_hash: str, # Skeleton config hash (identity input: VAD config, + split policy when a split stage ran)
84
119
  chunk_start: float, # VAD chunk start (chunk-local seconds within the rendition WAV)
85
120
  chunk_end: float, # VAD chunk end (chunk-local seconds)
121
+ identity_salt: Optional[str] = None, # Respine salt (0b4d5cfa (3)): joins the id ONLY; None = the spine's original derivation
86
122
  ) -> str: # Deterministic Segment node id
87
123
  """Fine Segment identity = audio-side only (audio rendition, VAD config, chunk
88
124
  range) — so the skeleton's ids are SHARED across transcribers by
89
125
  construction (C4 "store agreement once" falls out of identity design). Keyed
90
126
  on the RENDITION: each rendition has its own fine spine (vocals isolation can
91
- yield different VAD chunk boundaries than raw)."""
127
+ yield different VAD chunk boundaries than raw).
128
+
129
+ A CHUNK RESPINE (0b4d5cfa) re-derives one coarse chunk into the LIVE spine
130
+ from a landed external transcript: its segments keep the live skeleton hash
131
+ (every picker groups spines by it) and fork identity through `identity_salt`
132
+ = the landed Transcript's node id, so the same landing twice collides into
133
+ a no-op and a different landing mints fresh segments. None (the default)
134
+ reproduces every pre-salt id unchanged."""
135
+ if identity_salt:
136
+ return derive_node_id("segment", rendition_id, vad_config_hash, chunk_start, chunk_end,
137
+ identity_salt)
92
138
  return derive_node_id("segment", rendition_id, vad_config_hash, chunk_start, chunk_end)
93
139
 
94
140
 
@@ -220,6 +266,8 @@ class TranscriptNode:
220
266
  audio_hash: str # Content hash of the consumed model-input WAV (the rendition's)
221
267
  metadata: Dict[str, Any] = field(default_factory=dict) # Transcriber-reported metadata
222
268
  asserted_at: Optional[float] = None # Derivation timestamp; None = now
269
+ actor: Optional[str] = None # Attribution actor; None = "capability:<transcriber>" (an EXTERNAL landing names the operator, 9ffce5f7 (1))
270
+ method: str = "transcribe" # Attribution method ("transcribe" for a capability run; "external-landing" for pasted text)
223
271
 
224
272
  @property
225
273
  def id(self) -> str: # Deterministic node id
@@ -236,7 +284,7 @@ class TranscriptNode:
236
284
  }
237
285
  if self.metadata:
238
286
  props["metadata"] = dict(self.metadata)
239
- props.update(attribution(f"capability:{self.transcriber}", method="transcribe",
287
+ props.update(attribution(self.actor or f"capability:{self.transcriber}", method=self.method,
240
288
  asserted_at=self.asserted_at))
241
289
  return {
242
290
  "id": self.id,
@@ -251,6 +299,18 @@ class TranscriptNode:
251
299
  """DERIVED_FROM edge: this Transcript derives from its AudioRendition."""
252
300
  return make_edge(self.id, self.rendition, OverlayRelations.DERIVED_FROM)
253
301
 
302
+ def supersedes_edge(
303
+ self,
304
+ prior_transcript_id: str, # The Transcript variant this one replaces (same rendition + transcriber, an older config)
305
+ ) -> Dict[str, Any]: # Edge wire dict
306
+ """SUPERSEDES edge: this variant replaces a prior one for the same (rendition,
307
+ transcriber) — a chunk-targeted re-run under a new config, or a re-landed
308
+ external transcript (ruling 910f3692 (2)). The prior node STAYS as append-only
309
+ history; readers treat a variant with an incoming SUPERSEDES edge as NOT LIVE."""
310
+ if prior_transcript_id == self.id:
311
+ raise ValueError("supersedes_edge: a Transcript cannot supersede itself")
312
+ return make_edge(self.id, prior_transcript_id, OverlayRelations.SUPERSEDES)
313
+
254
314
 
255
315
  @dataclass
256
316
  class TranscriptSliceRef:
@@ -273,14 +333,23 @@ class TranscriptSliceRef:
273
333
  class SegmentNode:
274
334
  """Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
275
335
  text (the immutable-audio/mutable-text spine seam). PART_OF its AudioRendition
276
- (each rendition has its own fine spine).
336
+ (each rendition has its own fine spine). Parallel SPINES coexist under one
337
+ rendition (sentence-split, DEC f1024568): the skeleton hash feeds every node
338
+ id AND rides the queryable `skeleton_hash` prop, so spine-scoped reads never
339
+ mix coexisting skeletons (pre-split nodes lack the prop = the legacy spine).
277
340
 
278
341
  Layer-0 `text` is the ACCURACY transcriber's alignment; the designation is
279
342
  per-segment provenance, not global config (`text_from` names the
280
343
  authoritative Transcript; every transcriber's char range rides
281
- `text_slices`, the authoritative one included)."""
344
+ `text_slices`, the authoritative one included).
345
+
346
+ A CHUNK RESPINE (0b4d5cfa) replaces one coarse chunk's segments INSIDE the
347
+ live spine: the new segments carry `identity_salt` (joins the id only —
348
+ `skeleton_hash` stays the live spine's, so no reader splits the spine),
349
+ the old ones are stamped `superseded_by` (the respine op id) and leave the
350
+ live view — readers filter `superseded_by is null`; nothing is deleted."""
282
351
  rendition: str # Owning AudioRendition node id
283
- vad_config_hash: str # VAD config hash (skeleton identity input)
352
+ vad_config_hash: str # Skeleton config hash (identity input: VAD config, + split policy when a split stage ran)
284
353
  chunk_start: float # VAD chunk start (chunk-local seconds within the rendition WAV)
285
354
  chunk_end: float # VAD chunk end (chunk-local seconds)
286
355
  index: int # Source-wide fine-spine index (0-based)
@@ -290,13 +359,18 @@ class SegmentNode:
290
359
  audio_hash: str = "" # Content hash of the owning rendition's model-input WAV
291
360
  source: Optional[str] = None # Source node id (convenience for direct filters)
292
361
  text_from: Optional[str] = None # Authoritative Transcript node id (None when text is empty)
362
+ split_policy: Optional[str] = None # Split policy+version that refined the skeleton (None = raw VAD chunks)
293
363
  text_slices: List[TranscriptSliceRef] = field(default_factory=list) # All per-transcriber slice refs
364
+ identity_salt: Optional[str] = None # Respine salt (the landed Transcript id): joins the id ONLY (0b4d5cfa (3)); None = original derivation
365
+ superseded_by: Optional[str] = None # Respine op id that retired this segment from the live view (stamped post-hoc; None = live)
294
366
 
295
367
  @property
296
368
  def id(self) -> str: # Deterministic node id
297
- """Deterministic node id (audio-side identity; shared across transcribers, per-rendition)."""
369
+ """Deterministic node id (audio-side identity; shared across transcribers, per-rendition;
370
+ forked by `identity_salt` on a chunk respine)."""
298
371
  return segment_node_id(self.rendition, self.vad_config_hash,
299
- self.chunk_start, self.chunk_end)
372
+ self.chunk_start, self.chunk_end,
373
+ identity_salt=self.identity_salt)
300
374
 
301
375
  def to_graph_node(self) -> Dict[str, Any]: # Node wire dict
302
376
  """Build the Segment node wire dict (audio ref + per-transcriber text slice refs)."""
@@ -308,11 +382,18 @@ class SegmentNode:
308
382
  "chunk_start": self.chunk_start,
309
383
  "chunk_end": self.chunk_end,
310
384
  "rendition_id": self.rendition,
385
+ "skeleton_hash": self.vad_config_hash,
311
386
  }
387
+ if self.split_policy:
388
+ props["split_policy"] = self.split_policy
312
389
  if self.source:
313
390
  props["source_id"] = self.source
314
391
  if self.text_from:
315
392
  props["text_from"] = self.text_from
393
+ if self.identity_salt:
394
+ props["identity_salt"] = self.identity_salt
395
+ if self.superseded_by:
396
+ props["superseded_by"] = self.superseded_by
316
397
  sources = [SourceRef(locator=GraphNodeRef(node_id=self.rendition),
317
398
  content_hash=self.audio_hash,
318
399
  slice=TimeSlice(start=self.chunk_start, end=self.chunk_end)).to_dict()]
@@ -325,6 +406,53 @@ class SegmentNode:
325
406
  }
326
407
 
327
408
 
409
+ RESPINE_OP_VERB = "chunk-respine" # The journaled spine-fact op that lands a chunk respine (0b4d5cfa (4))
410
+ SOURCE_RESPINED_CHUNKS_PROP = "respined_chunks" # Source prop: the list of RespinedChunkEntry dicts, append-only
411
+ SEGMENT_SUPERSEDED_BY_PROP = "superseded_by" # Segment prop stamped on the chunk's old segments (live readers filter it null)
412
+
413
+
414
+ @dataclass
415
+ class RespinedChunkEntry:
416
+ """One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
417
+ `chunk-respine` op appends to the Source's `respined_chunks` list — WHICH
418
+ coarse chunk, WHICH landed Transcript re-derived it (= the new segments'
419
+ identity salt), under WHICH decomp run, when, and what the op did to the
420
+ dependents (stranded ids on explicit say-so; carried = the corrections the
421
+ chunk-scoped transfer re-homed by time: accepted event inserts, speaker
422
+ assignments). The op id is what `superseded_by` on the old segments names."""
423
+ audio_segment: str # AudioSegment node id (the coarse chunk)
424
+ transcript: str # The landed Transcript node id (= identity_salt of the new segments)
425
+ run: str # The decomp run id the re-derivation ran under
426
+ op_id: str # The chunk-respine op id (stamped as superseded_by)
427
+ ts: float # Wall-clock seconds of the landing
428
+ old_segments: List[str] = field(default_factory=list) # The superseded segment ids, index order
429
+ new_segments: List[str] = field(default_factory=list) # The replacement segment ids, index order
430
+ stranded: List[str] = field(default_factory=list) # Dependent Correction ids that stopped projecting (on say-so)
431
+ carried: List[str] = field(default_factory=list) # Fresh Correction ids the transfer minted onto the new segments
432
+ straddles: List[str] = field(default_factory=list) # New segment ids left unassigned because they straddle two donor speakers (4a7ec4f8 (3))
433
+
434
+ def to_dict(self) -> Dict[str, Any]: # The wire entry
435
+ """The entry as it rides the Source prop (plain JSON, list order preserved)."""
436
+ return {"audio_segment": self.audio_segment, "transcript": self.transcript,
437
+ "run": self.run, "op_id": self.op_id, "ts": self.ts,
438
+ "old_segments": list(self.old_segments), "new_segments": list(self.new_segments),
439
+ "stranded": list(self.stranded), "carried": list(self.carried),
440
+ "straddles": list(self.straddles)}
441
+
442
+ @classmethod
443
+ def from_dict(cls, d: Dict[str, Any]) -> "RespinedChunkEntry": # Parsed entry
444
+ """Read an entry back off the Source prop (missing lists read empty)."""
445
+ return cls(audio_segment=str(d.get("audio_segment") or ""),
446
+ transcript=str(d.get("transcript") or ""),
447
+ run=str(d.get("run") or ""), op_id=str(d.get("op_id") or ""),
448
+ ts=float(d.get("ts") or 0.0),
449
+ old_segments=[str(s) for s in (d.get("old_segments") or [])],
450
+ new_segments=[str(s) for s in (d.get("new_segments") or [])],
451
+ stranded=[str(s) for s in (d.get("stranded") or [])],
452
+ carried=[str(s) for s in (d.get("carried") or [])],
453
+ straddles=[str(s) for s in (d.get("straddles") or [])])
454
+
455
+
328
456
  def normalize_collection_title(
329
457
  title: str, # Raw title (folder name, user input, ...)
330
458
  ) -> str: # Normalized identity form
@@ -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.11
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
@@ -32,6 +32,7 @@ Audio-transcript layer schema for context graphs: Source, AudioSegment, Transcri
32
32
  - `AudioRenditionNode` _class_ — A model-input rendition OF an AudioSegment — the materialized 16k-mono WAV
33
33
  - `AudioSegmentNode` _class_ — Coarse ~5-min spine member: a BOUNDARY range of the Source (an audio fact),
34
34
  - `CollectionNode` _class_ — An intrinsic collection (book, podcast series, lecture course) — the
35
+ - `RespinedChunkEntry` _class_ — One chunk respine as the Source records it (0b4d5cfa (4)): the entry the
35
36
  - `SegmentNode` _class_ — Fine spine member: one VAD chunk — IMMUTABLE audio range + CORRECTABLE
36
37
  - `SourceNode` _class_ — The provenance root: one ingested media file.
37
38
  - `TranscriptGraphLabels` _class_ — Node labels of the audio-transcript layer schema.
@@ -174,3 +174,118 @@ def test_collection_identity_wire_shape_and_edges():
174
174
  # late-added member attaches to the SAME node without touching the chain
175
175
  late = collection_edges(cid, [source_node_id("sha256:ch2.5")])
176
176
  assert late[0]["relation_type"] == "PART_OF" and late[0]["target_id"] == cid
177
+
178
+
179
+ def test_segment_skeleton_hash_prop_and_split_policy():
180
+ # Every new commit records its skeleton identity input as a QUERYABLE prop
181
+ # (spine-scoped reads filter on it; legacy nodes lack it entirely).
182
+ seg = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
183
+ chunk_end=2.0, index=0, start_time=1.0, end_time=2.0, text="hi")
184
+ props = seg.to_graph_node()["properties"]
185
+ assert props["skeleton_hash"] == "sha256:skel"
186
+ assert "split_policy" not in props, "no split stage -> no split_policy prop"
187
+
188
+ split = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel2", chunk_start=1.0,
189
+ chunk_end=1.6, index=0, start_time=1.0, end_time=1.6, text="hi",
190
+ split_policy="sentence-split/v1")
191
+ sprops = split.to_graph_node()["properties"]
192
+ assert sprops["skeleton_hash"] == "sha256:skel2"
193
+ assert sprops["split_policy"] == "sentence-split/v1"
194
+
195
+ # split_policy is METADATA, never an identity input — the id stays a pure
196
+ # function of (rendition, skeleton hash, chunk range).
197
+ a = SegmentNode(rendition=R_RAW, vad_config_hash="h", chunk_start=1.0, chunk_end=2.0,
198
+ index=0, start_time=1.0, end_time=2.0)
199
+ b = SegmentNode(rendition=R_RAW, vad_config_hash="h", chunk_start=1.0, chunk_end=2.0,
200
+ index=0, start_time=1.0, end_time=2.0, split_policy="sentence-split/v1")
201
+ assert a.id == b.id
202
+ # ...while the skeleton hash itself DOES fork identity (parallel spines
203
+ # coexist because no node id is shared across skeletons).
204
+ c = SegmentNode(rendition=R_RAW, vad_config_hash="h2", chunk_start=1.0, chunk_end=2.0,
205
+ index=0, start_time=1.0, end_time=2.0)
206
+ assert c.id != a.id
207
+
208
+
209
+ def test_transcript_supersedes_edge_external_identity_and_operator_attribution():
210
+ """cf0b91d6 / ruling 910f3692: a re-run's new variant SUPERSEDES the prior variant for
211
+ the same (rendition, transcriber) with a deterministic edge id and refuses a self-edge;
212
+ an EXTERNAL landing files under `<model id>/manual` with a config hash derived from
213
+ (model id, prompt hash) — a different prompt is a different variant — and carries the
214
+ OPERATOR as actor (default attribution stays capability:<transcriber>/transcribe)."""
215
+ import pytest
216
+ from cjm_transcript_graph_schema.schema import (EXTERNAL_TRANSCRIBER_MARKER, external_config_hash,
217
+ external_transcriber_name)
218
+
219
+ old = TranscriptNode(rendition=R_RAW, transcriber="voxtral", config_hash="cfg-old",
220
+ text="some some some", audio_hash="sha256:wav0")
221
+ new = TranscriptNode(rendition=R_RAW, transcriber="voxtral", config_hash="cfg-new",
222
+ text="the good prefix", audio_hash="sha256:wav0")
223
+ assert old.id != new.id
224
+ e = new.supersedes_edge(old.id)
225
+ assert (e["source_id"], e["target_id"], e["relation_type"]) == (new.id, old.id, "SUPERSEDES")
226
+ assert e["id"] == new.supersedes_edge(old.id)["id"], "deterministic edge id"
227
+ with pytest.raises(ValueError):
228
+ new.supersedes_edge(new.id)
229
+ # External landing identity.
230
+ name = external_transcriber_name("gemini-2.5-pro")
231
+ assert name == "gemini-2.5-pro" + EXTERNAL_TRANSCRIBER_MARKER == "gemini-2.5-pro/manual"
232
+ with pytest.raises(ValueError):
233
+ external_transcriber_name(" ")
234
+ h1 = external_config_hash("gemini-2.5-pro", "sha256:prompt-a")
235
+ h2 = external_config_hash("gemini-2.5-pro", "sha256:prompt-b")
236
+ assert h1.startswith("sha256:") and h1 != h2
237
+ assert h1 == external_config_hash("gemini-2.5-pro", "sha256:prompt-a"), "deterministic"
238
+ ext = TranscriptNode(rendition=R_RAW, transcriber=name, config_hash=h1, text="NCCL, not nickel",
239
+ audio_hash="sha256:wav0", actor="human:operator", method="external-landing")
240
+ en = ext.to_graph_node()
241
+ assert en["id"] == transcript_node_id(R_RAW, name, h1)
242
+ assert en["properties"]["actor"] == "human:operator" and en["properties"]["method"] == "external-landing"
243
+ # The default attribution is unchanged for capability runs.
244
+ assert new.to_graph_node()["properties"]["actor"] == "capability:voxtral"
245
+ assert new.to_graph_node()["properties"]["method"] == "transcribe"
246
+
247
+
248
+ def test_segment_identity_salt_and_superseded_by_respine_props():
249
+ """0b4d5cfa (3)/(4), schema 0.0.11: a chunk respine forks Segment identity through
250
+ `identity_salt` ONLY — the skeleton_hash prop stays the live spine's, so no reader
251
+ splits the spine; the salt is the landed Transcript id, so the same landing twice
252
+ collides into the same id (idempotent) and a different landing mints fresh ids.
253
+ `superseded_by` rides as a prop only when set (live segments carry none)."""
254
+ from cjm_transcript_graph_schema.schema import (RESPINE_OP_VERB, SEGMENT_SUPERSEDED_BY_PROP,
255
+ SOURCE_RESPINED_CHUNKS_PROP, RespinedChunkEntry)
256
+
257
+ live = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
258
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi")
259
+ t_a = transcript_node_id(R_RAW, "gemini-3.8-flash/manual", "sha256:cfg-a")
260
+ t_b = transcript_node_id(R_RAW, "gemini-3.8-flash/manual", "sha256:cfg-b")
261
+ salted = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
262
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
263
+ identity_salt=t_a)
264
+ again = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
265
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
266
+ identity_salt=t_a)
267
+ other = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
268
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
269
+ identity_salt=t_b)
270
+ assert live.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0), "pre-salt ids unchanged"
271
+ assert live.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0, identity_salt=None)
272
+ assert salted.id != live.id and salted.id == again.id and other.id != salted.id
273
+ assert salted.id == segment_node_id(R_RAW, "sha256:skel", 1.0, 2.0, identity_salt=t_a)
274
+ lp, sp = live.to_graph_node()["properties"], salted.to_graph_node()["properties"]
275
+ assert sp["skeleton_hash"] == lp["skeleton_hash"] == "sha256:skel", "the salt never touches the hash"
276
+ assert sp["identity_salt"] == t_a and "identity_salt" not in lp
277
+ assert SEGMENT_SUPERSEDED_BY_PROP not in lp and SEGMENT_SUPERSEDED_BY_PROP not in sp
278
+ old = SegmentNode(rendition=R_RAW, vad_config_hash="sha256:skel", chunk_start=1.0,
279
+ chunk_end=2.0, index=7, start_time=301.0, end_time=302.0, text="hi",
280
+ superseded_by="op-1")
281
+ assert old.id == live.id, "superseded_by is a stamp, never identity"
282
+ assert old.to_graph_node()["properties"][SEGMENT_SUPERSEDED_BY_PROP] == "op-1"
283
+ # The Source-side record round-trips (missing lists read empty).
284
+ assert (RESPINE_OP_VERB, SOURCE_RESPINED_CHUNKS_PROP) == ("chunk-respine", "respined_chunks")
285
+ entry = RespinedChunkEntry(audio_segment=A1, transcript=t_a, run="run-1", op_id="op-1",
286
+ ts=1.5, old_segments=[live.id], new_segments=[salted.id],
287
+ stranded=["c1"], carried=["c2"], straddles=[salted.id])
288
+ d = entry.to_dict()
289
+ assert RespinedChunkEntry.from_dict(d) == entry
290
+ assert RespinedChunkEntry.from_dict({"audio_segment": A1, "transcript": t_a,
291
+ "run": "r", "op_id": "o", "ts": 2}).stranded == []
@@ -1 +0,0 @@
1
- __version__ = "0.0.8"