davinci-resolve-mcp 2.82.1 → 2.86.2

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.
@@ -1008,7 +1008,8 @@ API_TRUTH: List[Dict[str, Any]] = [
1008
1008
  "a normalised position vector (param 17). Exposed as "
1009
1009
  "project_db list_subtitle_styles / set_subtitle_style "
1010
1010
  "(font family/size/weight/italic + position). Confirmed "
1011
- "live on Studio 19.1.3 (2026-08-06): Resolve opens a patched track "
1011
+ "live on BOTH editions 2026-08-06 — Studio 19.1.3 and "
1012
+ "free 21.0.3, identical behaviour: Resolve opens a patched track "
1012
1013
  "and re-serialises it back to its own zstd form with the "
1013
1014
  "patched values intact, so it genuinely parses the write. "
1014
1015
  "Caveats: whole-TRACK style not per-caption, project must "
@@ -1070,13 +1071,29 @@ API_TRUTH: List[Dict[str, Any]] = [
1070
1071
  "NOT refute the 21.0.0 record: if the bridge resolves any name "
1071
1072
  "known to the RemoteObject method table rather than literally any "
1072
1073
  "string, an invented name is correctly rejected on both builds and "
1073
- "the probe never exercised the failing case. Re-running the probe "
1074
- "with those five real names is what would settle it; until then, "
1075
- "assume fabrication is possible.",
1074
+ "the probe never exercised the failing case. "
1075
+ "PARTLY SETTLED 2026-08-06 on Studio 19.1.3.7, DIRECT connection "
1076
+ "(not the bridge): the five real borrowed names were re-run as the "
1077
+ "entry asked, across Resolve, ProjectManager, Project, MediaPool, "
1078
+ "Timeline, Folder and TimelineItem — 42 checks. Every one returned "
1079
+ "getattr-callable False and dir()-absent, agreeing in all 42 cases, "
1080
+ "exactly like the invented control. So NO fabrication on the direct "
1081
+ "path on this build, and the borrowed-name case the 21.0.2.4 probe "
1082
+ "missed is now covered there. What the same run DID establish is "
1083
+ "narrower and worse than expected: bare hasattr() returns True for "
1084
+ "EVERY name on every object — real, borrowed or invented — while "
1085
+ "getattr returns None. hasattr alone is therefore not a weak probe, "
1086
+ "it is a constant True and carries no information at all. "
1087
+ "STILL OPEN: the 21.0.0 record was against the BRIDGE, which this "
1088
+ "run did not exercise, so bridge-side fabrication remains untested "
1089
+ "and the recommendation below stands unchanged.",
1076
1090
  "recommended": "Use dir(obj) membership for capability probes. It is correct "
1077
1091
  "on every build measured, and it is the only form not affected "
1078
- "by whichever way this resolves. server._has_method uses "
1079
- "hasattr/getattr and so may over-report on builds where "
1092
+ "by whichever way this resolves. NEVER use bare hasattr(): it "
1093
+ "is a constant True on Resolve objects and cannot distinguish "
1094
+ "anything. server._has_method uses callable(getattr(...)), "
1095
+ "which agreed with dir() in all 42 direct-path checks on "
1096
+ "19.1.3.7 but may still over-report on a bridge build where "
1080
1097
  "fabrication is live — that is the case _requires_method gates "
1081
1098
  "guard, so it matters most exactly where it is least tested. "
1082
1099
  "Calling a fabricated method typically returns None/False with "
@@ -1444,7 +1461,12 @@ API_TRUTH: List[Dict[str, Any]] = [
1444
1461
  "are all marker-only and all refused. Five names overlap both "
1445
1462
  "palettes (Blue, Green, Yellow, Pink, Purple), which is why the "
1446
1463
  "decoy survives: reasoning from the marker constants scores 5 of "
1447
- "16 and looks like the right vocabulary with a few gaps.",
1464
+ "16 and looks like the right vocabulary with a few gaps. The "
1465
+ "reporter's field experience sharpens this — the trap is not that "
1466
+ "a decoy vocabulary exists, it is that the decoy HALF-WORKS. "
1467
+ "Intermittent successes read as an unreliable API rather than as "
1468
+ "a wrong vocabulary, so the wrong conclusion is the natural one; "
1469
+ "a clean 0-for-8 would have exposed the mechanism immediately.",
1448
1470
  "recommended": "Pass only the 16 clip-colour names; on False, treat it as an "
1449
1471
  "invalid name rather than an item/lock/page problem. The set "
1450
1472
  "is pinned in utils/clip_colors.py and named in the refusal "
@@ -1742,6 +1742,12 @@ def plan_transcript_tighten(
1742
1742
  plan.get("keep_ranges") or [],
1743
1743
  source_duration_seconds=float(duration) if isinstance(duration, (int, float)) and duration else None,
1744
1744
  )
1745
+ # The class this planner is structurally blind to. Its false-start heuristic
1746
+ # only sees restarts the transcript actually reports; an immediate re-read is
1747
+ # emitted ONCE with the pause and second take absorbed into one word, so the
1748
+ # plan cannot propose removing what it was never told happened (issue #125).
1749
+ from src.utils import swallowed_retakes as _swallowed
1750
+ plan["possible_swallowed_retakes"] = _swallowed.swallowed_retake_report(words)
1745
1751
  return plan
1746
1752
 
1747
1753
 
@@ -55,8 +55,10 @@ logger = logging.getLogger("resolve-mcp.bridge-client")
55
55
  DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
56
56
  #: Env override, so a caller can point at a non-default bridge without editing code.
57
57
  ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
58
- #: Opt-in. Absent means "do not try the bridge", so nothing changes for existing
59
- #: installs until someone asks for it.
58
+ #: Forces the bridge: it becomes the only transport tried, so its faults surface
59
+ #: directly. Absent does NOT mean "never try the bridge" — `connect_resolve` falls
60
+ #: back to it when a direct transport yields nothing, which is what lets the free
61
+ #: edition work with no configuration beyond starting the in-Resolve script.
60
62
  ENV_ENABLE = "DAVINCI_RESOLVE_BRIDGE"
61
63
 
62
64
  #: Operations this client cannot work without. The in-Resolve script is a *copy*
@@ -390,7 +392,8 @@ def connect(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, require_enabled: bool =
390
392
  """
391
393
  if require_enabled and not bridge_enabled():
392
394
  raise BridgeUnavailable(
393
- f"The in-app bridge is opt-in: set {ENV_ENABLE}=1 to use it."
395
+ f"This caller required an explicitly-enabled bridge: set {ENV_ENABLE}=1, "
396
+ "or call connect(require_enabled=False) to use the bridge as a fallback."
394
397
  )
395
398
  path = config_path()
396
399
  try:
@@ -37,20 +37,51 @@ def _network_timeout():
37
37
  return timeout
38
38
 
39
39
 
40
+ def _try_bridge_fallback():
41
+ """Last-resort bridge attempt after a direct transport has already failed.
42
+
43
+ Returns a proxy, or None when there is no bridge to reach. Unlike the
44
+ explicitly-requested path this swallows `BridgeUnavailable`: nobody asked for
45
+ the bridge, so "no bridge running" is simply "no Resolve" — the same answer
46
+ the caller got before this fallback existed.
47
+ """
48
+ if bridge_client is None:
49
+ return None
50
+ try:
51
+ proxy = bridge_client.connect(require_enabled=False)
52
+ except bridge_client.BridgeUnavailable as exc:
53
+ logger.debug("no in-app bridge to fall back to: %s", exc)
54
+ return None
55
+ logger.info(
56
+ "external scripting unavailable; automatically using the in-app bridge "
57
+ "(set %s=1 to require it and surface bridge faults directly)",
58
+ bridge_client.ENV_ENABLE,
59
+ )
60
+ return proxy
61
+
62
+
40
63
  def connect_resolve(dvr_script):
41
64
  """Create a Resolve handle. Three modes, tried in order of directness.
42
65
 
43
- 1. **Bridge** (opt-in, `DAVINCI_RESOLVE_BRIDGE=1`) — a proxy over the
66
+ 1. **Bridge** (forced, `DAVINCI_RESOLVE_BRIDGE=1`) — a proxy over the
44
67
  authenticated loopback bridge running inside Resolve. This is the only
45
68
  mode that works on the **free edition**, whose external scripting API
46
- refuses foreign processes. Tried first when enabled, because if a user has
47
- asked for it, silently using a different transport would hide a broken
48
- bridge.
69
+ refuses foreign processes. Tried first *and exclusively* when the env var
70
+ is set, because if a user has asked for it, silently using a different
71
+ transport would hide a broken bridge.
49
72
  2. **Network** (`RESOLVE_SCRIPT_HOST`) — Studio's remote scripting.
50
73
  3. **Local** — Studio's local scripting. The default.
51
74
 
52
- `dvr_script` may be None in bridge mode: the bridge does not need Blackmagic's
53
- module at all, which is precisely why it reaches editions the module cannot.
75
+ If a direct transport yields nothing, the bridge is tried once more as a
76
+ **fallback**, without the env var. That cannot mask a broken bridge the way
77
+ an unconditional bridge-first order would: it only runs on a path that has
78
+ already failed, so the env var stays meaningful as "require the bridge and
79
+ tell me when it breaks" while the free edition works with no configuration
80
+ beyond starting the in-Resolve script.
81
+
82
+ `dvr_script` may be None: the bridge does not need Blackmagic's module at
83
+ all, which is precisely why it reaches editions the module cannot — so a
84
+ missing module is a reason to try the bridge, not to give up.
54
85
  """
55
86
  if bridge_client is not None and bridge_client.bridge_enabled():
56
87
  try:
@@ -60,12 +91,16 @@ def connect_resolve(dvr_script):
60
91
  # falling through to a transport the user did not ask for.
61
92
  logger.error("in-app bridge requested but unavailable: %s", exc)
62
93
  raise
63
- if dvr_script is None:
64
- return None
65
- host = os.environ.get("RESOLVE_SCRIPT_HOST")
66
- if host:
67
- return dvr_script.scriptapp("Resolve", host, _network_timeout())
68
- return dvr_script.scriptapp("Resolve")
94
+ resolve = None
95
+ if dvr_script is not None:
96
+ host = os.environ.get("RESOLVE_SCRIPT_HOST")
97
+ if host:
98
+ resolve = dvr_script.scriptapp("Resolve", host, _network_timeout())
99
+ else:
100
+ resolve = dvr_script.scriptapp("Resolve")
101
+ if resolve is not None:
102
+ return resolve
103
+ return _try_bridge_fallback()
69
104
 
70
105
 
71
106
  def initialize_resolve():
@@ -0,0 +1,338 @@
1
+ """Resolve build comparison, and the API surfaces that are gated on a build.
2
+
3
+ The scripting API changes per **patch** release, not per major one. "Resolve 21"
4
+ is not a fine enough label to route on: `GetFairlightPresets` exists on 20.2.2
5
+ and not on 19.1.3, and issue #131 reports three surfaces that appear only in
6
+ 21.0.4. An agent given the same guidance regardless of the build it is connected
7
+ to will confidently describe a method that is not there — which is exactly the
8
+ report in issue #132.
9
+
10
+ Two separate questions live here, and conflating them is the trap:
11
+
12
+ 1. **Does this build have the method?** Answered from `VERSION_GATES` below,
13
+ which records only gates we can point at evidence for.
14
+ 2. **Was a recorded quirk measured on a build like mine?** Answered by comparing
15
+ an `api_truth` entry's stamp against the live build. A fact measured on
16
+ 19.1.3.7 is a *prior* on 21.0.4, not a finding.
17
+
18
+ Neither question is answered by guessing. A symbol absent from `VERSION_GATES`
19
+ returns `unknown`, never `available` — the registry records what we know, and
20
+ most of the API has never been version-bisected. `unknown` means "probe it",
21
+ which is the honest instruction; a false `available` is how an agent ends up
22
+ insisting a method exists.
23
+ """
24
+
25
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
26
+
27
+ # ── Version parsing ─────────────────────────────────────────────────────────
28
+ # Resolve reports versions three ways depending on where you ask:
29
+ # Resolve.GetVersion() -> [19, 1, 3, 7, ""] (list, trailing build str)
30
+ # Resolve.GetVersionString() -> "19.1.3.7"
31
+ # and prose in this repo says things like "Studio 19.1.3.7 (macOS)".
32
+
33
+
34
+ def parse_version(value: Any) -> Optional[Tuple[int, ...]]:
35
+ """Best-effort parse to a comparable tuple. None when nothing numeric is found.
36
+
37
+ Accepts the list form, the dotted string, or a sentence containing one. Only
38
+ leading numeric components are kept, so "21.0.4.5" -> (21, 0, 4, 5) and the
39
+ trailing build-name string Resolve appends is dropped rather than coerced.
40
+ """
41
+ # bool is a subclass of int, so True would otherwise parse as version (1,) —
42
+ # a truthy flag leaking in must not become a plausible-looking build number.
43
+ if value is None or isinstance(value, bool):
44
+ return None
45
+ if isinstance(value, (list, tuple)):
46
+ parts: List[int] = []
47
+ for item in value:
48
+ if isinstance(item, bool):
49
+ break
50
+ if isinstance(item, int):
51
+ parts.append(item)
52
+ elif isinstance(item, str) and item.strip().isdigit():
53
+ parts.append(int(item.strip()))
54
+ else:
55
+ break
56
+ return tuple(parts) or None
57
+ if isinstance(value, (int, float)):
58
+ return (int(value),)
59
+ if not isinstance(value, str):
60
+ return None
61
+
62
+ best: Optional[Tuple[int, ...]] = None
63
+ token: List[str] = []
64
+ for char in list(value) + [" "]:
65
+ if char.isdigit() or char == ".":
66
+ token.append(char)
67
+ continue
68
+ if token:
69
+ parts = [p for p in "".join(token).split(".") if p.isdigit()]
70
+ if parts:
71
+ candidate = tuple(int(p) for p in parts)
72
+ # Prefer the most specific run, so "Studio 19.1.3.7" beats a bare
73
+ # "19" appearing earlier in the same sentence.
74
+ if best is None or len(candidate) > len(best):
75
+ best = candidate
76
+ token = []
77
+ return best
78
+
79
+
80
+ def compare_versions(a: Any, b: Any) -> Optional[int]:
81
+ """-1/0/1 for a<b, a==b, a>b. None when either side will not parse.
82
+
83
+ Shorter tuples compare as though zero-padded: 21.0 == 21.0.0.
84
+ """
85
+ va, vb = parse_version(a), parse_version(b)
86
+ if va is None or vb is None:
87
+ return None
88
+ width = max(len(va), len(vb))
89
+ pa = va + (0,) * (width - len(va))
90
+ pb = vb + (0,) * (width - len(vb))
91
+ return (pa > pb) - (pa < pb)
92
+
93
+
94
+ def at_least(live: Any, required: Any) -> Optional[bool]:
95
+ """True when `live` >= `required`. None when either will not parse."""
96
+ result = compare_versions(live, required)
97
+ return None if result is None else result >= 0
98
+
99
+
100
+ # ── Version gates ───────────────────────────────────────────────────────────
101
+ # Only surfaces with evidence behind them. `source` separates what this repo
102
+ # measured from what a reporter measured, because the two carry different
103
+ # weight and an agent relaying the fact should be able to say which it is.
104
+ #
105
+ # measured — probed live by this project
106
+ # reported — a user's live probe, credited, not independently reproduced
107
+ # vendor — stated by Blackmagic's shipped Developer/Scripting/README.txt
108
+
109
+ VERSION_GATES: List[Dict[str, Any]] = [
110
+ {
111
+ "symbol": "Resolve.GetFairlightPresets",
112
+ "introduced_in": "20.2.2",
113
+ "source": "measured",
114
+ "note": "Absent on 19.1.3 (confirmed live). Without it the per-parameter "
115
+ "Fairlight gap is the whole story, because the whole-mix preset "
116
+ "workaround is unavailable.",
117
+ "issue": 128,
118
+ },
119
+ {
120
+ "symbol": "Timeline.ApplyFairlightPresetToCurrentTimeline",
121
+ "introduced_in": "20.2.2",
122
+ "source": "measured",
123
+ "note": "Same gate as GetFairlightPresets; the two are only useful together.",
124
+ "issue": 128,
125
+ },
126
+ {
127
+ "symbol": "Timeline.GetSelectedClips",
128
+ "introduced_in": "21.0.4",
129
+ "source": "reported",
130
+ "note": "Reported against 21.0.4.5 and listed in the shipped scripting "
131
+ "README. This repo's selection helper duck-probes three names and "
132
+ "reaches it by luck on builds that have it, so an absence here "
133
+ "degrades rather than errors.",
134
+ "issue": 131,
135
+ },
136
+ {
137
+ "symbol": "MediaPoolItem.GetTimeline",
138
+ "introduced_in": "21.0.4",
139
+ "source": "reported",
140
+ "note": "Reported against 21.0.4.5. Not reachable through this server yet.",
141
+ "issue": 131,
142
+ },
143
+ {
144
+ "symbol": "Project.SetRenderSettings UseFullExtents",
145
+ "introduced_in": "21.0.4",
146
+ "source": "vendor",
147
+ "note": "New SetRenderSettings key per the shipped scripting README. "
148
+ "SetRenderSettings ignores unknown keys silently, so on an older "
149
+ "build this is dropped with no signal rather than refused.",
150
+ "issue": 131,
151
+ },
152
+ {
153
+ "symbol": "Project.SetRenderSettings AddFrameHandles",
154
+ "introduced_in": "21.0.4",
155
+ "source": "vendor",
156
+ "note": "New SetRenderSettings key; ignored when full extents is enabled, "
157
+ "so it can do nothing for two different reasons.",
158
+ "issue": 131,
159
+ },
160
+ {
161
+ "symbol": "Project.SetRenderSettings DataBurnIn",
162
+ "introduced_in": "21.0.4",
163
+ "source": "vendor",
164
+ "note": "New SetRenderSettings key per the shipped scripting README.",
165
+ "issue": 131,
166
+ },
167
+ ]
168
+
169
+ _GATES_BY_SYMBOL = {gate["symbol"].lower(): gate for gate in VERSION_GATES}
170
+
171
+ AVAILABLE = "available"
172
+ UNAVAILABLE = "unavailable"
173
+ UNKNOWN = "unknown"
174
+
175
+
176
+ def gate_for(symbol: str) -> Optional[Dict[str, Any]]:
177
+ """The recorded gate for `symbol`, matched loosely. None if we have none."""
178
+ if not symbol:
179
+ return None
180
+ needle = symbol.strip().lower()
181
+ if needle in _GATES_BY_SYMBOL:
182
+ return _GATES_BY_SYMBOL[needle]
183
+ for key, gate in _GATES_BY_SYMBOL.items():
184
+ if needle in key or key in needle:
185
+ return gate
186
+ return None
187
+
188
+
189
+ def availability(symbol: str, live_version: Any) -> Dict[str, Any]:
190
+ """Is `symbol` present on `live_version`? Says `unknown` unless it knows.
191
+
192
+ The default matters more than the positive cases. Most of the API has never
193
+ been version-bisected, so anything outside VERSION_GATES is unknown — and an
194
+ agent should probe rather than assert.
195
+ """
196
+ gate = gate_for(symbol)
197
+ if gate is None:
198
+ return {
199
+ "status": UNKNOWN,
200
+ "symbol": symbol,
201
+ "reason": "No version gate is recorded for this symbol.",
202
+ "remediation": (
203
+ "Probe it on the live build with `name in dir(obj)` rather than "
204
+ "assuming either way. Do NOT use hasattr: on a Resolve API object "
205
+ "hasattr returns True for EVERY name, real or invented (measured on "
206
+ "Studio 19.1.3.7), so it can only ever say yes. "
207
+ "`callable(getattr(obj, name, None))` also works on the direct "
208
+ "connection, but dir() membership is the form unaffected by the "
209
+ "attribute-fabrication question — see the api_truth entry "
210
+ "'hasattr() / getattr() on Resolve API objects'. Most of the "
211
+ "scripting API has never been version-bisected here."
212
+ ),
213
+ }
214
+ ok = at_least(live_version, gate["introduced_in"])
215
+ if ok is None:
216
+ return {
217
+ "status": UNKNOWN,
218
+ "symbol": gate["symbol"],
219
+ "introduced_in": gate["introduced_in"],
220
+ "source": gate["source"],
221
+ "reason": f"Could not parse the live version ({live_version!r}).",
222
+ "remediation": "Read resolve_control get_version and pass version_string.",
223
+ }
224
+ return {
225
+ "status": AVAILABLE if ok else UNAVAILABLE,
226
+ "symbol": gate["symbol"],
227
+ "introduced_in": gate["introduced_in"],
228
+ "live_version": str(live_version),
229
+ "source": gate["source"],
230
+ "note": gate.get("note"),
231
+ "issue": gate.get("issue"),
232
+ "reason": (
233
+ f"{gate['symbol']} is recorded as introduced in {gate['introduced_in']} "
234
+ f"({gate['source']}); this build is {live_version}."
235
+ ),
236
+ "remediation": None if ok else (
237
+ f"Not present on this build. Do not offer it — say the build is too old "
238
+ f"and name {gate['introduced_in']} as the floor."
239
+ ),
240
+ }
241
+
242
+
243
+ def gates_unavailable_on(live_version: Any) -> List[Dict[str, Any]]:
244
+ """Every recorded gate this build does NOT clear, for a session preflight."""
245
+ out = []
246
+ for gate in VERSION_GATES:
247
+ if at_least(live_version, gate["introduced_in"]) is False:
248
+ out.append({
249
+ "symbol": gate["symbol"],
250
+ "introduced_in": gate["introduced_in"],
251
+ "source": gate["source"],
252
+ "issue": gate.get("issue"),
253
+ "note": gate.get("note"),
254
+ })
255
+ return out
256
+
257
+
258
+ # ── Applying a recorded fact to the live build ──────────────────────────────
259
+
260
+ def measurement_relevance(verified_on: Any, live_version: Any) -> Dict[str, Any]:
261
+ """How much weight does a fact measured on `verified_on` carry here?
262
+
263
+ Deliberately three-valued. A fact measured on an older build is not wrong,
264
+ it is unconfirmed — and saying so is different from both "applies" and
265
+ "does not apply".
266
+ """
267
+ result = compare_versions(verified_on, live_version)
268
+ if result is None:
269
+ return {
270
+ "relevance": UNKNOWN,
271
+ "note": "Could not compare the measurement stamp with the live build.",
272
+ }
273
+ if result == 0:
274
+ return {
275
+ "relevance": "same_build",
276
+ "note": "Measured on this exact build.",
277
+ }
278
+ if result < 0:
279
+ return {
280
+ "relevance": "older_measurement",
281
+ "note": (
282
+ f"Measured on {verified_on}, older than this build ({live_version}). "
283
+ "Treat it as a prior, not a finding — Blackmagic changes the "
284
+ "scripting API per patch release. Re-confirm before relying on it."
285
+ ),
286
+ }
287
+ return {
288
+ "relevance": "newer_measurement",
289
+ "note": (
290
+ f"Measured on {verified_on}, NEWER than this build ({live_version}). "
291
+ "The behaviour described may not exist here yet, and a fix recorded "
292
+ "there has certainly not landed here."
293
+ ),
294
+ }
295
+
296
+
297
+ def annotate_facts(
298
+ facts: Sequence[Dict[str, Any]],
299
+ live_version: Any,
300
+ *,
301
+ ledger_verified_on: Optional[str] = None,
302
+ ) -> List[Dict[str, Any]]:
303
+ """Copy `facts` with a `version_context` block against the live build.
304
+
305
+ `ledger_verified_on` is the module-wide stamp — when the ledger as a whole
306
+ was last reviewed. It is deliberately NOT used as a per-fact measurement:
307
+ most entries record the build they were actually measured on in their prose
308
+ (19.1.3.7, 21.0.0, 21.0.2.4 …), and attributing the ledger-wide stamp to
309
+ each of them would invent a measurement that never happened. An entry with
310
+ no structured `verified_on` therefore reports relevance `unknown` and says
311
+ where to look — which is true, and points at the real gap.
312
+
313
+ Non-destructive: entries are copied, so the ledger is never mutated.
314
+ """
315
+ if not live_version:
316
+ return [dict(fact) for fact in facts]
317
+ out = []
318
+ for fact in facts:
319
+ annotated = dict(fact)
320
+ context: Dict[str, Any] = {"live_version": str(live_version)}
321
+ stamp = fact.get("verified_on")
322
+ if stamp:
323
+ context.update(measurement_relevance(stamp, live_version))
324
+ context["measured_on"] = stamp
325
+ else:
326
+ context["relevance"] = UNKNOWN
327
+ context["note"] = (
328
+ "This entry carries no structured measurement stamp; the build it "
329
+ "was verified on is stated in its `reality` text. Read it there "
330
+ "before relying on the fact."
331
+ )
332
+ if ledger_verified_on:
333
+ context["ledger_reviewed_on"] = ledger_verified_on
334
+ if gate_for(fact.get("symbol", "")) or fact.get("min_version"):
335
+ context["availability"] = availability(fact.get("symbol", ""), live_version)
336
+ annotated["version_context"] = context
337
+ out.append(annotated)
338
+ return out
@@ -0,0 +1,139 @@
1
+ """Flag transcript words that may have swallowed an immediate retake.
2
+
3
+ When a speaker re-reads a sentence immediately — the normal way people
4
+ self-correct while recording — whisper emits the text ONCE, aligns it to the
5
+ FIRST take, and absorbs "pause + entire second take" into the duration of a
6
+ single word. The transcript then says the sentence was spoken once, cleanly.
7
+ Every transcript-reading feature downstream inherits that lie: retake
8
+ detection never fires, fluency scoring undercounts restarts, and a cut placed
9
+ on a word boundary near the swallow point lands inside speech.
10
+
11
+ Energy detection cannot catch it either. In the original measurement the
12
+ swallowed span was breathing and keyboard noise peaking at -12.1 dB, LOUDER
13
+ than the adjacent real speech at -16.7 dB, so `silencedetect` recalled 3 of 17
14
+ ground-truth instances at any threshold. See issue #125.
15
+
16
+ What this module does is deliberately small: it flags words whose duration is
17
+ implausible for a single spoken word, and stops. It does not propose a cut
18
+ point, because the data cannot support one — where inside the stretched word
19
+ the second take begins is exactly what the timestamps have destroyed.
20
+
21
+ **Why an absolute bar, after two better-sounding ideas failed.**
22
+ The reporter measured all three on English material (2026-08-06, synthetic but
23
+ acoustically matched, ground truth from a sample-accurate splice log):
24
+
25
+ - A characters-per-second gate (`>= 2.5s and < 4.5 chars/s`), which worked on
26
+ the original Chinese material, fires on **0 of 50** English segments — the
27
+ swallowed segment itself runs 8.81 chars/s against an English median of 14.5.
28
+ Characters per second is not comparable across writing systems; it is retired
29
+ here rather than made configurable.
30
+ - Scoring each word against the distribution of its OWN durations elsewhere in
31
+ the take — self-calibrating, and the obvious language-agnostic fix — missed
32
+ the swallow entirely and produced 8 false positives, all function words. The
33
+ reason is structural and worth keeping: the words that absorb retakes are
34
+ content words, and content words are naturally rare within one take, so they
35
+ never accumulate a distribution to be scored against.
36
+ - The plain absolute bar found exactly one stretched word in 191.5s, and it was
37
+ the swallow point.
38
+
39
+ So: one threshold, no calibration, no language assumptions.
40
+
41
+ **Confidence.** The English confirmation rests on a single genuine swallow
42
+ (n=1) in synthetic material — 13 of 15 planted retakes transcribed too cleanly
43
+ to swallow, which is itself a limit of TTS-generated speech. The Chinese
44
+ measurement it generalises from had 17 real instances. Treat the bar as a
45
+ usable default, not a tuned constant.
46
+ """
47
+
48
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
49
+
50
+ # Seconds. A single spoken word above this is implausible in any language the
51
+ # reporter measured; the one true positive ran well past it.
52
+ DEFAULT_MIN_WORD_SECONDS = 1.2
53
+
54
+ CAVEAT = (
55
+ "A flag is not a cut point. Where inside a stretched word the second take "
56
+ "begins is precisely what the swallow destroyed, so these need a human ear "
57
+ "(or a re-transcription pass over the isolated window) before anything is "
58
+ "removed. Absence of flags is not evidence of no retakes: a swallow that "
59
+ "smeared across several words rather than one will not clear the bar."
60
+ )
61
+
62
+
63
+ def _seconds(row: Mapping[str, Any], *keys: str) -> Optional[float]:
64
+ for key in keys:
65
+ value = row.get(key)
66
+ if isinstance(value, (int, float)):
67
+ return float(value)
68
+ return None
69
+
70
+
71
+ def _word_text(row: Mapping[str, Any]) -> str:
72
+ for key in ("word", "text", "token"):
73
+ value = row.get(key)
74
+ if isinstance(value, str) and value.strip():
75
+ return value.strip()
76
+ return ""
77
+
78
+
79
+ def find_swallowed_retake_candidates(
80
+ words: Sequence[Mapping[str, Any]],
81
+ *,
82
+ min_word_seconds: float = DEFAULT_MIN_WORD_SECONDS,
83
+ ) -> List[Dict[str, Any]]:
84
+ """Words whose duration exceeds the bar, in time order.
85
+
86
+ Accepts either transcript_words DB rows (`start_seconds`/`end_seconds`) or
87
+ raw whisper word dicts (`start`/`end`); a row missing either endpoint is
88
+ skipped rather than guessed at.
89
+ """
90
+ if not words or min_word_seconds <= 0:
91
+ return []
92
+ out: List[Dict[str, Any]] = []
93
+ for index, row in enumerate(words):
94
+ if not isinstance(row, Mapping):
95
+ continue
96
+ start = _seconds(row, "start_seconds", "start")
97
+ end = _seconds(row, "end_seconds", "end")
98
+ if start is None or end is None:
99
+ continue
100
+ duration = end - start
101
+ if duration < min_word_seconds:
102
+ continue
103
+ out.append({
104
+ "word_index": index,
105
+ "word": _word_text(row),
106
+ "start_seconds": round(start, 3),
107
+ "end_seconds": round(end, 3),
108
+ "duration_seconds": round(duration, 3),
109
+ "reason": (
110
+ f"word spans {duration:.2f}s, over the {min_word_seconds:.2f}s bar — "
111
+ "a retake may be absorbed into this word's duration"
112
+ ),
113
+ })
114
+ out.sort(key=lambda row: row["start_seconds"])
115
+ return out
116
+
117
+
118
+ def swallowed_retake_report(
119
+ words: Sequence[Mapping[str, Any]],
120
+ *,
121
+ min_word_seconds: float = DEFAULT_MIN_WORD_SECONDS,
122
+ ) -> Dict[str, Any]:
123
+ """`find_swallowed_retake_candidates` plus the caveat, ready to attach."""
124
+ candidates = find_swallowed_retake_candidates(words, min_word_seconds=min_word_seconds)
125
+ return {
126
+ "count": len(candidates),
127
+ "min_word_seconds": min_word_seconds,
128
+ "candidates": candidates,
129
+ "caveat": CAVEAT,
130
+ "note": (
131
+ f"{len(candidates)} word(s) long enough to have swallowed an immediate "
132
+ "retake. Whisper reports a re-read sentence once and absorbs the pause "
133
+ "plus the second take into one word, so these are invisible to both the "
134
+ "transcript and any silence threshold (issue #125)."
135
+ if candidates
136
+ else "No words cleared the stretched-word bar. That is weak evidence, "
137
+ "not proof: a swallow spread over several words will not show up here."
138
+ ),
139
+ }