The prefix settle walked every link in the graph three times per merge —
to derive the node set, to build the neighbour table the corroboration
vote reads, and to hunt down the links naming the id being folded. On the
device's mesh that is ~1150 links, three times over, some eighty times:
the largest remaining cost of opening the walk.
All three questions are one table's to answer, and a merge's effect on it
is knowable where it happens. So build it once and keep it: its keys are
the live node set, a fold relabels its own neighbours, and a stub whose
every link ran to its owner takes the owner out with it. Both passes now
walk only the under-specified ids, since a full-width one extends
nothing.
Ties in the shortest-first order break lexicographically too. Length
alone left equal-width ids in whatever order the node set iterated, and
CPython randomizes string hashing per process — so two runs over the same
evidence could take their merges in different orders and, where one fold
changes what a later one sees, settle to different graphs.
Verified byte-identical against the previous algorithm over twelve
randomized graphs, and 6.3x on the settle at that scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prewarm at connect filled contacts and the two slot probes, so the
first Contacts or Trace open still queued a path-hash-mode round trip
behind whichever probe was walking the slot table at the time — and the
slot walks are measured in seconds.
Warm every cached fact, cheapest first: self-info and the routing mode
(single round trips, and what every list screen needs), then contacts,
then the two probes only the channel manager waits on. The mode gets a
lock like its siblings, so a screen opening beside the warm awaits the
read in flight instead of issuing a second one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 53-column mark's fourth row is the only row in either wordmark whose
colour lands on the dim bank (185;28;28 -> slot 1), and on the console it
came out part red, part light red: the M's right leg, E, S, H and the R's
right leg promoted, the M's left leg, T, E and the R's left leg not.
The split follows the bevels. The mark alternates letter blocks with slate
bevel spans, the bevel is slot 8 -- spelled 90, which on the VT sets the
intensity bit -- and the quantizer handed back a bare 31 for the blocks
after it. Bare, it keeps the bit and renders 91. Every leg that happened to
follow a bevel went light red; every leg that followed one of the row's few
resets stayed red. The .ansi art is one colour throughout; the promotion is
entirely on the way out.
So the fold's quantizer now states its intent the way the theme's dim-slot
styles already must: a dim slot leaves as 22;3N, never a bare 3N. Adjacent
art spans reset nothing between them, so this is the only place that can
know. It fixes the same latent promotion in a map raster, where two
neighbouring cells are exactly this pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rasterizing a view is most of a second, so the paint that answers a pan
drew the nodes on an empty canvas and waited — the map going black on
every keypress and flashing back when the frame landed. Each finished
frame now keeps its braille layer, and the next moved view stands on it:
the two viewports differ by an affine scale-and-shift in Web Mercator,
which collapses to two cell-index tables the canvas pastes by. So the
streets slide with the view — dimmed, and short of the edge you are
panning onto — until their own raster replaces them.
Cell granularity, not dot-exact: the stand-in lands within half a cell
of true and is corrected a moment later, for +0.5 ms on the paint path
instead of eight times the work. Only the ground travels; labels and
markers are redrawn where they really are. The fade is off on the
16-slot console, where dimming a colour is a recolouring, and the whole
ghost is dropped once it says nothing — panned clear off, or more than
two zoom steps away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Routes tab had it the other way round: names in the graph, hashes
in the row list. Names in the fan read well on one route and fall
apart on several - each label is set above or below its own marker,
so a long one spills across the lanes either side of it, and the very
graph that earns its lanes is the one that can least afford them.
So take the split the Message paths dialog already settled on. In the
fan, every relay is its first hash byte alone - two cells, whatever
the node is called; only the two endpoints stay named. In the rows,
every hop the resolver can place reads as its contact name, in that
node's own hue, and only a hop with no name to show stands in its
hash at the device's path-hash width. Nothing spells the same node
twice: the two bands cross-reference by colour.
Names cost cells a hash chain doesn't, which is what the row's own
horizontal scroll is for - so it now draws the faint edge marks the
key lane and the Message paths rows draw, and clamps where the tail
is actually readable rather than one step short of it, under a right
mark promising a remainder the keys could not reach.
self_key falls out of the route line and its view: the far end is our
name now, not our hash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the PicoCalc the route graph opened under a blank row and closed
under a caption, neither of which draws anything the picture doesn't
already say. The blank is the renderer's own end padding: it centres
the lane band inside two cells of air while the topmost label wants
only one, so that first canvas row is reliably empty. The stage now
asks for one row over its ceiling and peels that row off, which pays
for the grant, and drops the caption outright — the white line and the
you star make the same point.
Both rows go back into the ceiling, so a ceiling-bound fan draws two
lanes taller on the console than the captioned desktop stage affords
it from the same viewport. The desktop keeps its air and its caption:
this rides frame_border, the same frugality signal the tab strip's air
already binds to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening the mesh walk took 4.9 s, and 4.2 s of it was build_topology.
coalesce_prefixes folds a short hop hash onto the longer id it abbreviates.
It restarts the whole pass after each merge; each pass asks _extensions
once per node; and _extensions scanned every node in the graph. On this
device - 3053 stored packet paths, 1145 links, ~80 merges to settle - that
came to 24 million len calls and 7.6 million startswith per build.
Ids sharing a prefix are contiguous once sorted, so the extensions of a
short id are a range, not a search: bisect to its start and stop at the
first id that no longer carries the prefix. build_topology 4.22 -> 1.23 s
with real contacts, and the graph is byte-identical - 1133 links, 345
nodes, both dumped and diffed. On the console the open is 4.94 -> 1.95 s.
Contacts' 1.3 s open turns out not to be ours at all: heard_nodes 23 ms,
the screen 131 ms, first render 43 ms. The rest is the first devstate read
over the UART, already cached for every later open - a warm one is 230 ms.
Warming devstate at connect is the fix, and it is now first in the plan.
The harness needed two things to measure any of this, both folded back into
the skill. An open is not a repaint - it paints the menu closing, falls
silent while it loads, then paints the new screen - so quiescence stopped
the clock on the first burst and reported the walk opening in 124 ms.
Widening the silence window does not help either: past a second the 2 s
header tick lands inside every measurement. `expect TEXT` watches
/dev/vcs1 until the screen is actually there. Its needle has to be unique
to the destination, because every main-menu row carries its own screen's
name - `expect Mesh walk` matched the menu and made a 2.3x win read as no
change at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.
Two changes, in that order.
The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).
The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.
Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.
On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.
Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every paint handed prompt_toolkit a finished ANSI frame and let it parse
that back into fragments, write all ~1400 cells into a fresh Char grid,
diff the grid, and re-emit ANSI. Measured on the PicoCalc that round trip
is 70-125 ms, and it is paid in full whether the frame changed by one row,
by all of them, or not at all - the 2 s header tick recomposes an
identical frame and still pays it.
Since the frame is already rows of text, FastRenderer compares this
frame's rows against the last frame's and rewrites only the ones that
differ. Opt-in for now (MESHTERM_FASTRENDER=1) and deliberately narrow:
a floating dialog or the busy overlay is placed by pt's float containers,
so those paints fall straight through to the stock renderer.
The erase has to lead the row, never follow it. A row that exactly fills
53 columns leaves the cursor in the last column with wrap pending, and an
erase-to-end there clears the character just written - the last cell of
every row went missing until the clear moved in front.
Alongside it, three costs that were pure waste on this platform:
* The redraw was postponed up to max_render_postpone_time before it
even started - a flat 3 ms on the Lyra, 10 ms on desktop, on every
key. That default guards a UI whose own output floods it with
invalidations; here they are keystrokes and a 2 s tick.
* The splash rendered its body twice per paint - once to measure the
box, once to fill it - and ran the corner glow ungated, walking every
line to hand back the identical list on a console with no truecolor
to blend. compose_dialog had the same ungated glow.
* A tile's decoded layers were re-read from the SD card on every frame
(~96 ms each), so panning one dot re-read every tile that had not
moved. They are held in RAM now, bounded at a viewport plus its ring.
On the device, driven over the real radio: main menu 115 -> 40 ms,
Contacts 155 -> 73 ms, Dashboard 213 -> 117 ms, leaving a screen
375 -> 103 ms. Console readback confirms the frames are byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The offer flow end to end: a device reporting the 0/0 null island
(exactly what SELF_INFO carries for unset - signed microdegrees over
1e6, verified against the meshcore reader) gets its remembered fix
written back silently; a real conflicting fix still prompts; mixed
drift splits between the silent restore and the prompt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shift+Up/Down reach the app as PgUp/PgDn there; with Shift physically
held (the watcher knows) they fine-pan instead of zooming - no real
PgUp exists on that keyboard, so the code cannot mean anything else.
Shift+Left/Right the keymap eats outright; nothing arrives to rescue
until the console keymap maps them back to plain arrows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The right-gutter marks come back on the desktop (redundant but nice)
and the console keeps the bare tick - axis_chrome is the one number a
caller budgets, platform-bound like the rest of the chrome. The Rhythm
ladder widens to 1/5/10/15/20/30/60-minute slices, finest that fits:
the desktop keeps its 15-minute sweep at 72 cols and earns finer on a
wide terminal; the mesh page re-slices one minute-grid scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-item chips (node page F1 Info / F2 Routes, Time machine
F1-F3 spans) revert to the single next-thing toggle on F3, per JP's
call. The PicoCalc window ring still stops at 30 d; its F3 chip just
cycles the three spans that remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rich honours NO_COLOR by stripping colour while keeping attributes, so
a shell that sets it (agent harnesses do) fails the suite spuriously.
The autouse fixture now deletes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the PicoCalc the query overlays the canvas last row - directly above
the F-key lane - so the ground never shifts when a find begins. You
(Ctrl+L) now clears the query as it comes home; its Shift half, You +,
also zooms in close, and Clear behind Frame drops the query outright.
Shifted arrows fine-pan on the handheld too: the Shift watcher covers a
console that strips the modifier off an arrow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 15-minute slices outran the PicoCalc; the chart now takes the
finest of 20/30/60-minute slices that fits (the mesh page re-slicing
one ten-minute base scan), and every section lays out for the single
left gutter. The window ring rides F1-F3 as direct chips - 24 h, 7 d,
30 d, the span on screen dimmed - and the handheld skips all time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marks now tick the left gutter alone and the right edge closes with a
bare border, handing its cells to the chart. Deep tallies compact to
k/M marks, and the gutter sizes to the widest mark actually printed -
a 1.5k peak reads 1k while a lower tick still lands three digits wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The F3 next-tab toggle gave way to one chip per view in strip order;
the tab on screen dims (its key would change nothing this paint), and
Tab/Shift+Tab still cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hash whose node nobody can name reads whole in node.unknown — path
lines, key lanes, the walk, the packet card, the arrivals — never in a
hue its bytes would derive, exactly as the path graph already drew it.
highlighted_hash gains a known flag; the two path widgets decide it from
their resolver, and the node page threads it per hop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F4/F5 keeps up on the right; an F1/F2 pair puts up on F1, so a select
list reads Sect ↑ then Sect ↓ — the hand anchors on the lane's edge and
rocks inward, whichever side the pair lives on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The map and the mesh walk echo the query you are typing in their footer
hint. The PicoCalc draws no footer hint -- the F-key lane took that row
-- so on the device both screens silently filtered themselves while the
reader had no way to see what they had typed.
The app already had an answer: the select list and the path composer
each draw the query as a warn-styled /query line above the rows it
narrows. That is now render.query_line, one definition, and the map and
the walk draw it too -- but only where the footer isn't, so the desktop
is untouched and no screen ever shows the query twice.
The row it costs comes from what the query narrows: the walk's link
list, the map's canvas. The map rebuilds its viewport a row shorter
through the ordinary resize path, so beginning a find loses a strip of
ground along the bottom rather than pushing the last row out of frame.
base_body_size was still mirroring only compose_base's bordered branch,
so a screen sizing itself against it left one row of a borderless frame
permanently blank -- which is the row the echo now wants back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.
That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:
select lists Sect down/up, the section jumps. Ctrl+PgUp on a
keyboard with no PgUp is the one binding in the app
that was physically unreachable. Plus Delete, lit on
exactly the rows the key acts on.
map Region (Home, renamed -- it frames the ground the mesh
covers, it does not "reset" anything), You (new: Ctrl+L,
recentre on our own node at the zoom you chose), and
Frame for the find matches.
node page the tab it would switch you to.
time machine the window span it would take you to -- w was
undiscoverable on the device.
The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.
Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).
The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A public key is 64 hex digits; the Info tab's value lane is 63 cells at
the 72-column standard and 44 on the PicoCalc. So the key never fit, and
wrapping it spent a whole second line on the handful of digits that fell
off — a line the location preview then had to give up.
It holds one line now and scrolls with ←→, the same step the route
pathline one tab over uses. The marks that say "more this way" sit
beside the window rather than over its first cell, so every window the
lane can settle on starts on a byte boundary: the step is four whole
bytes, and a key read from a mid-byte cut spells it wrong. The shift
clamps against the width actually being drawn, to the first step that
brings the tail into the lane, so scrolling can never run off into empty
lane and a resize only pulls an out-of-range shift back in.
The lane is pinned chrome, not a cursor row — walking down to the action
rows leaves the reader's window where they put it; only leaving the tab
drops it. Both the keys and the footer atom naming them are gated on the
key actually overflowing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.
So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.
A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.
The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.
Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where two adjacent chips are opposite ends of one axis, the up/in/more end
now takes the right slot: the map reads Zoom - then Zoom +, the shared lane
Bottom/Top and Page down/Page up, and the chat's relabelled jump pair follows
with Latest/Oldest. The actions move with their labels, so one handedness
carries across every screen and F1 always means the near end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.
The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.
Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.
Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Street names got rarer the further you zoomed in, which is backwards.
A line label anchored to the midpoint of the feature as the *tile* drew
it -- a fixed geographic point, not a point in your view -- and the
tighter the view, the less likely that point was still on screen. Of the
265 named streets in the Montréal fixture, 37 anchors landed on a 53x26
canvas at zoom 14 and 6 at zoom 16, of which 2 survived collision.
The line is now clipped to the canvas first and the label goes on the
longest piece that survives, so the anchor is always somewhere you are
actually looking. Clipping rather than testing the endpoints matters:
at high zoom a street often crosses the whole view with both of its ends
outside it, and that is exactly the street you most want named. The
next-longest pieces are kept as alternates and tried in turn when the
first spot is already taken, so a street whose middle sits under a node
marker still gets named further along. A feature with nothing on screen
queues nothing at all.
That made a second bug visible: OpenStreetMap splits a long street into
several named features, and once each one anchored to its own visible
stretch, René-Lévesque was drawn twice on one screen. A name is now drawn
once per frame -- on a terminal's worth of columns the second copy only
ever takes space from a street that has none.
Zoom 16 goes 2 -> 6 street names at 53x26 and 2 -> 8 at 72x24; zoom 15
goes 10 -> 13. Waterway labels take the same path and improve with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map's decoded-tile dict was never evicted from, so it grew for the
life of the screen with every tile the view had ever touched. Measured on
the Lyra, a decoded tile is ~0.9 MB resident (62 bytes a point) against a
device with ~100 MB in total and, in practice, under 20 MB free -- so a
long enough pan was an out-of-memory kill waiting to happen. It predates
the caching work, but making tiles cheap to load makes it easier to reach.
The budget is relative to the view rather than a fixed number, so it
scales itself: twice what the current viewport needs, floored at 8. That
lands around 8-12 tiles on the PicoCalc's 53x26 and grows on a desktop
terminal, without either one needing to know about the other.
Two details worth keeping. Tiles on screen are moved to the end of the
order before the trim runs, so eviction starts at the far end and can
never take something being drawn. And only entries holding geometry are
counted or dropped: a None is the memory of having *asked* -- the tile was
absent, or the source never answered -- which costs a dict slot rather
than a megabyte, and dropping it would buy nothing back while costing a
re-request on the next repaint.
Getting this wrong is now cheap, which is what makes a tight budget the
right call: an evicted tile comes back from the decoded cache in ~25-50 ms
having already been parsed once, so the cache is trading a little memory
for instant back-panning, not for correctness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layer narrowing cut what a tile costs to parse; this stops us paying
it twice. Decoding is a pure function of the bytes and the layer set, but
the cache only held the bytes, so every session re-parsed the same tiles
from scratch -- and parsing is the single most expensive thing this app
does.
A decoded/ sidecar now sits beside the raw tiles holding the parsed
layers, via marshal: stdlib, C-speed, and it understands the plain
tuples and scalars a decoded tile reduces to. Deliberately not pickle --
this reads a file off disk, and marshal cannot be talked into importing a
module or calling a constructor.
The sidecar is derived data throughout, and the design leans on that. It
is never what makes a tile "known", so losing it costs only the decode it
was saving. Every way it can fail to be what this build expects -- bumped
wire version, a Python whose marshal differs, a truncated file -- reads
as a cache miss rather than an error. It carries a stamp of the layer set
it was decoded under, so widening DRAWN_LAYERS re-decodes instead of
serving a narrower blob. And because it runs ~2.4x the size of the bytes
it came from, it is the half of the cache that gets a budget: 64 MB, LRU
by mtime, swept once per 8 MB written rather than per tile, since the
walk is slow on the SD card the PicoCalc runs from.
Measured on the device over the 50 real cached tiles: a first session
costs 126 s and a later one 13 s, 9x, with all 50 tiles restoring
identically. Disk goes 7.8 MB -> 7.8 + 19.0 MB. Serialising adds ~13% to
the first decode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.
Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.
Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.
Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.
_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fetch failure — offline, DNS, timeout, reset, 5xx — collapsed into
None and then into b"", which the loader wrote to disk as "this tile is
legitimately empty". One 12-second Wi-Fi stall on the PicoCalc therefore
poisoned that tile for good: every later pan and every later session read
the zero-byte marker and drew a black square. A body cut short mid-link
did the same by another door, cached and undecodable ever after. The PC's
network simply never flaked long enough to trip it.
So the cache now holds only tiles that decode to real geometry. A GET
returns whether the source actually answered: a 2xx body or a 404 is an
answer, everything else is silence and is never written down, so the next
pan asks again. A body short of its Content-Length is truncation, not a
tile. A tile the source really calls empty is remembered in memory for
the session alone — no repeat requests while the map is open, no
permanent hole on disk.
Cached entries that no longer decode are pruned on read, so a cache
already poisoned this way repairs itself as you pan over it. Writes go
through a temp file and os.replace, since the map and a mini-map share
one source and can fetch the same tile from their own threads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.
The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.
Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP's spec for the heard-age scale, replacing the four-step ladder: white
under 5 minutes, then yellow at 5m, light red at 1h, brown at 1d, red at
1w, light grey at 1mo, and one cold grey for over a year — which is also
"never heard", since past a year the distinction stops earning a colour.
The `heat.*` styles are renamed for what they describe: a node heard
eight minutes ago is `heat.minutes`, one heard eight days ago is
`heat.days`. The old hot/warm/cool/cold read as temperature but had to
carry an age, and at seven rungs that stopped being sayable. The steps
themselves move into a table, so the ladder is one thing to read.
Three of the seven land in the VT's dim bank, so they say `not bold` — a
selected row would otherwise jump them a rung (brown to yellow, red to
light red), which is the one way a heat colour can lie about an age.
The splash wordmark's vertical ramp becomes white / yellow / light red /
red / light black, each picked to quantize onto exactly one palette slot
(15, 11, 9, 1, 8) while staying a warm fade in truecolour. Its rows are
art rather than renderables, so they had never passed through the fold
and the console was downsampling them on its own — they do now, which is
what makes the slot choice hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reports on the PicoCalc's message paths, one root cause each.
The graph drew an unnamed hop's hash — and the sender's name — as white,
the "you" colour. MapCanvas emits its own bold for markers and labels,
and its colours arrive already quantized to a palette slot; the kernel VT
then draws bold as *brightness*, so anything that landed in the dim bank
was promoted to its bright partner. Light grey became white. A purple
repeater would have become pink the same way. The canvas now drops
emphasis where bold is brightness — the colour is the load-bearing part.
The same trap reaches the theme through inheritance: Rich merges a row's
base style into every span, so a dim-slot span left silent about bold
changes colour inside a selected row. Every dim-slot style now says which
it means — `not bold` to keep its colour, `bold` where the promotion is
the point (only title.muted) — and a test forbids staying silent.
The pathline drew the same hop dark grey, because `muted` collapses onto
the console's dark grey while the desktop's is light. That conflated two
things: `muted` is chrome and may recede a step, but a node we can't
identify is content you can still act on. It gets its own name,
`node.unknown` — light grey, identical to today's desktop muted, so
nothing moves there — and it is what `name_style` returns keyless, what
an unlit `highlighted_hash` wears, and what the `○` ring is.
Also fixes a crash from the previous commit: the graph's label colour
resolved a marker through `parse_hex`, which the type marks' theme names
are not. A repeater relay we couldn't name would have raised.
The gallery's node-detail header spelled a marker hex by hand; it goes
through the app's own styles now, so a palette bug can't hide behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.
node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.
The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.
With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane read as a keyboard: Top, End, PgUp, PgDn — the names of keys
this keyboard doesn't even have, standing in for whatever the screen did
with them. A chip's job is to name the action it performs *here*.
The shared lane now speaks the vocabulary of the one thing it assumes —
a scrolling body: Top, Bottom, Page ↑, Page ↓. The chat speaks its own,
since neither end of a conversation is a "top": Oldest reaches back to
the first message, Latest returns to the newest and the compose line.
The map already got Reset and Zoom ±.
Empty and dim now make different claims. Dim says "a thing here, just
not right now"; empty says "not a thing here at all" — so a channel
chat's F8 carries no Retry, because a channel message is never
acknowledged and there is nothing a retry could mean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The direct-message path view looked for frames of class TXT_MSG. The
meshcore library reports that class as TEXT_MSG — the spelling every
other reader uses (core.frames, the packet viewer's lane, the dashboard's
traffic keys) — so the filter matched nothing, and every direct message
reported an empty window no matter how much of the conversation the radio
had overheard. Its test fabricated rows under the same wrong name, so it
passed against a mesh that can't exist.
With the class fixed, the evidence can also be far better than "±90 s of
every direct frame we heard": a TEXT_MSG frame is encrypted, but its
addressing is not — both ends ride in the clear as one-byte key hashes,
and core.frames has been recovering and storing them all along. The
window now narrows to frames running between this conversation's two ends
in either direction, and the summary bills it as matched by address and
time. A frame whose addressing was never recovered drops out rather than
being counted: paths are evidence, and a hash that isn't there proves
nothing.
Still not a content match — a one-byte hash collides — so the view keeps
its warn-coloured summary and makes no claim it can't back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
^P used to fall back to the last message in the transcript when nothing
was picked. A path is the route one particular message walked, so that
fallback showed the paths of whichever message happened to sit at the
bottom — never something the user asked for, and easy to mistake for the
paths of the message they were looking at.
^P now opens the pick and nothing else. The footer drops its ^P atom
while nothing is picked, and the F-key lane dims F3 Paths on the same
gate, so neither offers a key that does nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PicoCalc lane replaces the footer hint outright, so it inherits the
hint line's standing rule — never name a key that would do nothing. Until
now it named several: Retry with nothing unacknowledged, the paging pair
on a body that already fits whole, and, worst, the map's End, bound to
nothing at all while PgUp/PgDn were labelled as paging when they zoom.
An FPair now carries enabled/opp_enabled per bank. A cleared slot keeps
its label and drops its fill, landing in the same muted "nothing to
press" class as an unassigned one — so the key still reads as what it
would do, it just plainly isn't live. The dimming stays presentational:
action_for still resolves a dim slot, and the screen's own handle()
remains the authority (it no-ops), so a lane built from stale metrics can
never swallow a key that works.
Applied where a screen can tell honestly: default_lane(nav=…) for the
shared slots (result windows and the dashboard gate on content_overflows,
the chat on having a transcript), the chat's Retry on there being an
unacknowledged direct message, and the map gets its own lane — Reset,
Zoom +, Zoom - — with F2 left unassigned rather than dead. The chat's
regular-platform hint drops ^R on the same gate.
compose_base now takes the lane as a builder and calls it after the body
renders, so those gates read this paint's scroll metrics instead of the
previous paint's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:
Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).
Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).
Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.
Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.
Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header's unread badge summed every conversation the recorder filed a
message under, but the picker only builds rows from what the *device*
reports — its channel slots and the messageable contacts in its table.
Anything outside that set was counted and unreachable: no row to open,
so no way to clear it. The badge sat there for the rest of the session
saying there were new messages while the Chat list showed none.
The badge is a pointer, not a tally — its job is to send you to a thread
you can open — so it now counts only what the picker can list. The rest
is still recorded to history, just silently. In practice what that drops
is machine traffic rather than correspondence:
* a direct message from a node that isn't a DM recipient — a repeater,
room server, or sensor. Every remote-CLI reply from a repeater you
administer arrives this way, so an admin session left a stuck badge;
* a direct message from a sender the contact table doesn't hold;
* a channel message the device has no configured slot for, including
one whose slot couldn't be identified at all.
Muting joins those under one rule instead of standing alone, and the
device is consulted through the session cache, so a message costs no
extra round-trip. Anything the cache can't answer notifies: a cold or
broken cache is a reason to over-notify, never to swallow a message.
That leaves one real divergence to fix rather than paper over. The
recorder resolves a channel slot fresh per message while the screens
read a session-cached list that only an in-app channel edit drops — so
a channel re-keyed or cleared on another client (the phone app) had the
picker listing the old one all session, and now would have silenced the
new one too. Resolving a slot to a different identity drops that cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP's clarification: a lane slot is for functionality that's
otherwise hard to reach, not a shortcut to a key already close at
hand. Enter and Esc sit right on the keyboard, so they never
occupy a slot; PgUp/PgDn have no physical key at all, so they take
over the prime F4/F5 pair from OK/Back. Top/End (Fn-layered) keep
F1/F2. F3 stays the lone slot a screen promotes its own fiddly
Ctrl-chord onto (chat's Paths/Retry, contactlist's Sort already
follow this and needed no change). Default Shift bank is now
empty across the board — pure overflow, per the original spec.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Rebuild the PicoCalc F-key lane per JP's spec: five fixed 9-cell
chips (F1 DESCRI, "10" standing in for the overflow F10 label),
gray fill on the plain F1-F5 bank and green while Shift's bank is
showing, white text on both. DEFAULT_LANE moves to an
adjacent-plain-key priority model: commit/leave take the easiest-
to-reach F4/F5 with no Shift needed, jump-to-top/end take F1/F2
with paging as their Shift companions, and F3 stays the one lone
slot a screen's own lane reaches for first. FPair's Shift half is
now optional, so a lone slot renders blank in the shifted bank
instead of forcing an unrelated opposite action onto it.
Also bridges the on-device quirk where the MCU's F6-F10 translation
can appear to release Shift an instant before the translated code
lands, flipping the lane back mid-chord: modifier_watch now latches
shift_down() true for a short grace window after any F6-F10 press
resolves, on top of the raw watcher state.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The console keeps its stock kernel palette - black background, classic
PC hues - instead of the custom setvtrgb remap. MESH_THEME_16 is
re-tuned against the stock slots, which hands back the map's own
language for free: clients wear bright pink (13), repeaters purple (5),
rooms cyan (6), sensors brown (3). The cost is one dark grey - muted,
faint and track all collapse onto slot 8. Heat quantizes on plain human
units (15 min / 1 h / 1 day). The custom design is archived, not
deleted: theme._VT_SLOTS_CUSTOM + MESHTERM_CUSTOM_PALETTE=1 on the font
script reinstalls it, with the parity test keeping the two in sync.
Undoing the remap on the device exposed a trap: setvtrgb overwrites the
kernel's DEFAULT colormap, so the OSC palette-reset "resets" straight
back to the custom values - the script's default mode now programs the
stock palette explicitly (verified live; specimen screenshot in
.claude/plans/p3-specimen-stock.png).
The header also follows JP's spec: the handheld brands the app -
"MeshTerm vX" - instead of naming a soldered port that never changes,
and the activity pulse takes whatever room remains.
Also root-causes the intermittent suite flake: Rich memoizes a parsed
Style's ANSI codes on first render (Style.parse is globally cached), so
a raw hex style first rendered by a truecolor console replays 38;2
verbatim on a standard console later in the process. The specimen's
nodes row now wears the type.* theme styles - which is also just
correct: the marks now match the names row. Six consecutive clean
full-suite runs.
Deferred at JP's request: the glyph substitution map (he will redo it),
the F-key lane design (awaits his explicit spec), the CLI fold gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The P1 xfail list (contacts, map, walk, livefeed, remote_cli,
path_composer) turned out to be footer_hint overflows plus one real
body bug - and picocalc never draws the hint strings at all since P4's
F-key lane replaced them. So:
- The gallery asserts the footer actually drawn per platform: the hint
string on regular, the screen's F-lane (both banks) on picocalc.
- path_composer's wrapped empty-state note emits one entry per wrapped
line instead of smuggling a newline into a single windowed row - the
one genuine 53-column body overflow.
- _KNOWN_WIDE is empty: all 32 picocalc gallery cases (16 screens x two
geometries) are now hard gates. Only the pre-existing regular map
footer xfail remains, documented as not this seam's to fix.
- First per-screen F-lane verbs, proving the override pattern: the
contact list puts sort cycling on F3/F8, chat puts paths/retry there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BatteryService grows the host provider (Platform.battery="host"): the
PicoCalc's own pack through the kernel power_supply driver - a true
percent and charging flag from sysfs, no voltage-curve estimation, no
radio connection needed. Unreadable supply = absent, gauge hidden.
- The console-font script now builds TWO 512-glyph PSF2 fonts: the 6x12
Terminus-based default (53x26) and a 6x8 A/B candidate (53x40) based
on the kernel's own font_6x8 (CP437 coverage - documented as having
no Cyrillic), with the braille block at 2px dot pitch and all 18
marks redrawn for the shorter cell. Built and verified live on the
Lyra: stty reports 53x40 under meshterm8, specimen legible
(.claude/plans/p5-specimen-6x8.png); flip with setfont, persist the
winner in vconsole.conf. JP judges the A/B by eye.
- calculinux-setup.sh puts the deploy user in input/dialout/video
(input is the modifier watch's /dev/input access).
- theme's platform binding now drops the fold table and cache on every
switch: its emoji pads bake in cell_len at build time, which the
regular platform's emoji-width calibration can re-measure - a stale
table survived platform swaps in randomized test runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- compose_base grows a borderless mode (Platform.frame_border=False): a
one-row title bar (rule + title + clip arrows) replaces the Panel's
border and padding, so the body wins back four columns and one row on
the 53-column console. Regular keeps its Panel untouched.
- The header composes only Platform.header_atoms: picocalc drops the
version mark and the activity pulse, keeping device, badges, battery.
- The F-key lane (ui/tui/fkeys): five FPair slots per screen, F1-F5
primary and F6-F10 their opposites - which is physical Shift+F1..F5,
translated to plain keycodes by the keyboard MCU. The session binds
all ten and resolves a press against the top screen's lane into a
normal screen action, so screens gain F-keys with no key handling of
their own. Default lane duplicates the Fn-layered nav cluster plus
OK/Back. Alt+Fn stays unbound (kernel VT switch).
- services/modifier_watch: a hand-rolled evdev reader (no keyboard-lib
dependency) tracking physical Shift so the lane flips to the F6-F10
labels mid-chord; engages only when the platform asks and the device
exists, degrades to the static lane on any failure.
- The popup-qualifying width gate (surface._dialog_max_cells) shrinks
to 43 on picocalc, matching the 47-outer dialog cap; the dialog frame
itself already clamps to cols-6, so prompt.py needed nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:
- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
rewritten against color(0..15) with the VT's bold-as-brightness and
dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
so theme, rasterizer and widgets swap implementations once per switch
and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
(NFKD accent folds - the Terminus base has no accented Latin - plus
symbol stand-ins and the width-padded emoji map), an embedded-SGR
quantizer for canvas truecolor, and a ?-at-own-width catch-all that
makes the no-wide-glyph short-circuit true by construction. Applied at
the only two places every visible byte passes: render_to_ansi and
MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
the gallery now asserts rendered picocalc ANSI carries no truecolor/
256-colour SGR and no character outside it - the parity gate the
first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
donor misses now fatal and a keeper canary (donating pi once erased
Cyrillic pe - shared slot). Table re-dumped on-device: identical to
fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
no longer import ui), fed from monitor observations, heard_nodes and
merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
marks, not the banned emoji pair.
Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the PicoCalc visual system:
- MESH_THEME_16: 16-slot palette with deliberate RGB values for truecolor=False
- make_console(): picks theme based on platform.truecolor
- glyph(): central emoji→single-BMP-char mapping for the 512-glyph font
- Type-based name_style() and register_node_type() for PicoCalc colour (key→type)
- Text folding: fold_text() with NFKD accent stripping and emoji→placeholder
- Platform-aware icon rendering in packet_viewer, dashboard_screen, courier_screen
- Reception layer registers node types for colour lookup
- Gallery tests check truecolor constraint on PICOCALC
All 1239 tests pass; 13 xfail (P6 width worklist).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).
The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.
Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.
Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.
That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.
The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.
tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>