1+ import datetime as dt
12import logging
2- import math
3- import statistics
4- from typing import Any , Counter , Dict , List , Literal , Optional , Tuple , TypedDict
3+ from dataclasses import dataclass
4+ from typing import Any , Counter , Dict , List , Literal , Optional , TypedDict
55
6- from common .benchmark_time_series_api_model import BenchmarkTimeSeriesApiData
6+ from common .benchmark_time_series_api_model import (
7+ BenchmarkTimeSeriesApiData ,
8+ BenchmarkTimeSeriesItem ,
9+ )
710from common .config_model import BenchmarkConfig , RegressionPolicy
811from dateutil .parser import isoparse
912
1518]
1619
1720
18- class BaselineItem (TypedDict ):
21+ class TimeSeriesDataMetaInfo (TypedDict ):
22+ commit : str
23+ branch : str
24+ timestamp : str
25+ workflow_id : str
26+
27+
28+ class TimeSeriesMetaInfo (TypedDict ):
29+ start : TimeSeriesDataMetaInfo
30+ end : TimeSeriesDataMetaInfo
31+
32+
33+ @dataclass
34+ class BenchmarkRegressionSummary (TypedDict ):
35+ total_count : int
36+ regression_count : int
37+ suspicious_count : int
38+ no_regression_count : int
39+ insufficient_data_count : int
40+ is_regression : int
41+
42+
43+ class BaselineResult (TypedDict ):
1944 group_info : Dict [str , Any ]
45+ orignal_item : Dict [str , Any ]
2046 value : float
2147
2248
@@ -27,37 +53,51 @@ class BenchmarkValueItem(TypedDict):
2753
2854class PerGroupResult (TypedDict , total = True ):
2955 group_info : Dict [str , Any ]
30- baseline : Optional [float ]
56+ baseline_item : Optional [Dict [ str , Any ] ]
3157 points : List [Any ]
3258 label : RegressionClassifyLabel
3359 policy : Optional ["RegressionPolicy" ]
3460
3561
36- def percentile (values : list [float ], q : float ):
37- v = sorted (values )
38- k = (len (v ) - 1 ) * q
39- f = math .floor (k )
40- c = math .ceil (k )
41- if f == c :
42- return v [int (k )]
43- return v [f ] + (v [c ] - v [f ]) * (k - f )
62+ class BenchmarkRegressionReport (TypedDict ):
63+ summary : BenchmarkRegressionSummary
64+ results : List [PerGroupResult ]
65+ baseline_meta_data : TimeSeriesMetaInfo
66+ new_meta_data : TimeSeriesMetaInfo
67+
68+
69+ def get_regression_status (regression_summary : BenchmarkRegressionSummary ) -> str :
70+ status = (
71+ "regression"
72+ if regression_summary .get ("regression_count" , 0 ) > 0
73+ else "suspicious"
74+ if regression_summary .get ("suspicious_count" , 0 ) > 0
75+ else "no_regression"
76+ )
77+ return status
4478
4579
4680class BenchmarkRegressionReportGenerator :
4781 def __init__ (
4882 self ,
4983 config : BenchmarkConfig ,
50- latest_ts : BenchmarkTimeSeriesApiData ,
84+ target_ts : BenchmarkTimeSeriesApiData ,
5185 baseline_ts : BenchmarkTimeSeriesApiData ,
5286 ) -> None :
5387 self .metric_policies = config .policy .metrics
54- self .latest_ts = self ._to_data_map (latest_ts )
55- self .baseline_raw = self ._to_data_map (baseline_ts )
88+ self .baseline_ts_info = self ._get_meta_info (baseline_ts .time_series )
89+ self .lastest_ts_info = self ._get_meta_info (target_ts .time_series )
90+ self .target_ts = self ._to_data_map (target_ts )
91+ self .baseline_ts = self ._to_data_map (baseline_ts )
92+
93+ def generate (self ) -> BenchmarkRegressionReport :
94+ if not self .baseline_ts or not self .target_ts :
95+ logger .warning ("No baseline or target data found" )
96+ raise ValueError ("No baseline or target data found" )
5697
57- def generate (self ) -> Tuple [List [PerGroupResult ], Dict [str , Any ]]:
5898 return self .detect_regressions_with_policies (
59- self .baseline_raw ,
60- self .latest_ts ,
99+ self .baseline_ts ,
100+ self .target_ts ,
61101 metric_policies = self .metric_policies ,
62102 )
63103
@@ -68,11 +108,11 @@ def detect_regressions_with_policies(
68108 * ,
69109 metric_policies : Dict [str , RegressionPolicy ],
70110 min_points : int = 2 ,
71- ) -> Tuple [ List [ PerGroupResult ], Dict [ str , Any ]] :
111+ ) -> BenchmarkRegressionReport :
72112 """
73113 For each dp_map:
74114 - choose policy based on targeting metric from group_info['metric'] (ex passrate, geomean ..)
75- - calculate baseline value based on policy.baseline_aggregation (ex mean, p90, max, min, latest , p50, p95)
115+ - calculate baseline value based on policy.baseline_aggregation (ex mean, p90, max, min, target , p50, p95)
76116 - use baseline value to generate violation flag list for each point, using policy.is_violation(value, baseline)
77117 - classify with labels to detect regression, using self.classify_flags(flags, min_points)
78118 Returns a list of Regression result {group_info, baseline, values, flags, label, policy}
@@ -87,11 +127,11 @@ def detect_regressions_with_policies(
87127
88128 base_item = baseline_map .get (key )
89129 if not base_item :
90- logger .warning ("Skip. No baseline item found for %s" , gi )
130+ logger .warning ("Skip. No baseline item found for %s" , key )
91131 results .append (
92132 PerGroupResult (
93133 group_info = gi ,
94- baseline = None ,
134+ baseline_item = None ,
95135 points = [],
96136 label = "insufficient_data" ,
97137 policy = None ,
@@ -104,65 +144,79 @@ def detect_regressions_with_policies(
104144 results .append (
105145 PerGroupResult (
106146 group_info = gi ,
107- baseline = None ,
147+ baseline_item = None ,
108148 points = [],
109149 label = "insufficient_data" ,
110150 policy = None ,
111151 )
112152 )
113153 continue
114-
115154 baseline_aggre_mode = policy .baseline_aggregation
116- baseline_value = self ._get_baseline (base_item , baseline_aggre_mode )
117- if baseline_value is None or len (points ) == 0 :
155+ baseline_result = self ._get_baseline (base_item , baseline_aggre_mode )
156+ if (
157+ not baseline_result
158+ or not baseline_result ["orignal_item" ]
159+ or len (points ) == 0
160+ ):
118161 logger .warning (
119- "baseline_value is %s, len(points) == %s" ,
120- baseline_value ,
162+ "No valid baseline result found, baseline_item is %s, len(points) == %s" ,
163+ baseline_result ,
121164 len (points ),
122165 )
123166 results .append (
124167 PerGroupResult (
125168 group_info = gi ,
126- baseline = None ,
169+ baseline_item = None ,
127170 points = [],
128171 label = "insufficient_data" ,
129172 policy = policy ,
130173 )
131174 )
132175 continue
133176
177+ orignal_baseline_obj = baseline_result ["orignal_item" ]
178+
134179 # Per-point violations (True = regression)
135180 flags : List [bool ] = [
136- policy .is_violation (p ["value" ], baseline_value ["value" ]) for p in points
181+ policy .is_violation (p ["value" ], baseline_result ["value" ])
182+ for p in points
137183 ]
138184 label = self .classify_flags (flags , min_points = min_points )
139185
140186 enriched_points = [{** p , "flag" : f } for p , f in zip (points , flags )]
141187 results .append (
142188 PerGroupResult (
143189 group_info = gi ,
144- baseline = baseline_value [ "value" ] ,
190+ baseline_item = orignal_baseline_obj ,
145191 points = enriched_points ,
146192 label = label ,
147193 policy = policy ,
148194 )
149195 )
150-
151196 logger .info ("Done. Generated %s regression results" , len (results ))
152197 summary = self .summarize_label_counts (results )
153- return results , summary
154198
155- def summarize_label_counts (self , results : list [PerGroupResult ]):
199+ return BenchmarkRegressionReport (
200+ summary = summary ,
201+ results = results ,
202+ baseline_meta_data = self .baseline_ts_info ,
203+ new_meta_data = self .lastest_ts_info ,
204+ )
205+
206+ def summarize_label_counts (
207+ self , results : list [PerGroupResult ]
208+ ) -> BenchmarkRegressionSummary :
156209 counts = Counter (self ._label_str (r ["label" ]) for r in results )
157210 total_count = len (results )
158- return {
211+ summmary : BenchmarkRegressionSummary = {
159212 "total_count" : total_count ,
160213 "regression_count" : counts .get ("regression" , 0 ),
161214 "suspicious_count" : counts .get ("suspicious" , 0 ),
162215 "no_regression_count" : counts .get ("no_regression" , 0 ),
163216 "insufficient_data_count" : counts .get ("insufficient_data" , 0 ),
164217 "is_regression" : int (counts .get ("regression" , 0 ) > 0 ),
165218 }
219+ return summmary
166220
167221 def _label_str (self , x ) -> str :
168222 # Robust: works for str or Enum-like labels
@@ -202,39 +256,33 @@ def _to_data_map(
202256 def _get_baseline (
203257 self ,
204258 data : BenchmarkValueItem ,
205- mode : str = "mean " ,
259+ mode : str = "max " ,
206260 field : str = "value" ,
207- ) -> Optional [BaselineItem ]:
261+ ) -> Optional [BaselineResult ]:
208262 """
209263 calculate the baseline value based on the mode
210- mode: mean, p90, max, min, latest , p50, p95
264+ mode: mean, p90, max, min, target , p50, p95
211265 """
212- values = [float ( d [ field ]) for d in data ["values" ] if field in d ]
213- if not values :
266+ items = [d for d in data ["values" ] if field in d ]
267+ if not items :
214268 return None
215269
216- if mode == "mean" :
217- val = statistics .fmean (values )
218- elif mode == "p90" :
219- val = percentile (values , 0.9 )
220- elif mode == "max" :
221- val = max (values )
270+ if mode == "max" :
271+ baseline_obj = max (items , key = lambda d : float (d [field ]))
222272 elif mode == "min" :
223- val = min (values )
224- elif mode == "latest " :
225- val = values [- 1 ]
273+ baseline_obj = min (items , key = lambda d : float ( d [ field ]) )
274+ elif mode == "target " :
275+ baseline_obj = items [- 1 ]
226276 elif mode == "earliest" :
227- val = values [0 ]
228- elif mode == "p50" :
229- val = percentile (values , 0.5 )
230- elif mode == "p95" :
231- val = percentile (values , 0.95 )
277+ baseline_obj = items [0 ]
232278 else :
233279 logger .warning ("Unknown mode: %s" , mode )
234280 return None
235- result : BaselineItem = {
281+
282+ result : BaselineResult = {
236283 "group_info" : data ["group_info" ],
237- "value" : val ,
284+ "value" : float (baseline_obj [field ]),
285+ "orignal_item" : baseline_obj ,
238286 }
239287 return result
240288
@@ -294,3 +342,34 @@ def _resolve_policy(
294342 return None
295343 m = metric .lower ()
296344 return metric_policies .get (m )
345+
346+ def _get_meta_info (
347+ self ,
348+ time_series : List [BenchmarkTimeSeriesItem ],
349+ ) -> TimeSeriesMetaInfo :
350+ pts = [p for s in time_series for p in s .data ]
351+ end_data = max (
352+ pts ,
353+ key = lambda p : dt .datetime .fromisoformat (
354+ p ["granularity_bucket" ].replace ("Z" , "+00:00" )
355+ ),
356+ )
357+ start_data = min (
358+ pts ,
359+ key = lambda p : dt .datetime .fromisoformat (
360+ p ["granularity_bucket" ].replace ("Z" , "+00:00" )
361+ ),
362+ )
363+ end : TimeSeriesDataMetaInfo = {
364+ "commit" : end_data .get ("commit" , "" ),
365+ "branch" : end_data .get ("branch" , "" ),
366+ "timestamp" : end_data .get ("granularity_bucket" , "" ),
367+ "workflow_id" : end_data .get ("workflow_id" , "" ),
368+ }
369+ start : TimeSeriesDataMetaInfo = {
370+ "commit" : start_data .get ("commit" , "" ),
371+ "branch" : start_data .get ("branch" , "" ),
372+ "timestamp" : start_data .get ("granularity_bucket" , "" ),
373+ "workflow_id" : start_data .get ("workflow_id" , "" ),
374+ }
375+ return {"start" : start , "end" : end }
0 commit comments