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:
1 parent
241c2c062a
commit
fa07d23adb
6 files changed
+54
-90
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user