@@ -835,53 +835,63 @@ def step4_verify(ep_num: int) -> None:
835835 ensure_ascii = False )
836836 print (f" step4: { len (w3_words )} words saved → { w3_path } " , flush = True )
837837
838- # ── Compare w2 vs w3 ──────────────────────────────────────────────────────
839- # Both in moshi-finetune format: {"alignments": [[text, [start, end], ...], ...]}
838+ # ── Compare w2 vs w3 timestamps ───────────────────────────────────────────
839+ # Strategy: forward-cursor walk through both streams in order.
840+ # For each w2 word advance the w3 cursor until we find the same normalised
841+ # text within a ±LOOK_AHEAD word window. Only words that both runs agree on
842+ # (same text, same relative order) get their timestamps compared.
843+ # This avoids the "common short word matched at wrong instance" problem of
844+ # the previous bisect approach.
840845 w2_alignments = json .load (open (w2_path , encoding = "utf-8" ))["alignments" ]
841846
842847 w3_norm = [((norm_words (w ["word" ]) or ["" ])[0 ]) for w in w3_words ]
843848 w3_start = [w ["start" ] for w in w3_words ]
849+ w2_norm = [((norm_words (e [0 ]) or ["" ])[0 ]) for e in w2_alignments ]
850+ w2_start = [e [1 ][0 ] for e in w2_alignments ]
844851
845- MATCH_WIN = 15.0 # ±s around w2 time to search for w3 counterpart
846- # Two independent whisper runs on the same audio can drift
847- # by several seconds at segment boundaries over a long episode.
848- n_match = n_miss = 0
852+ LOOK_AHEAD = 30 # scan up to this many w3 words ahead before giving up
853+ TIME_SYNC = 10.0 # re-anchor w3 cursor by time if it drifts more than this
849854 deltas : list = []
850855 large : list = []
856+ n_matched = n_skipped = 0
857+ j = 0 # w3 cursor
851858
852- for entry in w2_alignments :
853- text , (t2 , _t2e ) = entry [0 ], entry [1 ]
854- key = (norm_words (text ) or ["" ])[0 ]
859+ for i , key in enumerate (w2_norm ):
855860 if not key :
856861 continue
857- lo = bisect .bisect_left (w3_start , t2 - MATCH_WIN )
858- hi = bisect .bisect_right (w3_start , t2 + MATCH_WIN )
859- best_d , best_t3 = float ("inf" ), None
860- for i in range (lo , hi ):
861- if w3_norm [i ] == key :
862- d = abs (w3_start [i ] - t2 )
863- if d < best_d :
864- best_d = d
865- best_t3 = w3_start [i ]
866- if best_t3 is None :
867- n_miss += 1
868- else :
869- n_match += 1
870- deltas .append (best_d )
871- if best_d > 0.300 :
872- large .append ((text , t2 , best_t3 ))
862+ t2 = w2_start [i ]
863+ # Re-anchor cursor: if w3[j] is more than TIME_SYNC seconds away from
864+ # the expected w2 time, reset j to the closest w3 position by time.
865+ if j < len (w3_start ) and abs (w3_start [j ] - t2 ) > TIME_SYNC :
866+ j = bisect .bisect_left (w3_start , t2 - 1.0 )
867+ # scan w3 from current cursor up to LOOK_AHEAD words ahead
868+ found = False
869+ for k in range (j , min (j + LOOK_AHEAD , len (w3_norm ))):
870+ if w3_norm [k ] == key :
871+ d = abs (w3_start [k ] - t2 )
872+ deltas .append (d )
873+ if d > 0.300 :
874+ large .append ((w2_alignments [i ][0 ], t2 , w3_start [k ]))
875+ j = k + 1 # advance cursor past this match
876+ n_matched += 1
877+ found = True
878+ break
879+ if not found :
880+ n_skipped += 1
873881
874- total = n_match + n_miss
875- pct = 100.0 * n_match / total if total else 0
882+ total = n_matched + n_skipped
883+ pct = 100.0 * n_matched / total if total else 0
876884 mean_d = sum (deltas ) / len (deltas ) if deltas else 0
877885 max_d = max (deltas ) if deltas else 0
886+ median_d = sorted (deltas )[len (deltas ) // 2 ] if deltas else 0
878887 over_300 = len (large )
879888
880889 print (f"\n Step 4 — w2 vs w3 timestamp comparison:" , flush = True )
881890 print (f" w2 words : { len (w2_alignments )} " , flush = True )
882891 print (f" w3 words : { len (w3_words )} " , flush = True )
883- print (f" Matched : { n_match } /{ total } ({ pct :.1f} %)" , flush = True )
892+ print (f" Order-matched : { n_matched } /{ total } ({ pct :.1f} %)" , flush = True )
884893 print (f" Mean |Δ| : { mean_d * 1000 :.0f} ms" , flush = True )
894+ print (f" Median|Δ| : { median_d * 1000 :.0f} ms" , flush = True )
885895 print (f" Max |Δ| : { max_d * 1000 :.0f} ms" , flush = True )
886896 print (f" Words > 300 ms : { over_300 } " , flush = True )
887897
@@ -891,18 +901,15 @@ def step4_verify(ep_num: int) -> None:
891901 print (f" { word !r:20s} w2={ t2 :.3f} s w3={ t3 :.3f} s "
892902 f"Δ={ abs (t2 - t3 )* 1000 :.0f} ms" , flush = True )
893903
894- # Quality gate: two independent whisper runs on the same audio will naturally
895- # differ by ~5-8% due to retranscription variance (different word choices, splits,
896- # merges) and timestamps drift by up to several seconds over a long episode.
897- # We check match rate ≥90% and median |Δ| ≤ 300ms — anything worse indicates
898- # a real problem with gap-cut boundaries or timestamp math.
899- median_d = sorted (deltas )[len (deltas ) // 2 ] if deltas else 0
900- if pct < 90.0 or median_d > 0.300 :
904+ # Quality gate: only words both runs agree on (same text, same order) are
905+ # compared. Timestamp delta should be small — large median Δ means the
906+ # gap-cut boundaries or timestamp math is wrong.
907+ if median_d > 0.300 :
901908 print (f"\n ✗ Quality gate FAILED "
902- f"(need ≥90% match and median Δ ≤ 300ms)" , flush = True )
909+ f"(median Δ= { median_d * 1000 :.0f } ms, need ≤ 300ms)" , flush = True )
903910 else :
904911 print (f"\n ✓ Quality gate PASSED "
905- f"(match= { pct :.1f } % median|Δ|={ median_d * 1000 :.0f} ms)" , flush = True )
912+ f"(median|Δ|={ median_d * 1000 :.0f} ms)" , flush = True )
906913
907914
908915# ══════════════════════════════════════════════════════════════════════════════
0 commit comments