"""Phase 5 — persistence clustering, uniqueness, and scale selection.
Groups candidate knees that recur at nearby locations across the smoothing
scales and sensitivities, measures how *persistent* each group is, rejects the
ambiguous multi-knee case, and picks the smallest stable smoothing scale of the
winning cluster.
Author
------
Warith Harchaoui, <warith.harchaoui@deraison.ai>
"""
from __future__ import annotations
from typing import List, Optional, Tuple
import numpy as np
from .config import RobustKneeConfig
from .smoothing import smoothing_grid
from .types import CandidateCluster, KneeCandidate, PreparedCurve, Reason
def _longest_consecutive_run(ranks: List[int]) -> int:
"""Longest run of consecutive integers present in ``ranks``.
Parameters
----------
ranks : list of int
Smoothing-scale ranks at which a cluster's members were observed.
Returns
-------
int
Length of the longest run of consecutive ranks, ``0`` if empty.
"""
if not ranks:
return 0
s = sorted(set(ranks))
best = run = 1
for a, b in zip(s, s[1:]):
run = run + 1 if b == a + 1 else 1
best = max(best, run)
return best
[docs]
def cluster_candidates(
candidates: List[KneeCandidate],
prepared: PreparedCurve,
config: RobustKneeConfig,
) -> List[CandidateCluster]:
"""Cluster candidates by location and score each cluster's persistence.
Parameters
----------
candidates : list of KneeCandidate
Candidates that already passed the basic filters.
prepared : PreparedCurve
The curve (for the total candidate count and window grid).
config : RobustKneeConfig
``cluster_tolerance`` and the persistence thresholds.
Returns
-------
list of CandidateCluster
Clusters sorted by support (descending), each flagged ``persistent``.
"""
if not candidates:
return []
windows = smoothing_grid(prepared.n, config)
window_rank = {w: i for i, w in enumerate(windows)}
ordered = sorted(candidates, key=lambda c: c.knee_x_norm)
clusters_members: List[List[KneeCandidate]] = []
current: List[KneeCandidate] = [ordered[0]]
center = ordered[0].knee_x_norm
for cand in ordered[1:]:
if abs(cand.knee_x_norm - center) <= config.cluster_tolerance:
current.append(cand)
center = float(np.median([m.knee_x_norm for m in current]))
else:
clusters_members.append(current)
current = [cand]
center = cand.knee_x_norm
clusters_members.append(current)
total = len(candidates)
all_sensitivities = float(len(_sensitivity_values(config, prepared.n)))
clusters: List[CandidateCluster] = []
for members in clusters_members:
locs = np.array([m.knee_x_norm for m in members])
median_knee = float(np.median(locs))
mad = float(np.median(np.abs(locs - median_knee)))
member_windows = [m.window for m in members]
ranks = [window_rank[w] for w in member_windows if w in window_rank]
consecutive = _longest_consecutive_run(ranks)
distinct_sens = {m.sensitivity for m in members}
sens_support = len(distinct_sens) / (all_sensitivities or 1.0)
neighbor_shift = _neighbor_shift(members, window_rank)
cluster = CandidateCluster(
median_knee=median_knee,
mad=mad,
members=members,
n_windows=len(set(member_windows)),
consecutive_scales=consecutive,
sensitivity_support=sens_support,
neighbor_shift=neighbor_shift,
support=len(members),
support_frac=len(members) / total,
median_prominence=float(np.median([m.prominence for m in members])),
median_noise_prominence_ratio=float(
np.median([m.noise_prominence_ratio for m in members])
),
)
cluster.persistent = _is_persistent(cluster, config)
if cluster.persistent:
cluster.stable_window = _smallest_stable_window(cluster, windows, config)
clusters.append(cluster)
clusters.sort(key=lambda c: c.support, reverse=True)
return clusters
def _sensitivity_values(config: RobustKneeConfig, n: int) -> List[float]:
"""The sensitivity grid (kept in sync with :mod:`candidates`).
Parameters
----------
config : RobustKneeConfig
Supplies ``sensitivity_fractions``.
n : int
Number of points in the curve.
Returns
-------
list of float
Distinct sensitivity values, sorted ascending.
"""
values = set()
for frac in config.sensitivity_fractions:
values.add(float(max(1, round(frac * n))))
return sorted(values)
def _neighbor_shift(members: List[KneeCandidate], window_rank: dict) -> float:
"""Max shift in per-window median location between adjacent windows.
Parameters
----------
members : list of KneeCandidate
A single cluster's member candidates.
window_rank : dict
Maps smoothing window size to its rank in the smoothing grid.
Returns
-------
float
Largest jump between consecutive windows' median normalized
location, ``0.0`` if the cluster spans fewer than two windows.
"""
by_window: dict = {}
for m in members:
by_window.setdefault(m.window, []).append(m.knee_x_norm)
if len(by_window) < 2:
return 0.0
ordered = sorted(by_window.items(), key=lambda kv: window_rank.get(kv[0], 0))
medians = [float(np.median(v)) for _, v in ordered]
return float(np.max(np.abs(np.diff(medians))))
def _is_persistent(cluster: CandidateCluster, config: RobustKneeConfig) -> bool:
"""Apply the four persistence gates from the plan.
Parameters
----------
cluster : CandidateCluster
A candidate cluster with its consecutive-scale, sensitivity-support,
MAD and neighbor-shift statistics already populated.
config : RobustKneeConfig
The four persistence thresholds to check against.
Returns
-------
bool
``True`` iff all four persistence gates pass.
"""
return (
cluster.consecutive_scales >= config.min_consecutive_scales
and cluster.sensitivity_support >= config.min_sensitivity_support
and cluster.mad <= config.max_cluster_mad
and cluster.neighbor_shift <= config.max_neighbor_shift
)
def _smallest_stable_window(
cluster: CandidateCluster, windows: List[int], config: RobustKneeConfig
) -> int:
"""Smallest window in the cluster's stable run with enough sensitivity support.
Parameters
----------
cluster : CandidateCluster
A cluster already flagged persistent.
windows : list of int
The full smoothing-window grid, ascending.
config : RobustKneeConfig
Supplies ``min_sensitivity_support``.
Returns
-------
int
The smallest window size meeting the sensitivity-support threshold,
or the smallest window the cluster appears in at all as a fallback.
"""
all_sens = len({m.sensitivity for m in cluster.members}) or 1
by_window: dict = {}
for m in cluster.members:
by_window.setdefault(m.window, set()).add(m.sensitivity)
for w in windows: # ascending
if (
w in by_window
and len(by_window[w]) / all_sens >= config.min_sensitivity_support
):
return w
# Fall back to the smallest window that appears at all.
present = sorted(by_window)
return present[0] if present else windows[0]
[docs]
def select_unique_cluster(
clusters: List[CandidateCluster], config: RobustKneeConfig
) -> Tuple[Optional[CandidateCluster], Optional[str]]:
"""Pick the single dominant persistent cluster, or explain the abstention.
Parameters
----------
clusters : list of CandidateCluster
All clusters (sorted by support).
config : RobustKneeConfig
``secondary_support_frac`` and ``min_dominance_ratio``.
Returns
-------
tuple
``(cluster, None)`` on success, or ``(None, reason_code)`` when there is
no persistent cluster or the winner is not clearly dominant.
"""
persistent = [c for c in clusters if c.persistent]
if not persistent:
return None, Reason.NO_PERSISTENT_CLUSTER
persistent.sort(key=lambda c: c.support, reverse=True)
primary = persistent[0]
if len(persistent) >= 2:
second = persistent[1]
both_strong = second.support_frac > config.secondary_support_frac
not_dominant = primary.support < config.min_dominance_ratio * second.support
well_separated = (
abs(primary.median_knee - second.median_knee) > 2 * config.cluster_tolerance
)
if well_separated and (both_strong or not_dominant):
return None, Reason.MULTIPLE_PLAUSIBLE_KNEES
return primary, None