@@ -12,6 +12,7 @@ const DRASTIC_MIN_OLD_WORDS: usize = 4;
1212const DRASTIC_SHRINK_RATIO : f32 = 0.60 ;
1313const DRASTIC_EDIT_RATIO : f32 = 0.50 ;
1414const DRASTIC_MIN_EDIT_WORDS : usize = 3 ;
15+ const GATE_TELEMETRY_MAX_ENTRIES : usize = 50 ;
1516
1617#[ derive( Debug , Clone ) ]
1718pub struct SegmentAccumulator {
@@ -67,6 +68,7 @@ impl SegmentAccumulator {
6768pub struct SegmentEmissionGate {
6869 last_emitted : Option < EmissionSnapshot > ,
6970 pending : Option < PendingCandidate > ,
71+ next_sequence : u64 ,
7072}
7173
7274#[ derive( Debug , Clone ) ]
@@ -75,13 +77,68 @@ pub enum SegmentEmissionDecision {
7577 Suppress ( SegmentSuppressionReason ) ,
7678}
7779
78- #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
80+ #[ derive(
81+ Debug ,
82+ Clone ,
83+ Copy ,
84+ PartialEq ,
85+ Eq ,
86+ serde:: Serialize ,
87+ serde:: Deserialize ,
88+ specta:: Type ,
89+ ) ]
7990pub enum SegmentSuppressionReason {
8091 Empty ,
8192 DuplicateNormalizedText ,
8293 PendingDrasticChange ,
8394}
8495
96+ #[ derive( Debug , Clone ) ]
97+ pub struct SegmentEmissionGateEvaluation {
98+ pub decision : SegmentEmissionDecision ,
99+ pub telemetry : GateEvaluationTelemetryEntry ,
100+ }
101+
102+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , serde:: Serialize , serde:: Deserialize , specta:: Type ) ]
103+ pub enum SegmentEmissionDecisionKind {
104+ Emit ,
105+ Suppress ,
106+ }
107+
108+ #[ derive( Debug , Default , Clone , serde:: Serialize , serde:: Deserialize , specta:: Type ) ]
109+ #[ serde( default ) ]
110+ pub struct GateTelemetryState {
111+ pub entries : Vec < GateEvaluationTelemetryEntry > ,
112+ }
113+
114+ impl GateTelemetryState {
115+ pub fn push ( & mut self , entry : GateEvaluationTelemetryEntry ) {
116+ self . entries . push ( entry) ;
117+
118+ let overflow = self . entries . len ( ) . saturating_sub ( GATE_TELEMETRY_MAX_ENTRIES ) ;
119+ if overflow > 0 {
120+ self . entries . drain ( 0 ..overflow) ;
121+ }
122+ }
123+ }
124+
125+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , specta:: Type ) ]
126+ pub struct GateEvaluationTelemetryEntry {
127+ pub sequence : u64 ,
128+ pub segment_id : String ,
129+ pub candidate_words : u64 ,
130+ pub last_emitted_words : u64 ,
131+ pub decision : SegmentEmissionDecisionKind ,
132+ pub suppression_reason : Option < SegmentSuppressionReason > ,
133+ pub is_drastic : Option < bool > ,
134+ pub distance : Option < u64 > ,
135+ pub normalize_ms : f64 ,
136+ pub validation_ms : f64 ,
137+ pub drastic_check_ms : f64 ,
138+ pub distance_ms : f64 ,
139+ pub evaluate_ms : f64 ,
140+ }
141+
85142#[ derive( Debug , Clone ) ]
86143struct EmissionSnapshot {
87144 normalized_text : String ,
@@ -104,19 +161,26 @@ impl SegmentEmissionGate {
104161 Self {
105162 last_emitted : None ,
106163 pending : None ,
164+ next_sequence : 1 ,
107165 }
108166 }
109167
110- pub fn evaluate ( & mut self , candidate : WhisperSegment ) -> SegmentEmissionDecision {
168+ pub fn evaluate ( & mut self , candidate : WhisperSegment ) -> SegmentEmissionGateEvaluation {
111169 let evaluate_started = Instant :: now ( ) ;
112170 let segment_id = candidate. id . clone ( ) ;
171+ let sequence = self . next_sequence ;
172+ self . next_sequence += 1 ;
113173
114174 let normalize_started = Instant :: now ( ) ;
115175 let normalized_text = normalized_segment_text ( & candidate) ;
116176 let normalize_duration = normalize_started. elapsed ( ) ;
117177
118- let mut telemetry =
119- GateEvaluationTelemetry :: new ( segment_id, normalize_duration, evaluate_started) ;
178+ let mut telemetry = GateEvaluationTelemetry :: new (
179+ sequence,
180+ segment_id,
181+ normalize_duration,
182+ evaluate_started,
183+ ) ;
120184 let validation_started = Instant :: now ( ) ;
121185 let candidate_words = words ( & normalized_text) ;
122186 telemetry. candidate_words = candidate_words. len ( ) ;
@@ -184,7 +248,7 @@ impl SegmentEmissionGate {
184248 candidate : WhisperSegment ,
185249 normalized_text : String ,
186250 telemetry : GateEvaluationTelemetry ,
187- ) -> SegmentEmissionDecision {
251+ ) -> SegmentEmissionGateEvaluation {
188252 self . last_emitted = Some ( EmissionSnapshot { normalized_text } ) ;
189253 self . pending = None ;
190254 telemetry. emit ( candidate)
@@ -201,6 +265,7 @@ impl SegmentEmissionGate {
201265}
202266
203267struct GateEvaluationTelemetry {
268+ sequence : u64 ,
204269 segment_id : String ,
205270 candidate_words : usize ,
206271 last_emitted_words : usize ,
@@ -221,8 +286,14 @@ struct DrasticChangeCheck {
221286}
222287
223288impl GateEvaluationTelemetry {
224- fn new ( segment_id : String , normalize_duration : Duration , evaluate_started : Instant ) -> Self {
289+ fn new (
290+ sequence : u64 ,
291+ segment_id : String ,
292+ normalize_duration : Duration ,
293+ evaluate_started : Instant ,
294+ ) -> Self {
225295 Self {
296+ sequence,
226297 segment_id,
227298 candidate_words : 0 ,
228299 last_emitted_words : 0 ,
@@ -236,35 +307,68 @@ impl GateEvaluationTelemetry {
236307 }
237308 }
238309
239- fn emit ( self , segment : WhisperSegment ) -> SegmentEmissionDecision {
240- self . log ( "emit" , None ) ;
241- SegmentEmissionDecision :: Emit ( segment)
310+ fn emit ( self , segment : WhisperSegment ) -> SegmentEmissionGateEvaluation {
311+ let telemetry = self . into_entry ( SegmentEmissionDecisionKind :: Emit , None ) ;
312+ log_gate_telemetry ( & telemetry) ;
313+
314+ SegmentEmissionGateEvaluation {
315+ decision : SegmentEmissionDecision :: Emit ( segment) ,
316+ telemetry,
317+ }
242318 }
243319
244- fn suppress ( self , reason : SegmentSuppressionReason ) -> SegmentEmissionDecision {
245- self . log ( "suppress" , Some ( reason) ) ;
246- SegmentEmissionDecision :: Suppress ( reason)
320+ fn suppress ( self , reason : SegmentSuppressionReason ) -> SegmentEmissionGateEvaluation {
321+ let telemetry =
322+ self . into_entry ( SegmentEmissionDecisionKind :: Suppress , Some ( reason) ) ;
323+ log_gate_telemetry ( & telemetry) ;
324+
325+ SegmentEmissionGateEvaluation {
326+ decision : SegmentEmissionDecision :: Suppress ( reason) ,
327+ telemetry,
328+ }
247329 }
248330
249- fn log ( & self , decision : & str , suppression_reason : Option < SegmentSuppressionReason > ) {
250- debug ! (
251- segment_id = %self . segment_id,
252- candidate_words = self . candidate_words,
253- last_emitted_words = self . last_emitted_words,
254- decision = decision,
255- suppression_reason = ?suppression_reason,
256- is_drastic = ?self . is_drastic,
257- distance = ?self . distance,
258- normalize_ms = elapsed_ms( self . normalize_duration) ,
259- validation_ms = elapsed_ms( self . validation_duration) ,
260- drastic_check_ms = elapsed_ms( self . drastic_check_duration) ,
261- distance_ms = elapsed_ms( self . distance_duration) ,
262- evaluate_ms = elapsed_ms( self . evaluate_started. elapsed( ) ) ,
263- "segment emission gate evaluated"
264- ) ;
331+ fn into_entry (
332+ self ,
333+ decision : SegmentEmissionDecisionKind ,
334+ suppression_reason : Option < SegmentSuppressionReason > ,
335+ ) -> GateEvaluationTelemetryEntry {
336+ GateEvaluationTelemetryEntry {
337+ sequence : self . sequence ,
338+ segment_id : self . segment_id ,
339+ candidate_words : self . candidate_words as u64 ,
340+ last_emitted_words : self . last_emitted_words as u64 ,
341+ decision,
342+ suppression_reason,
343+ is_drastic : self . is_drastic ,
344+ distance : self . distance . map ( |distance| distance as u64 ) ,
345+ normalize_ms : elapsed_ms ( self . normalize_duration ) ,
346+ validation_ms : elapsed_ms ( self . validation_duration ) ,
347+ drastic_check_ms : elapsed_ms ( self . drastic_check_duration ) ,
348+ distance_ms : elapsed_ms ( self . distance_duration ) ,
349+ evaluate_ms : elapsed_ms ( self . evaluate_started . elapsed ( ) ) ,
350+ }
265351 }
266352}
267353
354+ fn log_gate_telemetry ( entry : & GateEvaluationTelemetryEntry ) {
355+ debug ! (
356+ segment_id = %entry. segment_id,
357+ candidate_words = entry. candidate_words,
358+ last_emitted_words = entry. last_emitted_words,
359+ decision = ?entry. decision,
360+ suppression_reason = ?entry. suppression_reason,
361+ is_drastic = ?entry. is_drastic,
362+ distance = ?entry. distance,
363+ normalize_ms = entry. normalize_ms,
364+ validation_ms = entry. validation_ms,
365+ drastic_check_ms = entry. drastic_check_ms,
366+ distance_ms = entry. distance_ms,
367+ evaluate_ms = entry. evaluate_ms,
368+ "segment emission gate evaluated"
369+ ) ;
370+ }
371+
268372fn normalized_segment_text ( segment : & WhisperSegment ) -> String {
269373 let text = segment
270374 . items
@@ -364,8 +468,11 @@ mod tests {
364468 }
365469 }
366470
367- fn assert_emits ( decision : SegmentEmissionDecision , expected_text : & str ) {
368- match decision {
471+ fn assert_emits ( evaluation : SegmentEmissionGateEvaluation , expected_text : & str ) {
472+ assert_eq ! ( evaluation. telemetry. decision, SegmentEmissionDecisionKind :: Emit ) ;
473+ assert ! ( evaluation. telemetry. suppression_reason. is_none( ) ) ;
474+
475+ match evaluation. decision {
369476 SegmentEmissionDecision :: Emit ( segment) => {
370477 assert_eq ! ( segment. items[ 0 ] . text, expected_text) ;
371478 }
@@ -376,10 +483,16 @@ mod tests {
376483 }
377484
378485 fn assert_suppresses (
379- decision : SegmentEmissionDecision ,
486+ evaluation : SegmentEmissionGateEvaluation ,
380487 expected_reason : SegmentSuppressionReason ,
381488 ) {
382- match decision {
489+ assert_eq ! (
490+ evaluation. telemetry. decision,
491+ SegmentEmissionDecisionKind :: Suppress
492+ ) ;
493+ assert_eq ! ( evaluation. telemetry. suppression_reason, Some ( expected_reason) ) ;
494+
495+ match evaluation. decision {
383496 SegmentEmissionDecision :: Emit ( segment) => {
384497 panic ! ( "expected suppress, emitted {:?}" , segment. items) ;
385498 }
@@ -435,7 +548,14 @@ mod tests {
435548 fn emission_gate_emits_first_meaningful_update ( ) {
436549 let mut gate = SegmentEmissionGate :: new ( ) ;
437550
438- assert_emits ( gate. evaluate ( segment ( "segment-0" , "hello" ) ) , "hello" ) ;
551+ let evaluation = gate. evaluate ( segment ( "segment-0" , "hello" ) ) ;
552+
553+ assert_eq ! ( evaluation. telemetry. sequence, 1 ) ;
554+ assert_eq ! ( evaluation. telemetry. segment_id, "segment-0" ) ;
555+ assert_eq ! ( evaluation. telemetry. candidate_words, 1 ) ;
556+ assert_eq ! ( evaluation. telemetry. last_emitted_words, 0 ) ;
557+ assert ! ( evaluation. telemetry. evaluate_ms >= evaluation. telemetry. normalize_ms) ;
558+ assert_emits ( evaluation, "hello" ) ;
439559 }
440560
441561 #[ test]
@@ -538,4 +658,60 @@ mod tests {
538658
539659 assert_emits ( gate. evaluate ( segment ( "segment-1" , "alpha" ) ) , "alpha" ) ;
540660 }
661+
662+ #[ test]
663+ fn emission_gate_telemetry_records_suppression_details ( ) {
664+ let mut gate = SegmentEmissionGate :: new ( ) ;
665+
666+ assert_emits (
667+ gate. evaluate ( segment ( "segment-0" , "alpha beta gamma delta" ) ) ,
668+ "alpha beta gamma delta" ,
669+ ) ;
670+ let evaluation = gate. evaluate ( segment ( "segment-0" , "one two three four" ) ) ;
671+
672+ assert_eq ! ( evaluation. telemetry. sequence, 2 ) ;
673+ assert_eq ! (
674+ evaluation. telemetry. decision,
675+ SegmentEmissionDecisionKind :: Suppress
676+ ) ;
677+ assert_eq ! (
678+ evaluation. telemetry. suppression_reason,
679+ Some ( SegmentSuppressionReason :: PendingDrasticChange )
680+ ) ;
681+ assert_eq ! ( evaluation. telemetry. candidate_words, 4 ) ;
682+ assert_eq ! ( evaluation. telemetry. last_emitted_words, 4 ) ;
683+ assert_eq ! ( evaluation. telemetry. is_drastic, Some ( true ) ) ;
684+ assert_eq ! ( evaluation. telemetry. distance, Some ( 4 ) ) ;
685+ assert_suppresses (
686+ evaluation,
687+ SegmentSuppressionReason :: PendingDrasticChange ,
688+ ) ;
689+ }
690+
691+ #[ test]
692+ fn gate_telemetry_state_keeps_latest_entries ( ) {
693+ let mut state = GateTelemetryState :: default ( ) ;
694+
695+ for sequence in 1 ..=55 {
696+ state. push ( GateEvaluationTelemetryEntry {
697+ sequence,
698+ segment_id : "segment-0" . to_owned ( ) ,
699+ candidate_words : 1 ,
700+ last_emitted_words : 0 ,
701+ decision : SegmentEmissionDecisionKind :: Emit ,
702+ suppression_reason : None ,
703+ is_drastic : None ,
704+ distance : None ,
705+ normalize_ms : 0.0 ,
706+ validation_ms : 0.0 ,
707+ drastic_check_ms : 0.0 ,
708+ distance_ms : 0.0 ,
709+ evaluate_ms : 0.0 ,
710+ } ) ;
711+ }
712+
713+ assert_eq ! ( state. entries. len( ) , GATE_TELEMETRY_MAX_ENTRIES ) ;
714+ assert_eq ! ( state. entries[ 0 ] . sequence, 6 ) ;
715+ assert_eq ! ( state. entries. last( ) . unwrap( ) . sequence, 55 ) ;
716+ }
541717}
0 commit comments