3333
3434logger = logging .getLogger (__name__ )
3535
36-
37- STRATEGY = "equal"
38- CONS_TH = 3
39- MARGIN = 1
36+ # Selects which voting algorithm is used to combine detections across methods. The avaiable strategies are equal and priority voting.
37+ VOTING_STRATEGY = "equal"
38+ # Sets how many methods must agree before an alert is raised.
39+ MIN_METHOD_AGREEMENT = 3
40+ # Controls how far apart two detections can be while still being counted as the same change.
41+ DETECTION_INDEX_TOLERANCE = 1
42+ # Toggles whether raw repeated measurements are passed to the detectors instead of aggregated values.
4043REPLICATES = False
4144
4245
@@ -297,6 +300,10 @@ def build_cpd_methods():
297300
298301
299302def name_voting_strategy (strategy , cons_th , margin , replicates_enabled , existing_name = None ):
303+ """
304+ Builds a string label encoding the active voting configuration, used to tag
305+ alerts with the strategy that produced them.
306+ """
300307 if existing_name is not None :
301308 return existing_name
302309 suffix = "replicates_enabled" if replicates_enabled else "replicates_not_enabled"
@@ -320,18 +327,19 @@ def vote(
320327):
321328 """
322329 Apply voting logic to determine which alerts to create based on multiple detection methods.
330+ Each strategy returns a list of (weighted_index, prev_index, methods_data) tuples and
331+ a detection method naming string. Alert creation is handled here to ensure exactly one
332+ alert is created per agreed-upon change point regardless of which strategy is used.
323333 """
324334 if strategy == "equal" :
325- equal_voting_strategy (
326- signature = signature ,
335+ detections , detection_method_naming = equal_voting_strategy (
327336 analyzed_series = analyzed_series ,
328337 cons_th = cons_th ,
329338 margin = margin ,
330339 replicates_enabled = replicates_enabled ,
331340 )
332341 elif strategy == "priority" :
333- priority_voting_strategy (
334- signature = signature ,
342+ detections , detection_method_naming = priority_voting_strategy (
335343 analyzed_series = analyzed_series ,
336344 cons_th = cons_th ,
337345 margin = margin ,
@@ -340,6 +348,19 @@ def vote(
340348 else :
341349 raise ValueError (f"Unknown voting strategy: { strategy } " )
342350
351+ for weighted_index , prev_index , methods_data in detections :
352+ cur = analyzed_series [weighted_index ]
353+ prev = analyzed_series [prev_index ]
354+ create_alert (
355+ signature ,
356+ analyzed_series ,
357+ prev ,
358+ cur ,
359+ weighted_index ,
360+ methods_data ,
361+ detection_method_naming ,
362+ )
363+
343364
344365def get_methods_detecting_at_index (analyzed_series , index , margin = 2 ):
345366 """
@@ -407,33 +428,33 @@ def get_weighted_average_push(analyzed_series, methods, start_idx, end_idx):
407428 return weighted_avg_index , prev_index
408429
409430
410- def priority_voting_strategy (
411- signature , analyzed_series , cons_th = 3 , margin = 1 , replicates_enabled = False
412- ):
431+ def priority_voting_strategy (analyzed_series , cons_th = 3 , margin = 1 , replicates_enabled = False ):
413432 """
414433 Priority voting strategy where student method has voting priority.
434+ Returns a list of (weighted_index, prev_index, methods_data) tuples and a naming string.
415435 """
416436 if not analyzed_series or len (analyzed_series ) < 2 :
417- return
437+ return [], name_voting_strategy ( "priority" , cons_th , margin , replicates_enabled )
418438
419439 all_methods = build_cpd_methods ().keys ()
420440 detection_method_naming = name_voting_strategy ("priority" , cons_th , margin , replicates_enabled )
421441
422- # Track which indices we've already created alerts for (to avoid duplicates
442+ detections = []
443+ # Track which indices we've already added detections for (to avoid duplicates
423444 # in both Phase 1 and the fallback equal strategy)
424445 alerted_indices = set ()
425446
426447 # Phase 1: Student detections (no margin tolerance)
427448 for i in range (1 , len (analyzed_series )):
449+ # This prevents duplicate alerts from being raised for the same underlying change event
450+ # since different detection methods may pinpoint it at slightly different indices.
428451 if any (abs (i - alerted_idx ) <= margin for alerted_idx in alerted_indices ):
429452 continue
430453
431454 cur = analyzed_series [i ]
432455
433456 if cur .change_detected .get ("student" , False ):
434- prev = analyzed_series [i - 1 ]
435-
436- # Collect ALL methods detecting at this exact index (not within margin) to include in alert details, but do not require them for the alert to be created
457+ prev_index = i - 1
437458 methods_data = {}
438459 for method in all_methods :
439460 if cur .change_detected .get (method , False ):
@@ -442,44 +463,48 @@ def priority_voting_strategy(
442463 confidence_value = 1000
443464
444465 methods_data [method ] = {"push_id" : cur .push_id , "confidence" : confidence_value }
445- create_alert (
446- signature , analyzed_series , prev , cur , i , methods_data , detection_method_naming
447- )
466+
467+ detections . append (( i , prev_index , methods_data ))
468+ alerted_indices . add ( i )
448469
449470 # Phase 2: Fall back to equal strategy for indices not caught by Student
450471 # Student won't influence the vote here since change_detected["student"]
451472 # is False for all remaining candidates
452- equal_voting_strategy (
453- signature = signature ,
473+ equal_detections , _ = equal_voting_strategy (
454474 analyzed_series = analyzed_series ,
455475 cons_th = cons_th ,
456476 margin = margin ,
457477 alerted_indices = alerted_indices ,
458- detection_method_naming = detection_method_naming ,
459478 replicates_enabled = replicates_enabled ,
460479 )
480+ detections .extend (equal_detections )
481+
482+ return detections , detection_method_naming
461483
462484
463485def equal_voting_strategy (
464- signature ,
465486 analyzed_series ,
466487 cons_th = 3 ,
467- margin = 2 ,
488+ margin = 1 ,
468489 alerted_indices = None ,
469490 detection_method_naming = None ,
470491 replicates_enabled = False ,
471492):
472493 """
473494 Equal voting strategy where all methods have equal weight.
495+ Returns a list of (weighted_index, prev_index, methods_data) tuples and a naming string.
474496 """
475497 if not analyzed_series or len (analyzed_series ) < 2 :
476- return
498+ return [], name_voting_strategy (
499+ "equal" , cons_th , margin , replicates_enabled , detection_method_naming
500+ )
477501
478- # Track which indices we've already created alerts for (to avoid duplicates)
479502 detection_method_naming = name_voting_strategy (
480503 "equal" , cons_th , margin , replicates_enabled , detection_method_naming
481504 )
482505 alerted_indices = alerted_indices if alerted_indices is not None else set ()
506+ detections = []
507+
483508 for i in range (1 , len (analyzed_series )):
484509 # Skip if we've already created an alert near this index
485510 if any (abs (i - alerted_idx ) <= margin for alerted_idx in alerted_indices ):
@@ -498,20 +523,11 @@ def equal_voting_strategy(
498523 )
499524
500525 if weighted_index is not None :
501- cur = analyzed_series [weighted_index ]
502- prev = analyzed_series [prev_index ]
503-
504- create_alert (
505- signature ,
506- analyzed_series ,
507- prev ,
508- cur ,
509- weighted_index ,
510- methods_detecting_data ,
511- detection_method_naming ,
512- )
526+ detections .append ((weighted_index , prev_index , methods_detecting_data ))
513527 alerted_indices .add (weighted_index )
514528
529+ return detections , detection_method_naming
530+
515531
516532def create_alert (
517533 signature ,
@@ -621,7 +637,11 @@ def create_alert(
621637
622638
623639def generate_new_test_alerts_in_series (
624- signature , strategy = STRATEGY , cons_th = CONS_TH , margin = MARGIN , replicates_enabled = REPLICATES
640+ signature ,
641+ strategy = VOTING_STRATEGY ,
642+ cons_th = MIN_METHOD_AGREEMENT ,
643+ margin = DETECTION_INDEX_TOLERANCE ,
644+ replicates_enabled = REPLICATES ,
625645):
626646 # get series data starting from either:
627647 # (1) the last alert, if there is one
0 commit comments