Skip to content

Commit b1bbca2

Browse files
committed
Apply the trigger-correlation importance boost in the default clustering path
_run_clustering built each ClusterData with is_trigger_correlated=False and a comment promising it would be 'refined below after sorting' - but rank_and_merge_clusters only runs the optional semantic merge and re-sorts; it never recomputes importance. So in the default fingerprint-only path (embeddings disabled), no cluster ever got the scoring.py trigger_weight boost, even though is_trigger was already correctly detected and stored on the cluster. is_trigger is known before compute_importance_score is called, so pass it straight through instead of hardcoding False - the same pattern semantic_merge.py's _merge_component already uses. This also fixes the embeddings-enabled case for single-member components, which bypass _merge_component entirely and previously kept their unboosted score. Extracted the per-fingerprint ClusterData construction into _build_cluster_data() so this path is unit-testable without a database - the rest of _run_clustering is only covered by the Postgres-backed integration suite. Fixes #63
1 parent 261f8b7 commit b1bbca2

2 files changed

Lines changed: 111 additions & 44 deletions

File tree

‎src/core/clustering/clusterer.py‎

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,51 @@ class ClusterData:
3939
merged_fingerprints: list[str] = field(default_factory=list)
4040

4141

42+
def _build_cluster_data(
43+
fingerprint: str, group: dict, baseline_counts: dict[str, int]
44+
) -> ClusterData:
45+
"""Build a ClusterData from one fingerprint's grouped log rows."""
46+
count = len(group["ids"])
47+
services = dict(group["services"])
48+
levels = dict(group["levels"])
49+
timestamps = sorted([t for t in group["timestamps"] if t is not None])
50+
51+
baseline_count = baseline_counts.get(fingerprint, 0)
52+
change_ratio = compute_change_ratio(count, baseline_count)
53+
54+
# Representative message: most common
55+
rep_msg = ""
56+
if group["messages"]:
57+
from collections import Counter
58+
59+
rep_msg = Counter(group["messages"]).most_common(1)[0][0]
60+
61+
is_trigger = is_trigger_message(rep_msg)
62+
63+
importance = compute_importance_score(
64+
count=count,
65+
levels_distribution=levels,
66+
change_ratio=change_ratio,
67+
services_count=len(services),
68+
is_trigger_correlated=is_trigger,
69+
)
70+
71+
return ClusterData(
72+
fingerprint=fingerprint,
73+
representative_message=rep_msg,
74+
count=count,
75+
services=services,
76+
levels=levels,
77+
first_seen=timestamps[0] if timestamps else None,
78+
last_seen=timestamps[-1] if timestamps else None,
79+
baseline_count=baseline_count,
80+
change_ratio=change_ratio,
81+
importance_score=importance,
82+
is_trigger=is_trigger,
83+
log_entry_ids=group["ids"],
84+
)
85+
86+
4287
def run_clustering(
4388
db: Session,
4489
window_start: datetime,
@@ -171,50 +216,9 @@ def _run_clustering(
171216
)
172217

173218
# 4. Build cluster data
174-
clusters: list[ClusterData] = []
175-
176-
for fp, g in groups.items():
177-
count = len(g["ids"])
178-
services = dict(g["services"])
179-
levels = dict(g["levels"])
180-
timestamps = sorted([t for t in g["timestamps"] if t is not None])
181-
182-
baseline_count = baseline_counts.get(fp, 0)
183-
change_ratio = compute_change_ratio(count, baseline_count)
184-
185-
# Representative message: most common
186-
rep_msg = ""
187-
if g["messages"]:
188-
from collections import Counter
189-
190-
rep_msg = Counter(g["messages"]).most_common(1)[0][0]
191-
192-
is_trigger = is_trigger_message(rep_msg)
193-
194-
importance = compute_importance_score(
195-
count=count,
196-
levels_distribution=levels,
197-
change_ratio=change_ratio,
198-
services_count=len(services),
199-
is_trigger_correlated=False, # refined below after sorting
200-
)
201-
202-
clusters.append(
203-
ClusterData(
204-
fingerprint=fp,
205-
representative_message=rep_msg,
206-
count=count,
207-
services=services,
208-
levels=levels,
209-
first_seen=timestamps[0] if timestamps else None,
210-
last_seen=timestamps[-1] if timestamps else None,
211-
baseline_count=baseline_count,
212-
change_ratio=change_ratio,
213-
importance_score=importance,
214-
is_trigger=is_trigger,
215-
log_entry_ids=g["ids"],
216-
)
217-
)
219+
clusters: list[ClusterData] = [
220+
_build_cluster_data(fp, g, baseline_counts) for fp, g in groups.items()
221+
]
218222

219223
# 5. Optional semantic merge, then rank and cap
220224
top_clusters, algorithm = rank_and_merge_clusters(clusters, max_clusters)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""The trigger-correlation importance boost must apply in the default
2+
(fingerprint-only, embeddings-disabled) clustering path.
3+
4+
_run_clustering computed importance with is_trigger_correlated=False and a
5+
"refined below after sorting" comment promising a later fix-up that never
6+
existed, so trigger-correlated clusters never got the scoring.py
7+
trigger_weight boost unless they happened to be merged by the (opt-in)
8+
semantic-merge path.
9+
"""
10+
11+
import uuid
12+
from datetime import datetime, timezone
13+
14+
from src.core.clustering.baseline import compute_change_ratio
15+
from src.core.clustering.clusterer import _build_cluster_data
16+
from src.core.clustering.scoring import compute_importance_score
17+
18+
19+
def _group(message: str) -> dict:
20+
return {
21+
"messages": [message] * 5,
22+
"services": {"api": 5},
23+
"levels": {"error": 5},
24+
"timestamps": [datetime(2026, 3, 12, 22, 0, 0, tzinfo=timezone.utc)],
25+
"ids": [uuid.uuid4() for _ in range(5)],
26+
}
27+
28+
29+
def test_trigger_message_sets_is_trigger_and_boosts_importance():
30+
cluster = _build_cluster_data("fp-trigger", _group("deployment started"), {})
31+
32+
assert cluster.is_trigger is True
33+
34+
expected = compute_importance_score(
35+
count=5,
36+
levels_distribution={"error": 5},
37+
change_ratio=compute_change_ratio(5, 0),
38+
services_count=1,
39+
is_trigger_correlated=True,
40+
)
41+
assert cluster.importance_score == expected
42+
43+
44+
def test_non_trigger_message_gets_no_boost():
45+
cluster = _build_cluster_data("fp-plain", _group("request handled"), {})
46+
47+
assert cluster.is_trigger is False
48+
49+
expected = compute_importance_score(
50+
count=5,
51+
levels_distribution={"error": 5},
52+
change_ratio=compute_change_ratio(5, 0),
53+
services_count=1,
54+
is_trigger_correlated=False,
55+
)
56+
assert cluster.importance_score == expected
57+
58+
59+
def test_trigger_cluster_outranks_otherwise_identical_non_trigger_cluster():
60+
trigger_cluster = _build_cluster_data("fp-a", _group("deployment started"), {})
61+
plain_cluster = _build_cluster_data("fp-b", _group("request handled"), {})
62+
63+
assert trigger_cluster.importance_score == plain_cluster.importance_score + 2.0

0 commit comments

Comments
 (0)