What L5 looks like
- Uses the bounded score range: counts plus a Fenwick tree (or a count array), with O(log R) or O(R) queries, and explains when the simpler array is enough.
- Handles real-valued scores with an order-statistic structure and states its costs.
- Works through window size and query cost with real numbers.
Clarify the setup
A support agent platform scores every finished conversation (say 0 to 100 from an automated grader). Operations:
-
record(timestamp, score): timestamps arrive in non-decreasing order. -
percentile(p, now): thep-th percentile of the scores recorded in the window(now - W, now].
Ask:
-
Which percentile definition? Use nearest rank: sort the
nscores and return the one at rankceil(p / 100 × n)(rank 1 forp = 0). It always returns a real score, which dashboards prefer. Interpolated percentiles are a follow-up. - What range do scores have? Integers 0 to 100 here. That matters a lot: a bounded integer range allows a counting structure.
- Query rate versus record rate. Dashboards query often (p50, p90, p99 every few seconds); records arrive at conversation rate.
Approach
Brute force: keep the window in a deque, and on each query copy it, sort it, pick the rank. O(n log n) per query.
Bounded integer scores: counts plus a Fenwick tree.
- A deque of
(timestamp, score)holds the window in arrival order. Evicting expired entries from the left is O(1) each, and every entry is evicted at most once. - A Fenwick tree over the 101 possible scores holds how many window entries have each score.
recordadds 1 atscore; eviction adds −1. - A percentile query is "find the smallest score
swhose count of entries<= sreaches the rank". A Fenwick tree answers that with a binary descent in O(log R), where R = 101.
With R this small, a plain count array and a linear scan (101 steps) is also fine and simpler. Say that, then show the Fenwick version as the answer that still works for R = 10,000 (scores with two decimals).
Arbitrary real scores need an order-statistic structure: a sorted list with binary search (Python's bisect.insort is O(n) per insert but a fast memmove; fine to about 10^5 entries), or a balanced tree or skip list with subtree sizes for O(log n). For millions of entries per window, approximate with a t-digest or a histogram sketch and say what error you accept.
Code
import bisect
import math
from collections import deque
from typing import Deque, List, Optional, Tuple
class ScoreWindow:
"""Percentiles over integer scores in [0, max_score] within a time window."""
def __init__(self, window: float, max_score: int = 100) -> None:
self.window = window
self.size = max_score + 1
self.tree = [0] * (self.size + 1) # Fenwick over score values, 1-based
self.events: Deque[Tuple[float, int]] = deque()
self.top = 1 << (self.size.bit_length() - 1)
def record(self, timestamp: float, score: int) -> None:
if not 0 <= score < self.size:
raise ValueError("score out of range")
if self.events and timestamp < self.events[-1][0]:
raise ValueError("timestamps must not go backwards")
self.events.append((timestamp, score))
self._add(score, 1)
def count(self, now: float) -> int:
self._expire(now)
return len(self.events)
def percentile(self, p: float, now: float) -> Optional[int]:
self._expire(now)
n = len(self.events)
if n == 0:
return None
rank = max(1, math.ceil(p / 100 * n))
return self._kth(rank)
# ---- internals ----------------------------------------------------------
def _expire(self, now: float) -> None:
while self.events and self.events[0][0] <= now - self.window:
_, score = self.events.popleft()
self._add(score, -1)
def _add(self, score: int, delta: int) -> None:
i = score + 1
while i <= self.size:
self.tree[i] += delta
i += i & -i
def _kth(self, k: int) -> int:
"""Smallest score whose cumulative count reaches k (binary descent)."""
pos, step = 0, self.top
while step:
nxt = pos + step
if nxt <= self.size and self.tree[nxt] < k:
pos = nxt
k -= self.tree[nxt]
step >>= 1
return pos # 1-based position pos + 1 is score pos
class RealScoreWindow:
"""Any real-valued score: sorted list, O(log n) search, O(n) memmove insert."""
def __init__(self, window: float) -> None:
self.window = window
self.events: Deque[Tuple[float, float]] = deque()
self.sorted: List[float] = []
def record(self, timestamp: float, score: float) -> None:
self.events.append((timestamp, score))
bisect.insort(self.sorted, score)
def percentile(self, p: float, now: float) -> Optional[float]:
while self.events and self.events[0][0] <= now - self.window:
_, score = self.events.popleft()
del self.sorted[bisect.bisect_left(self.sorted, score)]
n = len(self.sorted)
if n == 0:
return None
return self.sorted[max(1, math.ceil(p / 100 * n)) - 1]
Worked numbers
A platform handling 2 million conversations a day sees about 23 a second, so a one-hour window holds about 83,000 scores.
- Brute force: sorting 83,000 values per query is about 83,000 × 17 ≈ 1.4M comparisons, roughly 15 ms in Python. Three percentiles every 5 seconds is tolerable for one dashboard, not for thousands of tenants.
- Counts plus Fenwick: each record is 7 tree updates (log2 of 101), each query 7 steps. Memory is the deque (83,000 entries) plus 102 counters.
- If the per-tenant memory of the deque matters, bucket time instead: keep one count array per minute (60 buckets × 101 counters), and a query sums the live buckets. Exact at minute granularity, constant memory per tenant.
Test cases
import math as _math
import random as _random
def _nearest_rank(values, p):
s = sorted(values)
return s[max(1, _math.ceil(p / 100 * len(s))) - 1] if s else None
def test_simple_percentiles():
w = ScoreWindow(window=60)
for t, s in enumerate([50, 10, 90, 30, 70]):
w.record(t, s)
assert w.percentile(50, now=5) == 50
assert w.percentile(0, now=5) == 10
assert w.percentile(100, now=5) == 90
assert w.percentile(90, now=5) == 90
assert w.percentile(20, now=5) == 10
def test_expiry():
w = ScoreWindow(window=10)
w.record(0, 100)
w.record(5, 0)
assert w.percentile(100, now=9) == 100
assert w.percentile(100, now=10) == 0 # t=0 left the window (now - 10, now]
assert w.percentile(50, now=100) is None
assert w.count(now=100) == 0
def test_matches_brute_force():
rng = _random.Random(11)
w = ScoreWindow(window=50)
r = RealScoreWindow(window=50)
events = []
t = 0.0
for _ in range(3000):
t += rng.random() * 2
s = rng.randint(0, 100)
w.record(t, s)
r.record(t, s)
events.append((t, s))
if rng.random() < 0.2:
live = [x for ts, x in events if ts > t - 50]
p = rng.choice([0, 10, 50, 90, 99, 100])
assert w.percentile(p, t) == _nearest_rank(live, p) == r.percentile(p, t)
def test_rejects_bad_input():
w = ScoreWindow(window=10)
w.record(5, 1)
for args in ((4, 1), (6, 101), (6, -1)):
try:
w.record(*args)
assert False
except ValueError:
pass
def test_large_score_range():
w = ScoreWindow(window=1000, max_score=9999)
for i in range(10000):
w.record(i * 0.01, i)
assert w.percentile(50, now=100) == 4999
assert w.percentile(99, now=100) == 9899
Follow-ups
- Out-of-order timestamps. Late events (a conversation scored after a delay) break the deque's order. Use time buckets keyed by minute: a late event goes into its own bucket if that bucket is still in the window, and is dropped otherwise.
-
Interpolated percentiles. Linear interpolation between ranks
floorandceilofp/100 × (n − 1)needs the two neighboring order statistics: two_kthcalls. - Many tenants and segments. Percentiles per customer, per intent and per channel multiply the structures. Time-bucketed histograms merge by adding counts, so store per-segment buckets and merge at query time.
- Alerting. "Page someone when p10 drops below 40 for 10 minutes": evaluate on a schedule from the same structure and require the condition to hold across consecutive checks to avoid flapping.
- Approximate at scale. For unbounded real scores and huge windows, t-digest or DDSketch give mergeable percentiles with bounded relative error and kilobytes of memory.
Interviewer expectations
Mid-level (L4)
- Correct windowing with a deque and a correct nearest-rank percentile, even if each query sorts.
- Handles an empty window,
p = 0andp = 100, and the window boundary. - Tests against a brute-force reference.
Senior (L5)
- Uses the bounded score range: counts plus a Fenwick tree (or a count array), with O(log R) or O(R) queries, and explains when the simpler array is enough.
- Handles real-valued scores with an order-statistic structure and states its costs.
- Works through window size and query cost with real numbers.
Staff (L6+)
- Moves to time-bucketed, mergeable histograms for late events, many segments and constant memory.
- Knows the approximate options (t-digest, DDSketch) and their error guarantees.
- Connects it to the product: quality dashboards per customer, alert thresholds without flapping.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 2-level tests, run in your browser.
