"""Family-shared Hazen percentile transform — reference implementation. One transform for all three indices (ADI / SDI / CDI), per the reconciler amendment adi-2: percentile = (average_rank - 0.5) / n * 100, with tied values assigned the average of their ranks. ADI applies it along time (a series' own quarterly history); SDI and CDI apply it across places. The math is identical. The sibling prototypes carry their own copies today (drafts/index_rebuild_2026-06-09/sdi/compute_sdi.py hazen_percentiles, drafts/index_rebuild_2026-06-09/cdi/compute_cdi.py hazen_percentiles); validate_adi.py runs a fixed tie-containing parity corpus against all three. The family integration commit must collapse the three copies into one module under scripts/ and keep the parity test. """ from __future__ import annotations from typing import List, Sequence def hazen_percentiles_series(values: Sequence[float]) -> List[float]: """Map each value to its Hazen percentile within the full sequence. percentile_i = (average_rank_i - 0.5) / n * 100, ranks ascending (1 = lowest value), tied values receiving the average of the ranks they span. Output is positionally aligned with the input. Bounded on (0, 100) by construction; insensitive to outlier magnitude because only ordering enters the formula. """ n = len(values) if n == 0: return [] order = sorted(range(n), key=lambda i: values[i]) ranks = [0.0] * n i = 0 while i < n: j = i while j + 1 < n and values[order[j + 1]] == values[order[i]]: j += 1 average_rank = (i + j) / 2 + 1 # ranks are 1-based for k in range(i, j + 1): ranks[order[k]] = average_rank i = j + 1 return [(rank - 0.5) / n * 100.0 for rank in ranks]