"""Metric calculations shared by deterministic and randomized evaluations.""" from __future__ import annotations import math import random import statistics from collections.abc import Callable, Mapping, Sequence from typing import Any CLASSIFICATION_THRESHOLD_METRICS = ( "precision", "recall", "f1", "specificity", "accuracy", "balanced_accuracy", "matthews_correlation_coefficient", ) def _count(name: str, value: int) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError(f"{name} must be a non-negative integer") return value def classification_metrics( true_positive: int, false_positive: int, false_negative: int, true_negative: int | None = None, *, zero_division: float = 0.0, ) -> dict[str, Any]: """Return confusion counts and derived binary-classification metrics. Static-analysis corpora often do not enumerate all true negatives. In that case ``true_negative`` must remain ``None`` and specificity/FPR are omitted rather than being fabricated from unlabelled source locations. """ tp = _count("true_positive", true_positive) fp = _count("false_positive", false_positive) fn = _count("false_negative", false_negative) tn = None if true_negative is None else _count("true_negative", true_negative) if not math.isfinite(zero_division): raise ValueError("zero_division must be finite") precision_denominator = tp + fp recall_denominator = tp + fn precision_defined = precision_denominator > 0 recall_defined = recall_denominator > 0 precision = tp / precision_denominator if precision_defined else zero_division recall = tp / recall_denominator if recall_defined else zero_division f1_denominator = 2 * tp + fp + fn f1_defined = f1_denominator > 0 f1 = 2 * tp / f1_denominator if f1_defined else zero_division result: dict[str, Any] = { "true_positive": tp, "false_positive": fp, "false_negative": fn, "precision": precision, "recall": recall, "f1": f1, "defined": { "precision": precision_defined, "recall": recall_defined, "f1": f1_defined, }, } if tn is not None: negative_denominator = tn + fp negative_prediction_denominator = tn + fn total = tp + fp + fn + tn specificity_defined = negative_denominator > 0 negative_predictive_value_defined = negative_prediction_denominator > 0 accuracy_defined = total > 0 balanced_accuracy_defined = recall_defined and specificity_defined mcc_denominator = math.sqrt( (tp + fp) * (tp + fn) * (tn + fp) * (tn + fn) ) mcc_defined = mcc_denominator > 0 result.update( { "true_negative": tn, "specificity": ( tn / negative_denominator if specificity_defined else zero_division ), "false_positive_rate": ( fp / negative_denominator if specificity_defined else zero_division ), "negative_predictive_value": ( tn / negative_prediction_denominator if negative_predictive_value_defined else zero_division ), "accuracy": ( (tp + tn) / total if accuracy_defined else zero_division ), "balanced_accuracy": ( (recall + tn / negative_denominator) / 2 if balanced_accuracy_defined else zero_division ), "matthews_correlation_coefficient": ( (tp * tn - fp * fn) / mcc_denominator if mcc_defined else zero_division ), } ) result["defined"].update( { "specificity": specificity_defined, "false_positive_rate": specificity_defined, "negative_predictive_value": negative_predictive_value_defined, "accuracy": accuracy_defined, "balanced_accuracy": balanced_accuracy_defined, "matthews_correlation_coefficient": mcc_defined, } ) return result def distribution_summary(values: Sequence[float]) -> dict[str, float | int]: """Summarize repeated measurements without external statistics packages. The quartile indexing intentionally preserves the historical Fold benchmark convention, keeping existing performance baselines comparable. """ finite = [float(value) for value in values] if any(not math.isfinite(value) for value in finite): raise ValueError("distribution values must be finite") if not finite: return {"n": 0, "median": 0.0, "min": 0.0, "max": 0.0, "iqr": 0.0} ordered = sorted(finite) q1 = ordered[len(ordered) // 4] q3 = ordered[(3 * len(ordered)) // 4] return { "n": len(ordered), "median": round(float(statistics.median(ordered)), 3), "min": round(ordered[0], 3), "max": round(ordered[-1], 3), "iqr": round(q3 - q1, 3), } def bootstrap_interval( values: Sequence[float], *, statistic: Callable[[Sequence[float]], float] = statistics.median, confidence: float = 0.95, resamples: int = 2_000, seed: int = 0, ) -> dict[str, float | int]: """Return a deterministic percentile-bootstrap interval. This is intended for randomized DAST/fuzzing campaigns. It should not be used to manufacture certainty from one or two smoke-test repetitions. """ sample = [float(value) for value in values] if not sample: raise ValueError("bootstrap_interval requires at least one value") if any(not math.isfinite(value) for value in sample): raise ValueError("bootstrap values must be finite") if not 0.0 < confidence < 1.0: raise ValueError("confidence must be between zero and one") if resamples < 1: raise ValueError("resamples must be positive") rng = random.Random(seed) draws = sorted( float(statistic([rng.choice(sample) for _ in sample])) for _ in range(resamples) ) alpha = (1.0 - confidence) / 2.0 low_index = max(0, min(resamples - 1, math.floor(alpha * resamples))) high_index = max( 0, min(resamples - 1, math.ceil((1.0 - alpha) * resamples) - 1) ) return { "estimate": float(statistic(sample)), "low": draws[low_index], "high": draws[high_index], "confidence": confidence, "resamples": resamples, "seed": seed, } def aggregate_classification_metrics( slices: Mapping[str, Mapping[str, int | None]], ) -> dict[str, Any]: """Return per-slice, micro, and macro classification measurements. A pooled score can hide a weak detector/corpus stratum behind a large easy stratum. This helper preserves every count, computes the micro aggregate by summing counts, and computes each macro ratio only across slices where that ratio is actually defined. True negatives remain unavailable unless every slice explicitly enumerates them. """ per_slice: dict[str, dict[str, Any]] = {} total_tp = total_fp = total_fn = 0 total_tn = 0 all_have_tn = True for name in sorted(slices): row = slices[name] tp = _count(f"{name}.true_positive", row.get("true_positive", 0)) fp = _count(f"{name}.false_positive", row.get("false_positive", 0)) fn = _count(f"{name}.false_negative", row.get("false_negative", 0)) raw_tn = row.get("true_negative") tn = None if raw_tn is None else _count(f"{name}.true_negative", raw_tn) per_slice[name] = classification_metrics(tp, fp, fn, tn) total_tp += tp total_fp += fp total_fn += fn if tn is None: all_have_tn = False else: total_tn += tn micro = classification_metrics( total_tp, total_fp, total_fn, total_tn if all_have_tn and per_slice else None, ) ratio_names = ( "precision", "recall", "f1", "specificity", "false_positive_rate", "negative_predictive_value", "accuracy", "balanced_accuracy", "matthews_correlation_coefficient", ) macro: dict[str, float] = {} macro_support: dict[str, int] = {} for metric in ratio_names: values = [ float(row[metric]) for row in per_slice.values() if metric in row and row.get("defined", {}).get(metric, False) ] if values: macro[metric] = sum(values) / len(values) macro_support[metric] = len(values) return { "slices": per_slice, "micro": micro, "macro": macro, "macro_support": macro_support, } def paired_comparison( baseline: Sequence[float], candidate: Sequence[float], *, lower_is_better: bool = True, ) -> dict[str, Any]: """Compare paired trials with an effect size and exact sign test. Pairing is by seed/target/trial identity. The common-language effect is the fraction of non-tied pairs where the candidate wins; the two-sided sign test is dependency-free and makes no distributional assumption. This is a useful minimum for DAST/fuzz comparisons, not a substitute for enough independent trials or a predeclared experiment design. """ before = [float(value) for value in baseline] after = [float(value) for value in candidate] if not before or len(before) != len(after): raise ValueError("paired samples must be non-empty and have equal length") if any(not math.isfinite(value) for value in (*before, *after)): raise ValueError("paired samples must be finite") deltas = [right - left for left, right in zip(before, after, strict=True)] better = [ delta < 0 if lower_is_better else delta > 0 for delta in deltas ] worse = [ delta > 0 if lower_is_better else delta < 0 for delta in deltas ] wins = sum(better) losses = sum(worse) ties = len(deltas) - wins - losses non_ties = wins + losses if non_ties: tail = sum( math.comb(non_ties, count) for count in range(0, min(wins, losses) + 1) ) / (2**non_ties) sign_test_p = min(1.0, 2.0 * tail) common_language = wins / non_ties else: sign_test_p = 1.0 common_language = 0.5 relative = [ 100.0 * delta / left for left, delta in zip(before, deltas, strict=True) if left != 0 ] return { "pairs": len(deltas), "wins": wins, "losses": losses, "ties": ties, "median_delta": float(statistics.median(deltas)), "median_relative_change_pct": ( float(statistics.median(relative)) if relative else None ), "common_language_effect": common_language, "two_sided_sign_test_p": sign_test_p, "lower_is_better": lower_is_better, }