From fa07d23adb8de97afe4ec414b2c1729a631bddfe Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 18 Jul 2026 12:52:41 -0400 Subject: [PATCH] Activity sparkline: drop the recency term that collapsed the scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- meshterm/persistence/repository.py | 5 ++- meshterm/ui/braillechart.py | 69 ++++++++++++------------------ meshterm/ui/channels.py | 19 +++----- meshterm/ui/menu.py | 13 ++---- tests/test_braillechart.py | 34 ++++++--------- tests/test_header.py | 4 +- 6 files changed, 54 insertions(+), 90 deletions(-) diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index 0b804db..4ac7d26 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -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) diff --git a/meshterm/ui/braillechart.py b/meshterm/ui/braillechart.py index 7aa12b3..a21e5e7 100644 --- a/meshterm/ui/braillechart.py +++ b/meshterm/ui/braillechart.py @@ -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* diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index 813744d..6642348 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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 diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 8c2e2c0..ee2eb52 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -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, ) ) diff --git a/tests/test_braillechart.py b/tests/test_braillechart.py index b0c7c55..86b13d3 100644 --- a/tests/test_braillechart.py +++ b/tests/test_braillechart.py @@ -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 diff --git a/tests/test_header.py b/tests/test_header.py index 76a2814..5e54625 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -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