Skip to content

Commit a0455af

Browse files
Polishing the voting logic
1 parent 6d7ded9 commit a0455af

4 files changed

Lines changed: 124 additions & 91 deletions

File tree

tests/perfalert/test_methods_alerts.py

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import datetime
2+
import sys
23
import time
34

4-
import treeherder.perf.alerts as alerts_module
55
from treeherder.model.models import Push
66
from treeherder.perf.alerts import (
77
build_cpd_methods,
8+
create_alert,
89
detect_methods_changes,
9-
equal_voting_strategy,
1010
generate_new_test_alerts_in_series,
1111
get_methods_detecting_at_index,
1212
get_weighted_average_push,
1313
name_voting_strategy,
14+
vote,
1415
)
1516
from treeherder.perf.models import (
1617
PerformanceAlertSummaryTesting,
@@ -519,12 +520,10 @@ def test_margin_deduplication_guard_suppresses_nearby_duplicate_alerts(
519520
monkeypatch,
520521
):
521522
"""
522-
Verifies the deduplication guard in equal_voting_strategy (alerts.py in equal_voting_strategy and priority_voting_strategy functions):
523-
if any(abs(i - alerted_idx) <= margin for alerted_idx in alerted_indices):
524-
continue
523+
Verifies the deduplication guard in equal_voting_strategy suppresses duplicate
524+
create_alert calls for adjacent indices near the same detected regression.
525525
Compares create_alert call counts between the real implementation (guard active)
526-
and a guard-free copy. The guard must suppress at least one redundant call for
527-
adjacent indices near the detected regression.
526+
and a guard-free copy.
528527
Note: DB row counts cannot be used here because update_or_create silently
529528
merges duplicate calls with the same push_id regardless of the guard.
530529
"""
@@ -557,60 +556,51 @@ def test_margin_deduplication_guard_suppresses_nearby_duplicate_alerts(
557556
test_perf_signature, list(revision_data.values()), build_cpd_methods()
558557
)
559558

560-
def equal_without_guard(
561-
signature,
562-
analyzed_series,
563-
cons_th=3,
564-
margin=2,
565-
alerted_indices=None,
566-
detection_method_naming=None,
567-
replicates_enabled=False,
568-
):
569-
if not analyzed_series or len(analyzed_series) < 2:
570-
return
571-
detection_method_naming = name_voting_strategy(
572-
"equal", cons_th, margin, replicates_enabled, detection_method_naming
573-
)
574-
alerted_indices = alerted_indices if alerted_indices is not None else set()
559+
_alerts_mod = sys.modules["treeherder.perf.alerts"]
560+
original = create_alert
561+
call_counts = {"with_guard": 0, "without_guard": 0}
562+
563+
def vote_without_guard(signature, analyzed_series, cons_th=3, margin=2):
564+
"""Guard-free version: collects detections without deduplication, then calls create_alert."""
565+
detections = []
566+
detection_method_naming = name_voting_strategy("equal", cons_th, margin, False, None)
575567
for i in range(1, len(analyzed_series)):
576568
methods_detecting_data = get_methods_detecting_at_index(analyzed_series, i, margin)
577569
if len(methods_detecting_data) >= cons_th:
578-
start_idx, end_idx = max(0, i - margin), min(len(analyzed_series) - 1, i + margin)
570+
start_idx = max(0, i - margin)
571+
end_idx = min(len(analyzed_series) - 1, i + margin)
579572
weighted_index, prev_index = get_weighted_average_push(
580573
analyzed_series, methods_detecting_data, start_idx, end_idx
581574
)
582575
if weighted_index is not None:
583-
alerts_module.create_alert(
584-
signature,
585-
analyzed_series,
586-
analyzed_series[prev_index],
587-
analyzed_series[weighted_index],
588-
weighted_index,
589-
methods_detecting_data,
590-
detection_method_naming,
591-
)
592-
alerted_indices.add(weighted_index)
576+
detections.append((weighted_index, prev_index, methods_detecting_data))
577+
for weighted_index, prev_index, methods_data in detections:
578+
_alerts_mod.create_alert(
579+
signature,
580+
analyzed_series,
581+
analyzed_series[prev_index],
582+
analyzed_series[weighted_index],
583+
weighted_index,
584+
methods_data,
585+
detection_method_naming,
586+
)
587+
588+
def make_counter(key):
589+
return lambda *a, **kw: call_counts.__setitem__(key, call_counts[key] + 1) or original(
590+
*a, **kw
591+
)
592+
593+
monkeypatch.setattr(_alerts_mod, "create_alert", make_counter("with_guard"))
594+
vote(test_perf_signature, analyzed_series, strategy="equal", cons_th=cons_th, margin=margin)
593595

594-
call_counts = {"with_guard": 0, "without_guard": 0}
595-
original = alerts_module.create_alert
596-
monkeypatch.setattr(
597-
alerts_module,
598-
"create_alert",
599-
lambda *a, **kw: call_counts.__setitem__("with_guard", call_counts["with_guard"] + 1)
600-
or original(*a, **kw),
601-
)
602-
equal_voting_strategy(test_perf_signature, analyzed_series, cons_th=cons_th, margin=margin)
603596
PerformanceAlertTesting.objects.all().delete()
604597
PerformanceAlertSummaryTesting.objects.all().delete()
605-
monkeypatch.setattr(
606-
alerts_module,
607-
"create_alert",
608-
lambda *a, **kw: call_counts.__setitem__("without_guard", call_counts["without_guard"] + 1)
609-
or original(*a, **kw),
610-
)
611-
equal_without_guard(test_perf_signature, analyzed_series, cons_th=cons_th, margin=margin)
598+
599+
monkeypatch.setattr(_alerts_mod, "create_alert", make_counter("without_guard"))
600+
vote_without_guard(test_perf_signature, analyzed_series, cons_th=cons_th, margin=margin)
601+
612602
assert call_counts["without_guard"] > call_counts["with_guard"], (
613-
f"Deduplication guard missing in equal_voting_strategy (~line 490): "
603+
f"Deduplication guard missing in equal_voting_strategy: "
614604
f"expected fewer create_alert calls with guard ({call_counts['with_guard']}) "
615605
f"than without ({call_counts['without_guard']})."
616606
)

treeherder/perf/alerts.py

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@
3333

3434
logger = 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.
4043
REPLICATES = False
4144

4245

@@ -297,6 +300,10 @@ def build_cpd_methods():
297300

298301

299302
def 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

344365
def 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

463485
def 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

516532
def create_alert(
517533
signature,
@@ -621,7 +637,11 @@ def create_alert(
621637

622638

623639
def 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
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.1.15 on 2026-03-17 17:57
2+
3+
from django.db import migrations, models
4+
5+
import treeherder.perf.models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("perf", "0066_performancealerttesting_detected_changes_and_more"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="performancealerttesting",
17+
name="detected_changes",
18+
field=models.JSONField(
19+
default=treeherder.perf.models.default_detection_methods,
20+
help_text="A JSON object that indicates the confidence of the alert for each detection method used. It has methods detecting changes on the culprit revision or in one of the revisions aorund it, the push_id field indicates the revision where the change was detected for the given method.",
21+
),
22+
),
23+
]

treeherder/perf/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -808,8 +808,8 @@ class PerformanceAlertTesting(PerformanceAlertBase):
808808
new_p95 = models.FloatField(help_text="New P95 value of series after change")
809809

810810
detected_changes = models.JSONField(
811-
help_text="A JSON object that indicates the confidence of the alert for each detection method used."
812-
"It has methods detecting changes on the culprit revision or in one of the revisions aorund it, the push_id"
811+
help_text="A JSON object that indicates the confidence of the alert for each detection method used. "
812+
"It has methods detecting changes on the culprit revision or in one of the revisions aorund it, the push_id "
813813
"field indicates the revision where the change was detected for the given method.",
814814
default=default_detection_methods,
815815
)

0 commit comments

Comments
 (0)