Skip to content

Commit da98e34

Browse files
committed
feat: ground truth generation — auto severity, CSV upload, string parse — 122/122
1 parent 0a1e507 commit da98e34

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

src/benchmark/ground_truth.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Qute — benchmark/ground_truth.py
3+
Ground truth label generation for benchmark runs.
4+
5+
Three modes:
6+
auto — derive labels from event_severity (>= threshold = anomaly)
7+
upload — parse from uploaded CSV or comma-separated string
8+
db — load from stored ground truth associated with a dataset
9+
10+
Auto mode is the default and requires no user input.
11+
It is a proxy label, not a verified ground truth — clearly communicated to users.
12+
"""
13+
14+
from typing import Optional
15+
16+
17+
# ── Default severity threshold for anomaly classification ─────────
18+
DEFAULT_ANOMALY_THRESHOLD = 50 # event_severity >= 50 = anomaly (1)
19+
20+
21+
def from_severity(
22+
events: list[dict],
23+
threshold: int = DEFAULT_ANOMALY_THRESHOLD,
24+
) -> list[int]:
25+
"""
26+
Derive binary ground truth labels from event severity.
27+
severity >= threshold -> 1 (anomaly)
28+
severity < threshold -> 0 (benign)
29+
30+
This is a proxy label suitable for benchmarking but not
31+
a substitute for verified ground truth from a labelled dataset.
32+
"""
33+
labels = []
34+
for e in events:
35+
sev = e.get("event_severity")
36+
if sev is None:
37+
labels.append(0)
38+
else:
39+
labels.append(1 if int(sev) >= threshold else 0)
40+
return labels
41+
42+
43+
def from_string(
44+
gt_string: str,
45+
expected_length: Optional[int] = None,
46+
) -> tuple[list[int], Optional[str]]:
47+
"""
48+
Parse ground truth from a comma-separated string of 0s and 1s.
49+
Returns (labels, error_message). error_message is None on success.
50+
"""
51+
if not gt_string.strip():
52+
return [], "Ground truth string is empty."
53+
54+
try:
55+
labels = [int(x.strip()) for x in gt_string.split(",")]
56+
except ValueError:
57+
return [], "Ground truth must contain only 0s and 1s separated by commas."
58+
59+
if not all(v in (0, 1) for v in labels):
60+
return [], "All ground truth values must be 0 or 1."
61+
62+
if expected_length is not None and len(labels) != expected_length:
63+
return [], (
64+
f"Ground truth has {len(labels)} values but "
65+
f"{expected_length} events were loaded. They must match."
66+
)
67+
68+
return labels, None
69+
70+
71+
def from_csv_upload(
72+
content: str,
73+
expected_length: Optional[int] = None,
74+
) -> tuple[list[int], Optional[str]]:
75+
"""
76+
Parse ground truth from CSV file content.
77+
Accepts single column CSV or comma-separated values.
78+
"""
79+
# Strip header if present
80+
lines = [l.strip() for l in content.strip().splitlines() if l.strip()]
81+
if not lines:
82+
return [], "CSV file is empty."
83+
84+
# Try single column format (one value per line)
85+
if len(lines) > 1 and all(l in ("0", "1") for l in lines):
86+
labels = [int(l) for l in lines]
87+
if expected_length and len(labels) != expected_length:
88+
return [], (
89+
f"CSV has {len(labels)} rows but "
90+
f"{expected_length} events were loaded."
91+
)
92+
return labels, None
93+
94+
# Try comma-separated on one line
95+
return from_string(",".join(lines), expected_length)
96+
97+
98+
def summary(labels: list[int]) -> dict:
99+
"""Return a summary of ground truth labels."""
100+
total = len(labels)
101+
anomalies = sum(labels)
102+
benign = total - anomalies
103+
return {
104+
"total": total,
105+
"anomalies": anomalies,
106+
"benign": benign,
107+
"anomaly_rate": round(anomalies / total, 3) if total > 0 else 0.0,
108+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for ground truth label generation."""
2+
3+
import sys
4+
from pathlib import Path
5+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
6+
7+
from benchmark.ground_truth import (
8+
from_severity, from_string, from_csv_upload, summary,
9+
DEFAULT_ANOMALY_THRESHOLD
10+
)
11+
12+
13+
def test_from_severity_basic():
14+
events = [
15+
{"event_severity": 75},
16+
{"event_severity": 25},
17+
{"event_severity": 50},
18+
{"event_severity": 49},
19+
]
20+
labels = from_severity(events)
21+
assert labels == [1, 0, 1, 0]
22+
23+
24+
def test_from_severity_none_value():
25+
events = [{"event_severity": None}, {"event_severity": 80}]
26+
labels = from_severity(events)
27+
assert labels == [0, 1]
28+
29+
30+
def test_from_severity_custom_threshold():
31+
events = [{"event_severity": 60}, {"event_severity": 40}]
32+
labels = from_severity(events, threshold=70)
33+
assert labels == [0, 0]
34+
labels = from_severity(events, threshold=30)
35+
assert labels == [1, 1]
36+
37+
38+
def test_from_string_valid():
39+
labels, err = from_string("1,0,1,1,0")
40+
assert err is None
41+
assert labels == [1, 0, 1, 1, 0]
42+
43+
44+
def test_from_string_with_spaces():
45+
labels, err = from_string("1, 0, 1, 0")
46+
assert err is None
47+
assert labels == [1, 0, 1, 0]
48+
49+
50+
def test_from_string_invalid():
51+
labels, err = from_string("1,0,2,0")
52+
assert err is not None
53+
assert labels == []
54+
55+
56+
def test_from_string_empty():
57+
labels, err = from_string("")
58+
assert err is not None
59+
assert labels == []
60+
61+
62+
def test_from_string_length_mismatch():
63+
labels, err = from_string("1,0,1", expected_length=5)
64+
assert err is not None
65+
assert "3" in err
66+
assert "5" in err
67+
68+
69+
def test_from_string_length_match():
70+
labels, err = from_string("1,0,1", expected_length=3)
71+
assert err is None
72+
assert len(labels) == 3
73+
74+
75+
def test_from_csv_single_column():
76+
csv = "1\n0\n1\n0\n1"
77+
labels, err = from_csv_upload(csv)
78+
assert err is None
79+
assert labels == [1, 0, 1, 0, 1]
80+
81+
82+
def test_from_csv_comma_separated():
83+
csv = "1,0,1,0"
84+
labels, err = from_csv_upload(csv)
85+
assert err is None
86+
assert labels == [1, 0, 1, 0]
87+
88+
89+
def test_from_csv_empty():
90+
labels, err = from_csv_upload("")
91+
assert err is not None
92+
93+
94+
def test_summary():
95+
labels = [1, 0, 1, 0, 1, 0, 0, 0]
96+
s = summary(labels)
97+
assert s["total"] == 8
98+
assert s["anomalies"] == 3
99+
assert s["benign"] == 5
100+
assert s["anomaly_rate"] == 0.375
101+
102+
103+
def test_summary_empty():
104+
s = summary([])
105+
assert s["total"] == 0
106+
assert s["anomaly_rate"] == 0.0

0 commit comments

Comments
 (0)