actop 1.5.0
Reading-plane audit remediation (docs/TODO-reading-plane-audit-2026-07-29.md
§§1-6), verified against live hardware on an M4 Max / Darwin 25.5.0. §8
(IOAccelerator Device/Renderer/Tiler utilization) is deferred to its own PR;
§3.5 (removing the deprecated *_gb fields) is breaking and rides 2.0.0.
Added
-
Byte quantities are now exported as exact byte counts. New
SystemSnapshot.ram_used_bytes/ram_total_bytes/swap_used_bytes/
swap_total_bytes,ProcessSample.rss_bytes,*_byteskeys on
utils.get_ram_metrics_dict(), andactop_ram_used_bytes/
actop_ram_total_bytes/actop_swap_used_bytes/actop_swap_total_bytes
Prometheus gauges.Bytes rather than a GiB/GB prefix, for three reasons: the GB-vs-GiB question
cannot be got wrong if no prefix is applied; byte counts are exact, whereas the
old rounded fields quantize to ±50 MiB at one decimal; and base units are the
Prometheus/OpenMetrics naming convention (node_exporteruses
node_memory_MemTotal_bytes). Prefix formatting is a display concern and now
happens only in the TUI.Additive and non-breaking.
ram_used_gb/ram_total_gb/swap_used_gb/
swap_total_gb/rss_mb, the*_GBdict keys,convert_to_GBand the
*_gigabytesgauges all remain as rounded views with unchanged values.
They are deprecated and will be removed in 2.0.0. -
--alert-swap-rise-gibreplaces--alert-swap-rise-gb, which is kept as a
working alias (same destination) until 2.0.0. The threshold was always
compared against GiB values, so the old name was a misnomer rather than a
different unit.AlertFrame.swap_rise_gbis likewise renamed
AlertFrame.swap_rise_gib, and the alert token rendersSWAP+0.3Gi.
Fixed
-
Memory reported binary quantities under decimal names.
convert_to_GB
divided bytes by 2^30 and called the result GB;rss_mbdivided by 2^20 and
called it MB. Per IEC 80000-13,1 GB = 10^9while1 GiB = 2^30, so both were
wrong by standard. The mislabel reached the public API (ram_used_gb,
ram_total_gb,swap_*_gb,ProcessSample.rss_mb), theram_used_gigabytes
Prometheus gauge, the NDJSON stream, and the TUI (RAM 66.7/128.0GB,
MEM (MB)).Anyone dividing memory against the genuinely decimal
bandwidth_gbpswas
picking up a silent 7.4% error — andactop's audience does exactly that
(tokens/s ~= effective_bandwidth / bytes_read_per_token, RAM headroom vs.
quantized weights). Fixing only the display string was considered and rejected:
it would protect the casual reader while continuing to mislead the actual user.The TUI now displays GiB and MiB, matching modern monitors (btop,
bottom,free -h,nvidia-smi,docker stats, KubernetesMi/Gi).
Bandwidth is deliberately left decimal: the DCS bucket labels are literally
"32GB/s"and Apple publishes 546 GB/s for M4 Max decimally, soGB/sis the
vendor's own unit for the bus, not an inconsistency. -
The swap-rise alert now measures growth from the exact
swap_used_bytescounts
instead of the rounded*_gbview, so a 0.1 GiB threshold can no longer trip on
rounding alone. -
_resolve_state_freqreturned0andNonefor different flavours of
"unresolvable", and its two consumers disagreed about which meant what. An
out-of-rangeV{n}P{m}/P{n}index returned0, which
_compute_residency_metricscounted as an active state (inflating
active_pctwhile draggingavg_freqdown) but
_compute_residency_distributionbucketed as idle — sogpu_util_pctand
gpu_residency_pct, displayed side by side, could contradict each other.
Out-of-range now returnsNoneand both consumers rejectfreq <= 0.
Latent on M1-M4: a probe of all 316 real state entries across every
CPU Stats/GPU Statschannel on an M4 Max hit the zero path 0 times. It
triggers on a chip exposing more states than its DVFS table describes — the
unknown-future-chip path thesoc_profilestier fallback exists to serve. -
bandwidth_availablereported that a channel exists, not that it carried
data. A present-but-silentAMCC RD+WRchannel surfaced
bandwidth_available=Truewithbandwidth_gbps=0.0, so the TUI showed
Mem BW 0.0 GB/sinstead of hiding the row — the misleading zero the hide-row
logic exists to prevent. Availability now also requires non-zero residency.
Verified no row flicker on the first frame or at idle for bothsubsamples=1
andsubsamples=3. -
Percentages truncated instead of rounding.
flooris a biased estimator
(expected error -0.5 units, max 1.0, for a uniform fractional part), so every
percentage read systematically low and 99.9% displayed as99%.clamp_percent,
the residencyavg_freq/active_pct, and the RAM/swap used-percent now
round. Deliberately left asint():
sampler._largest_remainder_percentages' floors, which are Hamilton's
apportionment rather than rounding —round()there would let the remainder go
negative and silently break the sum-to-100 guarantee.This is not purely cosmetic.
clamp_percentfeedsbandwidth_percentand
package_power_percent, which feed theMEM-BOUNDandPKGalert thresholds
throughAlertEngine, so a value sitting exactly on its threshold can now
fire one sample earlier. -
Hz -> MHzconversion innative_sys.get_dvfs_tables_nativerounds instead of
flooring, so a 1,499,800,000 Hz state reads 1500 MHz rather than 1499.
No visible change on M1-M4 — every observed table entry is already an exact
MHz multiple, andget_dvfs_tables_native()returns the same table set,
lengths and values before and after on an M4 Max. This is pre-emptive
correctness for chips whose tables are not exact, not a fix for a wrong number
anyone is seeing today.
Changed
- Documented that Apple's DVFS tables are not monotonic. The M4 Max GPU
voltage-states table reads
[0, 338, ..., 1312, 1242, 1380, 1326, 1470, 1578]— non-ascending, with
1182appearing twice. Verified against rawpmgrbytes that the 8-byte
(freq_hz, voltage)stride is correct and this is the table's genuine shape,
not a stride bug. Three docstrings claimed ascending order, which would invite
"optimizing"max(freq_table)intofreq_table[-1]and silently break the
DVFS ceiling. Docstrings and comments only; the code was already correct
because it usesmax()throughout.