Activity sparkline: drop the recency term that collapsed the scale

The shared scaling peak (activity_peak) was fully-lighting ~60% of the
header pulse's bars on a healthy 1–18 packets/min history. Cause: the
recency damper decayed each bucket's *count* by 0.5^(age/half-life)
before taking the 90th percentile, then that percentile scaled the
*undecayed* bars drawn against it — a unit mismatch. With a 30-min
half-life over a 6h pool, most traffic mass sits older than one
half-life, so the weighted percentile collapsed (1.14 here) and the
floor (3) became the ceiling; every minute with ≥3 packets, anywhere in
the drawn history, then saturated.

Drop recency; keep the two dampers that actually deliver the steady,
shared ceiling: an outlier-robust high percentile over a pool deeper
than is drawn (steady — the percentile barely stirs as one bucket
scrolls off), plus a floor (a lone packet stays a green nub). On the
live DB this restores peak 8, ~4–13% of bars full, heights spread 1–4.

activity_peak loses its bucket_seconds/half_life_min params; menu.py and
channels.py drop the now-unused bucket-seconds plumbing. The tall
dashboard chart was already on an honest max and is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-18 12:52:41 -04:00
1 parent 241c2c062a
commit fa07d23adb
6 files changed
+54 -90

No files matched your search

+3 -2
View File
@@ -36,8 +36,9 @@ OBSERVATION_WINDOW = timedelta(hours=2)
#: The trailing window a channel's activity histogram is computed over. Six hours is
#: deeper than the sparkline draws (see :data:`ACTIVITY_DRAWN_BUCKETS`) on purpose: the
#: extra history is what the shared scaling peak decays over, so it glides rather than
#: snaps as a busy stretch ages out (see :func:`~meshterm.ui.braillechart.activity_peak`).
#: extra history deepens the pool the shared scaling peak takes its percentile over, so
#: the ceiling glides rather than snaps as a busy stretch ages out of the drawn window
#: (see :func:`~meshterm.ui.braillechart.activity_peak`).
#: All-time volume still lives in the MSGS lane; this stays a live-pulse window.
ACTIVITY_WINDOW = timedelta(hours=6)
+28 -41
View File
@@ -185,8 +185,8 @@ def activity_sparkline(
handed the *same* one share a scale so their bar heights are directly comparable
(a whole column of per-channel rows against the busiest channel on screen), and it
lets the caller substitute a *steadier* ceiling than the bare window maximum.
:func:`activity_peak` builds that steady, shared ceiling — a recency-weighted,
outlier-robust, floored peak over a deeper history than is drawn — and both the
:func:`activity_peak` builds that steady, shared ceiling — an outlier-robust,
floored peak over a deeper history than is drawn — and both the
header pulse and the channel manager pass its result here. The scale is otherwise a
pure function of what each call is given: no hidden cross-instance state lives here.
@@ -224,14 +224,6 @@ def activity_sparkline(
)[0]
#: Default half-life, in minutes, of :func:`activity_peak`'s recency weighting: a
#: bucket this many minutes old counts half as much toward the ceiling as the current
#: one, so an aging burst releases its grip on the scale smoothly (over minutes) rather
#: than snapping the instant it scrolls off the drawn window. One figure drives every
#: activity surface, so the pulse "feels" the same whether its buckets are one minute
#: (the header) or five (the channel rows) wide.
ACTIVITY_HALF_LIFE_MIN = 30.0
#: Default percentile :func:`activity_peak` reads its ceiling at, in place of the raw
#: maximum: a lone freak-busy bucket sits *above* this and clips to full height instead
#: of redefining the whole scale, so one outlier minute doesn't flatten every other bar.
@@ -259,8 +251,6 @@ def _percentile(values: Sequence[float], p: float) -> float:
def activity_peak(
*histograms: Sequence[int],
bucket_seconds: float,
half_life_min: float = ACTIVITY_HALF_LIFE_MIN,
percentile: float = ACTIVITY_PERCENTILE,
floor: float = 0.0,
) -> float:
@@ -269,49 +259,46 @@ def activity_peak(
Relative-to-peak scaling reads its shape well but is jumpy: the plain window
maximum lurches whenever the busiest bucket enters or ages out of view, and a lone
packet in a quiet window fills the column because it *is* the maximum. This folds
three dampers into one ceiling, each acting on a different axis, so a whole column
of sparklines — or one that slides bucket by bucket — stays legible:
two dampers into one ceiling so a whole column of sparklines — or one that slides
bucket by bucket — stays legible:
* **Recency.** Each bucket's contribution is discounted by its age
(``count * decay ** age``), ``decay`` set from ``half_life_min`` and the bucket
width, so a burst weighs full when it happens and fades over minutes as it ages:
the ceiling *glides* down rather than snapping when the burst leaves the drawn
window. Handing this a deeper history than is drawn (the header's six hours, the
channel pool's) is what gives it the room to glide.
* **Robustness.** The ceiling is a high ``percentile`` of those weighted counts,
not their maximum, so a single freak-busy bucket sits above it and clips to full
height instead of shrinking every other bar to redefine the scale.
* **Robustness.** The ceiling is a high ``percentile`` of the counts, not their
maximum, so a single freak-busy bucket sits above it and clips to full height
instead of shrinking every other bar to redefine the scale. Handing this a deeper
history than is drawn (the header's six hours, the channel pool's) steadies it
further: the percentile of a wide pool barely stirs as one bucket scrolls off the
drawn window, so the ceiling *glides* rather than snapping.
* **Floor.** The result never drops below ``floor``, so a stray packet in a
long-silent window draws a small nub against a meaningful scale instead of
shouting at full height. It is also the scale when everything is silent.
A third damper — **recency**, discounting each bucket by ``decay ** age`` — was
tried and removed. Decaying the *counts* before taking the percentile, then scaling
the *undecayed* bars drawn against that ceiling, is a unit mismatch: the two only
agree at age zero. With a half-life far shorter than the pooled window it starved
the ceiling to the floor (most traffic mass sits older than one half-life, so the
weighted percentile collapsed), and a genuinely busy earlier stretch still on screen
then saturated wholesale. The pool depth already supplies the steadiness recency was
reaching for, without the collapse — so age no longer enters the ceiling at all.
Several histograms pool into one ceiling — every visible channel's, say — so a
column of rows shares one scale and their bars stay directly comparable (each
bucket is weighted by its own age, consistent across the histograms since bucket
``i`` is the same age in every one). The result is a pure function of the counts
handed in: no hidden cross-frame state, so it is deterministic and decays on its
own as the data does. Pass it to each sparkline's ``peak``.
column of rows shares one scale and their bars stay directly comparable. The result
is a pure function of the counts handed in: no hidden cross-frame state, so it is
deterministic and eases on its own as the data does. Pass it to each sparkline's
``peak``.
Args:
histograms: One or more newest-first count series (bucket 0 is "now"); their
non-zero buckets pool into the ceiling.
bucket_seconds: How many seconds one bucket spans, so ``half_life_min`` reads
in real time whatever the bucket width.
half_life_min: Age, in minutes, at which a bucket's weight halves.
percentile: The percentile of weighted counts the ceiling reads (0–100).
non-zero buckets pool into the ceiling. Order carries no weight — a bucket
counts the same however old it is in the window.
percentile: The percentile of counts the ceiling reads (0–100).
floor: The lowest the ceiling may fall to (also the all-silent scale).
Returns:
The scaling ceiling, ``>= floor``.
"""
decay = 0.5 ** ((bucket_seconds / 60.0) / half_life_min) if half_life_min > 0 else 0.0
weighted = [
count * decay**age
for histogram in histograms
for age, count in enumerate(histogram)
if count > 0
]
return max(float(floor), _percentile(weighted, percentile))
counts = [count for histogram in histograms for count in histogram if count > 0]
return max(float(floor), _percentile(counts, percentile))
#: The meter's fill glyphs, ``(full step, half step)``, by profile. A *full-height*
+5 -14
View File
@@ -59,11 +59,7 @@ from ..core.channels import (
)
from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import (
ACTIVITY_BUCKETS,
ACTIVITY_DRAWN_BUCKETS,
ACTIVITY_WINDOW,
)
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
from .braillechart import activity_peak, activity_sparkline
from .menus import back_rows, fit_cells, menu_rows, section_heading
from .qr import qr_text
@@ -354,10 +350,6 @@ def _toggle_mute(ctx: "AppContext", slot: ChannelSlot) -> None:
# --- message statistics --------------------------------------------------------
#: Seconds one activity bucket spans (five minutes), so the shared scaling peak's
#: recency half-life reads in real time (see :func:`~meshterm.ui.braillechart.activity_peak`).
_ACTIVITY_BUCKET_SECONDS = ACTIVITY_WINDOW.total_seconds() / ACTIVITY_BUCKETS
#: Floor for the shared channel-activity peak: with every channel quiet, a lone message
#: draws against at least this many per bucket, so a single stray stays a small nub
#: rather than filling its column. Low, since channel chatter is sparse to begin with.
@@ -403,10 +395,10 @@ class _LiveStats:
"""The shared sparkline scale across *every* channel, cached per snapshot.
A steady ceiling over the pooled per-channel histograms (see
:func:`~meshterm.ui.braillechart.activity_peak`): recency-weighted so it glides
as chatter ages, outlier-robust so one busy burst doesn't flatten the column,
and floored so a lull's stray message stays a nub. Handing this one value to
every row's :func:`~meshterm.ui.braillechart.activity_sparkline` scales the whole
:func:`~meshterm.ui.braillechart.activity_peak`): outlier-robust so one busy
burst doesn't flatten the column, floored so a lull's stray message stays a nub,
and steadied by pooling a window deeper than the rows draw. Handing this one
value to every row's :func:`~meshterm.ui.braillechart.activity_sparkline` scales the whole
activity column against the busiest channel on screen, so the rows' bar heights
are comparable at a glance instead of each self-scaling to its own ceiling.
@@ -417,7 +409,6 @@ class _LiveStats:
if self._peak is None:
self._peak = activity_peak(
*(st.histogram for st in snapshot.values()),
bucket_seconds=_ACTIVITY_BUCKET_SECONDS,
floor=_ACTIVITY_FLOOR,
)
return self._peak
+4 -9
View File
@@ -26,7 +26,6 @@ from .. import __version__
from ..context import AppContext
from ..persistence.logging import get_logger
from ..tools import all_tools
from ..services.monitor_service import ACTIVITY_BUCKET_S
from .surface import TuiUi
from .braillechart import activity_peak, activity_sparkline
from .theme import make_console
@@ -171,19 +170,15 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text:
"ok" if live else "muted" for live in ctx.monitor.activity_session_flags()
]
# Scale to a steady ceiling over the monitor's *full* six-hour history — deeper
# than the row draws — not the drawn window's bare maximum: a recency-weighted,
# outlier-robust, floored peak (see activity_peak), so the pulse doesn't lurch as
# a busy minute scrolls off the edge and a lone packet in a lull stays a nub.
# than the row draws — not the drawn window's bare maximum: an outlier-robust,
# floored peak (see activity_peak), so the pulse doesn't lurch as a busy minute
# scrolls off the edge and a lone packet in a lull stays a nub.
histogram = ctx.monitor.activity_histogram()
header.append_text(
activity_sparkline(
histogram,
room * 2,
peak=activity_peak(
histogram,
bucket_seconds=ACTIVITY_BUCKET_S,
floor=_HEADER_ACTIVITY_FLOOR,
),
peak=activity_peak(histogram, floor=_HEADER_ACTIVITY_FLOOR),
column_styles=styles,
)
)
+12 -22
View File
@@ -16,8 +16,6 @@ from meshterm.ui.braillechart import (
y_axis_labels,
)
_INF = float("inf") # half_life → no decay, so a peak test isolates percentile/floor/pool
#: Dot bits for a column filled bottom-up to height 0–4 (left / right column).
_L = (0x00, 0x40, 0x44, 0x46, 0x47)
_R = (0x00, 0x80, 0xA0, 0xB0, 0xB8)
@@ -209,38 +207,30 @@ def test_sparkline_pads_and_crops_to_the_window() -> None:
def test_activity_peak_reads_a_percentile_not_the_max() -> None:
"""One freak-busy bucket sits above the ceiling instead of defining it."""
# Ten steady buckets of 10 and a lone spike of 1000; no decay, so the ceiling is
# purely the 90th percentile of the counts — the steady level, not the spike.
peak = activity_peak(
(1000,) + (10,) * 10, bucket_seconds=60, half_life_min=_INF, percentile=90
)
assert peak == 10.0
# Ten steady buckets of 10 and a lone spike of 1000: the ceiling is the 90th
# percentile of the counts — the steady level, not the spike.
assert activity_peak((1000,) + (10,) * 10, percentile=90) == 10.0
def test_activity_peak_weights_recent_buckets_over_old() -> None:
"""The same burst weighs less toward the ceiling the older it is (recency)."""
# A one-minute half-life halves a bucket's weight each minute: a fresh 20-burst sets
# the ceiling at 20, the same burst two minutes back at a quarter of that.
now = activity_peak((20, 0, 0), bucket_seconds=60, half_life_min=1.0)
aged = activity_peak((0, 0, 20), bucket_seconds=60, half_life_min=1.0)
assert now == 20.0
assert aged == 5.0 # two half-lives → 20 × ¼
def test_activity_peak_ignores_bucket_age() -> None:
"""A burst weighs the same however old it is: recency no longer enters the ceiling."""
# The magnitude-decay recency term was removed (it starved the ceiling to the floor
# over a deep pool), so a 20-burst sets the same ceiling now or three buckets back.
assert activity_peak((20, 0, 0)) == activity_peak((0, 0, 20)) == 20.0
def test_activity_peak_floors_a_quiet_window() -> None:
"""The floor holds the ceiling up so a lull's stray packet stays a nub, not a column."""
assert activity_peak((0, 0, 0), bucket_seconds=60, floor=3.0) == 3.0 # silent → floor
lone = activity_peak((1,), bucket_seconds=60, half_life_min=_INF, floor=3.0)
assert activity_peak((0, 0, 0), floor=3.0) == 3.0 # silent → floor
lone = activity_peak((1,), floor=3.0)
assert lone == 3.0 # a single packet scales against the floor, not against itself
def test_activity_peak_pools_several_histograms_into_one_ceiling() -> None:
"""Pooled channels share a scale: a quiet channel reads short beside a busy one."""
pooled = activity_peak(
(10,) * 10, (1,), bucket_seconds=300, half_life_min=_INF, percentile=90
)
pooled = activity_peak((10,) * 10, (1,), percentile=90)
assert pooled == 10.0 # the busy channel's level sets the shared ceiling…
solo = activity_peak((1,), bucket_seconds=300, half_life_min=_INF, percentile=90)
solo = activity_peak((1,), percentile=90)
assert solo == 1.0 # …where the quiet one alone would have set just 1
+2 -2
View File
@@ -71,8 +71,8 @@ def test_header_scales_to_the_window_peak() -> None:
"""The pulse scales relative to a peak, not a fixed threshold ladder.
A 16/12/8/4 ramp climbs one dot per step against the robust ceiling ``activity_peak``
computes for it (~14.7 here — a recency-weighted, floored 90th percentile), so the
four newest minutes read heights 1,2,3,4 left→right in the row's final two cells.
computes for it (~14.8 here — a floored 90th percentile of the counts), so the four
newest minutes read heights 1,2,3,4 left→right in the row's final two cells.
"""
histogram = (16, 12, 8, 4) + (0,) * 356
header = _header(_ctx(histogram=histogram), {}, 80).plain