1010 5. Store benchmark_run in DuckDB
1111 6. Return comparison report
1212
13- Classical head uses the same 16 -dimensional feature vector as the
13+ Classical head uses the same 24 -dimensional feature vector as the
1414quantum head — a weighted linear threshold on the feature dimensions
1515most predictive of anomalous activity. This ensures apples-to-apples
1616comparison: neither head has access to information the other doesn't.
3333
3434
3535# ── Classical feature-vector classifier ───────────────────────────
36- # Weighted linear combination of the 16 feature dimensions.
37- # Weights reflect empirical separation between benign and anomaly
38- # classes observed in the demo dataset analysis.
39- #
40- # Dimension index → weight mapping:
41- # 0 source_ip_is_private -0.5 (public IP = more suspicious for scans)
42- # 1 source_ip_oct3_norm 0.1
43- # 2 source_ip_oct4_norm 0.2
44- # 3 source_ip_entropy 0.1
45- # 4 severity_norm 1.5 (strong signal)
46- # 5 severity_is_high 2.0 (strongest signal)
47- # 6 process_risk 1.2
48- # 7 process_is_auth 0.8
49- # 8 process_is_network 0.8 (raised: kernel DROP = network process)
50- # 9 message_has_fail 1.0
51- # 10 message_has_auth 0.6
52- # 11 message_has_exploit 1.3
53- # 12 message_has_scan 1.8 (raised: primary port scan signal)
54- # 13 repeat_source 0.9
55- # 14 hour_of_day_norm 0.0
56- # 15 is_off_hours 0.4
36+ # Weighted linear combination of the 24 feature dimensions (v2).
5737
5838_FV_WEIGHTS = [
5939 # ── v1 base dimensions (0-15) ──────────────────────────────────
8666
8767# Decision threshold — optimised for v2 24-dim vector
8868# F1=0.989, Acc=99%, FPR=1.8%, 0 FN on 100-event demo dataset
89- # One persistent FP: legitimate admin SSH session (benign_auth, score=5.71)
90- # — structurally indistinguishable from brute force at feature level
9169_FV_THRESHOLD = 4.974
9270
9371
@@ -102,20 +80,22 @@ def _parse_feature_vector(event: dict) -> list[float]:
10280 """Extract feature vector from event dict, handling JSON string storage."""
10381 fv = event .get ("feature_vector" )
10482 if fv is None :
105- return [0.0 ] * 16
83+ return [0.0 ] * 24
10684 if isinstance (fv , str ):
10785 try :
10886 fv = json .loads (fv )
10987 except Exception :
110- return [0.0 ] * 16
88+ try :
89+ import ast
90+ fv = ast .literal_eval (fv )
91+ except Exception :
92+ return [0.0 ] * 24
11193 if isinstance (fv , list ):
11294 return [float (x ) for x in fv ]
113- return [0.0 ] * 16
95+ return [0.0 ] * 24
11496
11597
116- # ── Module-level decoder singleton ───────────────────────────────
117- # Shared across all BenchmarkComparator instances in the same process
118- # to avoid reloading model weights on every benchmark run.
98+ # ── Module-level decoder singleton ────────────────────────────────
11999_shared_decoder = None
120100
121101def _get_shared_decoder ():
@@ -138,35 +118,27 @@ def __init__(
138118 scenario_name : str = "" ,
139119 noise_enabled : Optional [bool ] = None ,
140120 backend : Optional [str ] = None ,
121+ progress_cb : object = None ,
122+ run_idx : int = 0 ,
141123 ):
142124 self .circuit_type = circuit_type
143125 self .scenario_name = scenario_name or f"benchmark_{ datetime .now ().strftime ('%Y%m%d_%H%M%S' )} "
144- self .q_runner = QuantumRunner (circuit_type = circuit_type ,
145- decoder = _get_shared_decoder (),
146- noise_enabled = noise_enabled ,
147- backend = backend )
126+ self .progress_cb = progress_cb
127+ self .run_idx = run_idx
128+ self .q_runner = QuantumRunner (
129+ circuit_type = circuit_type ,
130+ decoder = _get_shared_decoder (),
131+ noise_enabled = noise_enabled ,
132+ backend = backend ,
133+ )
148134
149135 def run (
150136 self ,
151- events : list [dict ],
152- ground_truth : list [int ],
137+ events : list [dict ],
138+ ground_truth : list [int ],
153139 classical_results : Optional [list [dict ]] = None ,
154- notes : str = "" ,
140+ notes : str = "" ,
155141 ) -> dict :
156- """
157- Execute a full benchmark run.
158-
159- Args:
160- events: List of normalised ECS events
161- ground_truth: Binary labels (1=anomaly, 0=benign)
162- Must match length of events
163- classical_results: Pre-computed classical results.
164- If None, uses feature-vector classifier.
165- notes: Optional notes stored with the run
166-
167- Returns:
168- Full comparison report dict
169- """
170142 if len (events ) != len (ground_truth ):
171143 raise ValueError (
172144 f"events ({ len (events )} ) and ground_truth "
@@ -175,7 +147,7 @@ def run(
175147 if not events :
176148 raise ValueError ("events list cannot be empty" )
177149
178- # ── Classical head ────────────────────────────────────────
150+ # ── Classical head ─────────────────────────────────────────
179151 if classical_results :
180152 c_predictions = [r .get ("prediction" , 0 ) for r in classical_results ]
181153 c_latencies = [r .get ("latency_ms" , 0.0 ) for r in classical_results ]
@@ -186,82 +158,58 @@ def run(
186158 c_predictions , ground_truth , c_latencies , label = "classical"
187159 )
188160
189- # ── Quantum head ──────────────────────────────────────────
161+ # ── Quantum head ───────────────────────────────────────────
190162 q_predictions = []
191163 q_latencies = []
192164
193- for event in events :
165+ for _ev_idx , event in enumerate ( events ) :
194166 result = self .q_runner .run (event )
195167 q_predictions .append (result ["prediction" ])
196168 q_latencies .append (result ["latency_ms" ])
169+ if self .progress_cb :
170+ try :
171+ self .progress_cb (self .run_idx , _ev_idx , len (events ))
172+ except Exception :
173+ pass
197174
198175 quantum_metrics = compute_metrics (
199176 q_predictions , ground_truth , q_latencies , label = "quantum"
200177 )
201178
202- # ── Compare ─────── ────────────────────────────────────────
179+ # ── Compare & store ────────────────────────────────────────
203180 comparison = compare_metrics (classical_metrics , quantum_metrics )
181+ run_id = self ._store_benchmark (classical_metrics , quantum_metrics , events , notes )
204182
205- # ── Store ─────────────────────────────────────────────────
206- run_id = self ._store_benchmark (
207- classical_metrics , quantum_metrics ,
208- events , notes
209- )
210-
211- comparison ["run_id" ] = run_id
212- comparison ["scenario_name" ] = self .scenario_name
213- comparison ["n_events" ] = len (events )
214- comparison ["circuit_type" ] = self .circuit_type
215- comparison ["created_at" ] = datetime .now (timezone .utc ).isoformat ()
183+ comparison ["run_id" ] = run_id
184+ comparison ["scenario_name" ] = self .scenario_name
185+ comparison ["n_events" ] = len (events )
186+ comparison ["circuit_type" ] = self .circuit_type
187+ comparison ["created_at" ] = datetime .now (timezone .utc ).isoformat ()
216188 comparison ["classical_predictions" ] = c_predictions
217189 comparison ["quantum_predictions" ] = q_predictions
218190
219191 return comparison
220192
221- # ── Classifiers ───────────────────────────────────────────────
222-
223- def _fv_classifier (
224- self ,
225- events : list [dict ],
226- ) -> tuple [list [int ], list [float ]]:
227- """
228- Feature-vector weighted linear classifier.
229- Uses the same 16-dimensional feature vector as the quantum head.
230- Predicts anomaly (1) if weighted score >= _FV_THRESHOLD.
231- Returns (predictions, latencies_ms).
232- """
193+ def _fv_classifier (self , events : list [dict ]) -> tuple [list [int ], list [float ]]:
233194 import time
234- predictions = []
235- latencies = []
236-
195+ predictions , latencies = [], []
237196 for event in events :
238197 start = time .perf_counter ()
239198 fv = _parse_feature_vector (event )
240199 score = _fv_score (fv )
241200 predictions .append (1 if score >= _FV_THRESHOLD else 0 )
242201 latencies .append ((time .perf_counter () - start ) * 1000 )
243-
244202 return predictions , latencies
245203
246- def _severity_classifier (
247- self ,
248- events : list [dict ],
249- ) -> tuple [list [int ], list [float ]]:
250- """
251- Legacy severity-threshold classifier. Kept for reference.
252- Predicts anomaly (1) if event_severity >= 50.
253- NOT used in benchmark — replaced by _fv_classifier.
254- """
204+ def _severity_classifier (self , events : list [dict ]) -> tuple [list [int ], list [float ]]:
205+ """Legacy severity-threshold classifier — not used in benchmark."""
255206 import time
256- predictions = []
257- latencies = []
258-
207+ predictions , latencies = [], []
259208 for event in events :
260209 start = time .perf_counter ()
261210 sev = event .get ("event_severity" ) or 0
262211 predictions .append (1 if sev >= 50 else 0 )
263212 latencies .append ((time .perf_counter () - start ) * 1000 )
264-
265213 return predictions , latencies
266214
267215 def _store_benchmark (
@@ -271,10 +219,8 @@ def _store_benchmark(
271219 events : list [dict ],
272220 notes : str ,
273221 ) -> str :
274- """Store benchmark run in DuckDB. Returns run ID."""
275222 run_id = str (uuid .uuid4 ())
276223 event_ids = [e .get ("id" , "" ) for e in events ]
277-
278224 con = get_connection ()
279225 con .execute ("""
280226 INSERT OR REPLACE INTO benchmark_runs (
@@ -287,16 +233,10 @@ def _store_benchmark(
287233 notes
288234 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
289235 """ , [
290- run_id ,
291- datetime .now (timezone .utc ),
292- self .scenario_name ,
293- event_ids ,
294- classical .accuracy , classical .f1 ,
295- classical .fpr , classical .avg_latency_ms ,
296- quantum .accuracy , quantum .f1 ,
297- quantum .fpr , quantum .avg_latency_ms ,
298- classical .efficacy , quantum .efficacy ,
299- notes ,
236+ run_id , datetime .now (timezone .utc ), self .scenario_name , event_ids ,
237+ classical .accuracy , classical .f1 , classical .fpr , classical .avg_latency_ms ,
238+ quantum .accuracy , quantum .f1 , quantum .fpr , quantum .avg_latency_ms ,
239+ classical .efficacy , quantum .efficacy , notes ,
300240 ])
301241 con .close ()
302242 return run_id
@@ -309,11 +249,10 @@ def quick_benchmark(
309249 scenario : str = "" ,
310250) -> dict :
311251 """Convenience function for a one-line benchmark run."""
312- comparator = BenchmarkComparator (
252+ return BenchmarkComparator (
313253 circuit_type = circuit_type ,
314254 scenario_name = scenario ,
315- )
316- return comparator .run (events , ground_truth )
255+ ).run (events , ground_truth )
317256
318257
319258def multi_run_benchmark (
@@ -325,11 +264,11 @@ def multi_run_benchmark(
325264 classical_results : list [dict ] | None = None ,
326265 noise_enabled : bool | None = None ,
327266 backend : str | None = None ,
267+ progress_cb : object = None ,
328268) -> dict :
329269 """
330- Run benchmark N times and return averaged results with
331- standard deviation — giving statistically meaningful results
332- at low qubit counts where single-run variance is high.
270+ Run benchmark N times and return averaged results with std dev.
271+ progress_cb(run_idx, event_idx, total_events) called per event.
333272 """
334273 if n_runs < 1 :
335274 raise ValueError ("n_runs must be >= 1" )
@@ -341,6 +280,8 @@ def multi_run_benchmark(
341280 scenario_name = f"{ scenario } _run{ i + 1 } " if scenario else f"run{ i + 1 } " ,
342281 noise_enabled = noise_enabled ,
343282 backend = backend ,
283+ progress_cb = progress_cb ,
284+ run_idx = i ,
344285 )
345286 report = comp .run (events , ground_truth , classical_results = classical_results )
346287 runs .append (report )
@@ -356,7 +297,6 @@ def agg(key: str, subkey: str) -> dict:
356297 "max" : round (max (vals ), 4 ),
357298 }
358299
359- # Aggregate predictions across runs (majority vote per event)
360300 def majority_preds (key : str ) -> list [int ]:
361301 all_preds = [r .get (key , []) for r in runs ]
362302 if not all_preds or not all_preds [0 ]:
@@ -369,10 +309,10 @@ def majority_preds(key: str) -> list[int]:
369309 return result
370310
371311 return {
372- "n_runs" : n_runs ,
373- "circuit_type" : circuit_type ,
374- "scenario" : scenario ,
375- "n_events" : len (events ),
312+ "n_runs" : n_runs ,
313+ "circuit_type" : circuit_type ,
314+ "scenario" : scenario ,
315+ "n_events" : len (events ),
376316 "classical" : {
377317 "efficacy" : agg ("classical" , "efficacy" ),
378318 "accuracy" : agg ("classical" , "accuracy" ),
@@ -400,7 +340,6 @@ def majority_preds(key: str) -> list[int]:
400340
401341
402342def _determine_overall_winner (runs : list [dict ]) -> str :
403- """Determine winner across multiple runs by average efficacy delta."""
404343 import statistics
405344 deltas = [r ["delta" ]["efficacy" ] for r in runs ]
406345 avg_delta = statistics .mean (deltas )
0 commit comments