From 1cc411e5dfc166695eca4eb006138ddd42e69cf5 Mon Sep 17 00:00:00 2001 From: R0ck Date: Tue, 1 Sep 2026 17:16:47 +0100 Subject: [PATCH] skills: the three skills are checked against the tree they describe A skill that states something no longer true is worse than one that says nothing, because an agent acts on it with confidence and does not go and look. Every specific claim in the three was read against the current tree, and a surprising number had moved. The worst were not stale but invented: the driving skill's workflows told an agent to run evaluate_link, reception_ledger, find_sites and energy_forecast, none of which has ever existed. They are replaced by the verbs and subcommands that do, and where a capability is genuinely unreachable from outside, the skill now says so: internal/study/planning has no verb, and a link budget computed by link.pair reaches no reply, no snapshot and no client. What else moved. Refusals are the big one: a parameter the workbench cannot understand is now refused with a code from a closed set rather than quietly replaced, so "it answers no by returning a value" is only half the story and the half that is left is where scripts still die. Terrain now waits for consent, and a held warm does not hang, it finishes the links job as failed and measures nothing, which is the failure a skill exists to warn about. Five verbs that answered with a count now return their rows, and four still do not, so the skill names those four instead of claiming the class was closed. Miss causes gained collision, receiver-busy and unclassified, and floor stopped being the catch-all, which changes what code matching on floor now sees. The emulator toolchain is a download, so the advice that a board cannot be run locally is gone. ESP32-S3 and nRF52 are emulated, and the claim that only plain ESP32 and only repeaters could be was two releases out of date. On counts: verbdoc owns them, so the skills cite the generator rather than carrying a number nothing checks. Several written-down verb counts have already gone stale, which is the argument. In the interface skill, Panel.InWindowMenu and "Show all panels..." are gone, every panel names its own menu instead; the audit's targets moved to their own file; and the two /tmp scripts it told an agent to use are machine-local and have no committed replacement, which is worth saying plainly rather than pointing at a path nobody else has. The meshcoresim skill's front-matter name said "meshbench", which matched neither its directory nor anything an agent invokes. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/README.md | 38 +- .claude/skills/meshbench-scripting/SKILL.md | 201 ++++++++--- .claude/skills/meshcoresim/SKILL.md | 381 +++++++++++--------- .claude/skills/wb2-design-language/SKILL.md | 152 ++++++-- 4 files changed, 521 insertions(+), 251 deletions(-) diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 8c347f3d..8698f8be 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -1,7 +1,9 @@ # MeshBench agent skills The skills an agent loads when working in this repository. Each is a directory -with a `SKILL.md` whose front-matter `description` says when it applies. +with a `SKILL.md` whose front-matter `description` says when it applies. The +front-matter `name` matches the directory, because the directory is what an +agent invokes and a `name` that says something else is a label nobody sees. | skill | for | |---|---| @@ -14,11 +16,31 @@ with a `SKILL.md` whose front-matter `description` says when it applies. So they can be installed into any agent, not only one working in this repo, the skills are mirrored into two standalone repositories: -- **[meshbench-scripting-skills](https://github.com/MeshBench/meshbench-scripting-skills)** - — `meshbench-scripting` and `meshcoresim`: driving and scripting the tool. -- **[meshbench-dev-skills](https://github.com/MeshBench/meshbench-dev-skills)** - — `wb2-design-language`: developing the tool. +- **[meshbench-scripting-skills](https://github.com/MeshBench/meshbench-scripting-skills)**: + `meshbench-scripting` and `meshcoresim`, driving and scripting the tool. The + second is installed there under the directory name `meshbench-driving`, + which is the one difference between the two trees. +- **[meshbench-dev-skills](https://github.com/MeshBench/meshbench-dev-skills)**: + `wb2-design-language`, developing the tool. -**The copies here are canonical.** When one of these skills changes, update the -mirror repository in the same breath, or the installed copies go stale — the -mirror READMEs say the same from their side. +**The copies here are canonical.** The mirroring is a manual copy: nothing +generates it, nothing checks it, and a mirror has already been found a line +behind. When one of these skills changes, update the mirror repository in the +same breath, and diff the two before believing they agree. + +The docs site explains what each skill is for and how to install it into +Claude Code, Cursor, VS Code, Gemini CLI and Codex: +. + +## The register they are written in + +A skill is not reference material. `docs/scripting-verbs.md` already lists every +verb; a skill earns its place by saying what an agent would get wrong without +it, with the reason attached, so the rule survives the case it did not +anticipate. + +Two consequences worth keeping. **A line that states a fact must name what +settles it**, a file, a verb or a command, because a stale skill is acted on +with more confidence than no skill at all. And **a count nobody generates does +not go in**: cite `tools/verbdoc/verbdoc.py`, or ask the running session, rather +than typing a number that will be wrong in a month. diff --git a/.claude/skills/meshbench-scripting/SKILL.md b/.claude/skills/meshbench-scripting/SKILL.md index 163a23a6..4d8bbf25 100644 --- a/.claude/skills/meshbench-scripting/SKILL.md +++ b/.claude/skills/meshbench-scripting/SKILL.md @@ -1,30 +1,79 @@ --- name: meshbench-scripting -description: Write and debug scripts that drive the MeshBench workbench from outside — the Python or Go client, the control socket, or raw verbs. Use when writing an example, a CI regression check, a soak driver, or anything that opens a session, brings a mesh up and waits for it. Encodes what twenty-four silent failures taught, and the shape of the ones still out there. +description: Write and debug scripts that drive the MeshBench workbench from outside, with the Python, Go or Node client, the control socket, or raw verbs. Use when writing an example, a CI regression check, a soak driver, or anything that opens a session, brings a mesh up and waits for it. Encodes what twenty-four silent failures taught, and the shape of the ones still out there. --- # Scripting the workbench -Two clients — `pkg/client-python/meshbench` and `pkg/client-go/meshbench` — over a -line-delimited JSON socket. They are peers; neither wraps the other. Examples -live beside each, one per directory, and `go build ./...` compiles the Go ones -so a broken example is a red build rather than something somebody finds by -trying it. +Three clients over one line-delimited JSON socket: `pkg/client-python/meshbench`, +`pkg/client-go/meshbench`, `pkg/client-js`. They are peers; none wraps another. +Python and Go ship the same seven examples, one per directory, and +`go build ./...` compiles the Go ones so a broken example is a red build rather +than something somebody finds by trying it. + +Read `docs/driving-the-workbench.md` for the verb-level story, and +`docs/scripting-verbs.md` for every verb and the call that covers it. This is +about writing something that works. + +**Never write down how many verbs there are.** `tools/verbdoc/verbdoc.py` +generates the counts into `docs/scripting-verbs.md` and `docs/scripting-api.md` +and CI runs it with `--check`; ask `session.verbs` at runtime, or cite the +generator. Several numbers typed by hand have already gone stale, and a number +nothing checks is worse than no number. + +## The one rule, in two shapes + +**A parameter the workbench cannot understand is now refused. Everything else it +declines, it declines by returning a value.** + +Refusals travel as `{"id":…, "error":"", "code":"…"}`. +The code is the closed set in `internal/app/control/codes.go`: `bad_params`, +`not_found`, `conflict`, `unavailable`, `unknown_verb`, `closing`, +`protocol_mismatch`, `unauthorised`. **Branch on the code, never on the prose** - +the message is deliberately the verb's own wording and will be reworded. Python +raises a subclass of `Refused`; Go returns a `*meshbench.Refused` that +`errors.Is` matches against `ErrBadParams` and friends. + +That half is new, and it closed a whole class: `nodes.select_many` handed +`{"names": […]}` used to match neither branch of a type switch, deselect +everything, and answer `{"selected": null}` as though it had worked. It now +refuses, names the shape it takes, and on an unknown node lists the nodes that +exist. A `nil` parameter still legitimately clears the selection. + +The other half has not changed, and it is still where scripts die. Twenty-four +faults were found by running the seven examples end to end and **every one was +silent**. A verb that answers "I did not do that" with a perfectly valid result +turns into a script that sits there doing nothing, and "it hangs" is what gets +reported, about the workbench rather than about the script. -Read `docs/driving-the-workbench.md` for the verb-level story. This is about -writing something that works. +So: **read the reply of anything that looks like a command**, and when a wait +times out, suspect the premise of the wait before you suspect the simulator. -## The one rule +## Terrain is not downloaded until somebody says yes -**The workbench answers "no" by returning a value, not by raising.** +A fresh machine has never been asked, and the preference +(`terrain_downloads` in `~/.config/meshbench/workbench2.json`) has three states, +not two: allowed, refused, never asked. Until it is answered, the tile store is +offline, `terrain.prefetch` refuses, and the first warm is held. -Twenty-four faults were found by running the seven examples end to end. Every -single one was silent. Not one raised. A script that ignores a reply turns a -clear refusal into a script that sits there doing nothing, and "it hangs" is -what gets reported — about the workbench, not about the script. +**A held warm does not hang, which is worse.** It marks the `links` job finished +*and failed* with "waiting for permission to download terrain", then sets the +session warmed so nothing is left in flight. A script that opens a fixture and +calls `wait_idle` therefore returns almost at once, with no jobs running and +**zero links measured**, and every study downstream runs over bare earth, which +is the most optimistic answer available. Nothing raises. -So: **read the reply of anything that looks like a command**, and when a wait -times out, suspect the premise of the wait before you suspect the simulator. +So a script that needs terrain does one of: + +- call `terrain.allow` (the `on` parameter defaults to true) and wait for the + warm it restarts, or +- wait on the `links` job specifically and check its `failed` flag, which is the + only place the sentence surfaces, or +- open nothing: launch with `-fixture ""` when the measurement does not want a + network. + +There is no environment variable and no CLI flag for this. Headless is not +exempt: `cmd_headless.go` loads the same preferences the window does. ## Bring a run up with the client, never with sim.start @@ -38,7 +87,7 @@ times out, suspect the premise of the wait before you suspect the simulator. | otherwise | plays | `{playing: true}` | Only the last is what a script means, and it starts firmware only when *no* -node is running — so a mesh where you pinned a build onto two nodes is +node is running, so a mesh where you pinned a build onto two nodes is "started" with the other fifty-six down. Use `wb.sim.start()` / `Sim.Start(ctx)`, which wait out the warm, call @@ -48,12 +97,18 @@ must drive it by hand, do those three things in that order and check each. ## Waits, and the premises that make them lie - **Wait for the fixture, not just the socket.** The windowed build opens its - fixture on a worker so the window appears first. `launch(fixture=…)` now - waits for nodes to exist; anything else you write should too, or `wait_idle` - returns in 0.00s having waited for work nobody had queued. -- **`wait_idle` ignores finished jobs.** Some jobs are removed when they end - and some are only marked. Waiting for the list to *empty* waits for ever on - half of them. + fixture on a worker so the window appears first. `launch(fixture=…)` waits for + nodes to exist; anything else you write should too, or `wait_idle` returns in + 0.00s having waited for work nobody had queued. +- **A finished job is sometimes removed and sometimes only marked.** + `job.progress` with `finished` keeps the row (`infer.run`'s is one of those); + `job.done` deletes it. Waiting for the list to *empty* waits for ever on half + of them, which is why both clients filter on the flag. `job.list` returns the + rows with a `running` count and drops finished ones unless you pass + `all: true`; note that both shipped clients still read the session snapshot + rather than that verb, so a change to one is not a change to the other. +- **A finished job is not a successful one.** Check `failed`. A read that could + not reach the feed, and a warm held for terrain consent, both end the job. - **Idle is not measured.** A warm that stopped to ask permission to download terrain finishes its own job row, so `wait_idle` returns in a moment having waited for nothing and no link was measured. `sim.state` answers @@ -69,8 +124,48 @@ must drive it by hand, do those three things in that order and check each. everything. - **A diagnostic can cost more than the thing it diagnoses.** `nodes.stats` is a `/proc` read per node. Calling it every poll during firmware startup timed - the socket out. Enrich a wait's message rarely — every ten seconds, not every - fiftieth of a second. + the socket out. Enrich a wait's message rarely, every ten seconds rather than + every fiftieth of a second. + +## Ask the session what it is missing + +`setup.check` is read-only, touches no network, and answers with four groups of +rows, each `{name, state, what, cost, where, do, verb, params}` over the states +`ready | needed | missing | undecided | blocked`. It covers what kind of build +this is, what firmware is installed, whether terrain has been answered, and +which emulator tools are present. A row carries the verb that would fix it, so a +first-run script can read the check and act on it rather than guessing at +`~/.cache`. `needed + undecided > 0` is what makes the workbench open its Setup +page unprompted, three seconds in. + +**`session.list` answers what is running before you attach to anything.** In +the clients it is a module or package function rather than a method, precisely +because the question comes before a connection. Liveness is proved by dialling +the address: a unix socket file outlives the process that bound it, and a pid +gets reused, so a script that checks either can attach to a corpse. That is +also the verb to reach for when one run is about to trample another, because +one mesh at a time is a real constraint on this machine. + +## Counts where the rows were the question + +Five verbs answered with a number and left the rows where only a panel could +reach them. All five are fixed and keep the old count key beside the new list: +`nodes.stats` (`stats`), `firmware.library` (`builds`), `console.read` (`tail`), +`boundary.list` (`areas`), `resource.list` (`resources`). + +**Four are still out there**, and the rows genuinely cannot be reached from a +script, because `session.snapshot` publishes counts for exactly these: + +| verb | answers | where the rows go | +|---|---|---| +| `budget.for_selection` | `{budgets: n}` | `World.Budgets`, in no summary and in neither client | +| `sweep.run` | `{arms, seeds}` | `World.Matrix`, read by one panel | +| `schedule.add` | `{sends: n}` | `World.Sends`; there is no `schedule.list` | +| `assert.add` | `{assertions: n}` | `World.Assertions`; `assert.check` does return `results` | + +The shape is worth recognising: **if a verb's reply is an `int` where you wanted +a list, look for a second key before assuming the data is not there** - and if +there is no second key, say so rather than working around it silently. ## Two consoles, and there is no `send` @@ -85,8 +180,9 @@ directly, pick by kind yourself. The vocabulary is `advert`, `floodadv`, `public `, `chan `, `infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`. **There is no `send`.** -`console.read` returns the lines under **`tail`**; `lines` is how many there -are. Reading `lines` hands you an integer where you asked for text. +`console.read` returns the lines under **`tail`**, capped at the last 200; +`lines` is how many there are. Reading `lines` hands you an integer where you +asked for text. Use `ask()`, not `send()` then `read()`: a node reads its serial input on its next loop and its loop only runs when the engine steps, so reading straight @@ -98,21 +194,13 @@ Verbs are keyed on the **application name**, as MeshCore names its example directory: `simple_repeater`, `companion_radio`, `simple_room_server` (plus `_usb` / `_ble` for board images). Use the `Role` enum. -The published catalogue spells some of the same things differently — +The published catalogue spells some of the same things differently: `repeater`, `room-server`. Those belong to release assets. Pin a build under one and it is installed, visible, and never run by anything. `Firmware.Download`'s role is the *asset* name and is deliberately a plain string. Everything else takes `Role`. -## Counts where the rows were the question - -Four verbs answered with a number and left the rows in the snapshot where only -a panel could reach them: `nodes.stats`, `firmware.library`, the study area, -and `console.read`. All four are fixed, and the shape is worth recognising — -if a verb's reply is an `int` where you wanted a list, check for a second key -before assuming the data is not there. - ## Enums, not strings `Kind`, `Board`, `Preset`, `Role`, `Class`, `Tab`, `Strategy`, `Transport` are @@ -120,40 +208,55 @@ generated by `tools/clientgen` from the tree, so both clients agree and CI fails when they drift. Never spell one as a free string: a board name nothing matches produces a different node, silently. +`Class` is the miss-cause set, and it grew: `sent`, `received`, `half-duplex`, +`interference`, `collision`, `receiver-busy`, `floor`, `unclassified`. Code that +matched on `floor` as the catch-all now silently sees fewer of them. Group an +`events.dump` NDJSON file on the `class` field, never on the detail sentence. + ## Running examples on this machine - **One mesh at a time.** Two 58-node fixtures at once will make the socket time out and look like a deadlock. I lost two debugging cycles to this. -- Point `MESHBENCH_BINARY` at the build under test; both clients honour it. +- Point `MESHBENCH_BINARY` at the build under test; the clients honour it. - Unix socket paths are capped at 104 bytes. The scratchpad path is longer than that, so let the client choose the address. -- A killed run can leave a QEMU emulator behind. Check `pgrep -f qemu-system`. +- **An emulated board no longer needs a hand-built toolchain.** + `resource.fetch` downloads `radioserver`, `qemu-system-xtensa` and `renode` + into `~/.cache/meshbench/tools/`, which is where a boot already looks, so no + environment variable is needed afterwards. **Pass `kind: "toolchain"`**: the + parameter defaults to `softdevice`, and a fetch that omits it asks for the + wrong thing. QEMU and Renode are published for linux/amd64 only; macOS gets + `radioserver` alone and Windows nothing, and `resource.list` says which with + a reason. +- A killed run can leave an emulator behind. Check `pgrep -f qemu-system`. - Firmware roles on disk: `ls ~/.cache/meshbench/firmware/native/`. ## Debugging a script that has stopped In this order, because each step invalidated a theory today: -1. **What else is running?** `pgrep -af meshbench`. Load, not logic. +1. **What else is running?** `pgrep -af meshbench`, or `session.list`. Load, not + logic. 2. **Attach and ask.** A second client can attach while the first is stuck: - `describe`, `firmware.state`, `jobs`, `nodes.stats`. That is how the - `56 of 58` and the unfinished warm job were both found in seconds. + `describe`, `firmware.state`, `job.list`, `setup.check`, `nodes.stats`. That + is how the `56 of 58` and the unfinished warm job were both found in seconds. 3. **Reproduce the exact sequence, timed.** Proving the general case healthy says nothing about the specific one. A general responsiveness test said the windowed workbench was fine; example 5's actual sequence was not. -4. **Then read the verb.** Not before — twice today the code looked correct and +4. **Then read the verb.** Not before: twice today the code looked correct and the behaviour was not. ## Defects queue behind each other -Today's chain ran four deep: `sim.start` ignored → firmware never up → traffic -never fired → the assertion could not pass → the fixture had no traffic to -fire. Fixing the outer one is what makes the inner one visible, so **"one more -fix and it will work" is a bad prediction.** Budget for the chain. +Today's chain ran four deep: `sim.start` ignored, so firmware was never up, so +traffic never fired, so the assertion could not pass, and the fixture had no +traffic to fire anyway. Fixing the outer one is what makes the inner one +visible, so **"one more fix and it will work" is a bad prediction.** Budget for +the chain. And the one that passed: example 2 exited 0, printed a cheerful summary, and had put a local build on a 311-node national network because it decided "first -run" by asking whether the session was empty — and a launched workbench never +run" by asking whether the session was empty, and a launched workbench never is. **A green exit code is not evidence.** Check the numbers say what the script claims. @@ -165,3 +268,7 @@ claims. goroutine and a worker both reach. - Nothing in CI brings a mesh up and waits for it. Green means it compiles and the unit tests pass; it does not mean an example runs. +- The live emulator tests skip unless `MESHBENCH_LIVE=1`, and they still gate on + `MESHBENCH_QEMU` or `PATH` rather than on the lookup a boot uses. A machine + set up entirely by `resource.fetch` therefore skips them while being perfectly + able to run a board. diff --git a/.claude/skills/meshcoresim/SKILL.md b/.claude/skills/meshcoresim/SKILL.md index 08c8389c..6929626d 100644 --- a/.claude/skills/meshcoresim/SKILL.md +++ b/.claude/skills/meshcoresim/SKILL.md @@ -1,24 +1,33 @@ --- -name: meshbench -description: Drive MeshcoreSim to answer RF and mesh-network questions — link viability, coverage, why a packet failed, site selection, solar survival, firmware A/B. Use when asked about MeshCore network behaviour, repeater placement, coverage, or radio settings. Encodes the honesty rules the simulator's own results depend on. +name: meshcoresim +description: Drive MeshBench to answer RF and mesh-network questions, such as link viability, coverage, why a packet missed, site selection, solar survival, firmware A/B. Use when asked about MeshCore network behaviour, repeater placement, coverage, or radio settings. Encodes the honesty rules the simulator's own results depend on. --- -# MeshcoreSim +# MeshBench An RF-accurate MeshCore simulator: real firmware, sample-accurate LoRa baseband, -real terrain. Plane project **MSIM**; decisions are ADR-0001…ADR-0018. +real terrain. Plane project **MSIM**; decisions are recorded as ADRs, in the +project's Pages and, where a decision is about the tree, under `docs/`. Load `plane-conventions` if you are also updating tickets. ## Driving it -It is a **native desktop app**, not a CLI or a service, so it needs a machine -with a display and it has to already be running. You drive the *running* app -over its control socket at +It is a **native desktop app**, not a CLI or a service, so the socket needs a +workbench already running (`meshbench workbench`, or `meshbench headless` for a +session with no window). You drive it over `$XDG_RUNTIME_DIR/meshbench.sock`, newline-delimited JSON, -`{"id":1,"method":"","params":{}}`. `session.describe` lists every verb; -read that before inventing a way to do something, because there is almost always -a verb for it. +`{"id":1,"method":"","params":{}}`. `session.describe` lists every verb +and `session.list` names the workbenches actually running; read the first before +inventing a way to do something, because there is almost always a verb for it. + +**Some questions do not need a session at all.** `meshbench link`, `profile`, +`coverage` and `terrain` are one-shot subcommands over their own tile store, and +they are the quickest route to a number. They do not read the workbench's +preferences, which is both why they work on a machine that has never opened the +app and why their defaults are their own: `meshbench link` defaults to +869.525 MHz, the **deprecated** preset, so pass `-freq 869.618` to compare with +anything the app produced. Prefer the verb over the file. Verbs drive the same code paths a person clicks, so the panel opens and the operator can see what you did; editing config or @@ -38,20 +47,35 @@ UDP stream on 127.0.0.1:5555 and launches Wireshark; it survives the engine rebuild each sweep run does, but a restarted workbench has no capture at all. "Wireshark shows nothing" after a restart means nobody started it. -## Building a scenario from CoreScope — the whole order +## Terrain is off until somebody allows it, and that is silent + +A machine that has never been asked runs with the tile store **offline**. The +first warm is then held: it marks the `links` job finished *and failed* with +"waiting for permission to download terrain" and marks the session warmed, so a +wait returns at once with **zero links measured**, and every study runs over +bare earth, which is free space, which is the most optimistic answer there is. +Nothing raises, and the map still draws. + +`terrain.allow` answers the question and restarts the held warm; `setup.check` +reports it as `undecided` alongside everything else a fresh install is missing. +**Check one of the two before quoting any margin**, because a result computed +over absent terrain looks exactly like a result computed over flat ground, and +the difference is every hill between the two ends. + +## Building a scenario from CoreScope: the whole order Do these in order. Every step below was skipped at least once, and each failure looks like bad RF rather than a missing step. -1. **Boundaries.** `boundary.set` (place) → `boundary.accept`, once per region. - The chosen set unions, so Scotland + Ireland is two accepts. -2. **Import.** `import.set_source` → `import.fetch` → `import.commit` with +1. **Boundaries.** `boundary.set` (place) then `boundary.accept`, once per + region. The chosen set unions, so Scotland plus Ireland is two accepts. +2. **Import.** `import.set_source`, `import.fetch`, `import.commit` with `strategy: "replace-all"` (plain `"replace"` is not a strategy name and leaves the demo nodes in, on a different preset). 3. **Firmware, per role.** `firmware.set` with `role: "simple_repeater"` for everything, then again per companion with `role: "companion_radio"`. Or set `repeater_version` / `companion_version` on `experiment.base`. -4. **Regions — `infer.run` then `infer.apply`.** This is the step that gets +4. **Regions: `infer.run` then `infer.apply`.** This is the step that gets forgotten, and it is the one that decides whether anything relays at all. 5. `firmware.start`, then check `firmware.state` says `running == total`. 6. Only then define and start the sweep. @@ -61,15 +85,16 @@ looks like bad RF rather than a missing step. `boundary.load {path}` or `{geojson}` takes a Polygon, MultiPolygon, Feature or FeatureCollection and puts it in the study area. `boundary.set` searches Nominatim, which needs the network and needs the area to have an administrative -name — a catchment, a valley or something drawn in QGIS has neither. +name; a catchment, a valley or something drawn in QGIS has neither. **Before the import, either way.** The import filters at fetch time, so a boundary set afterwards prunes what has already been fetched. `boundary.list` -says what the study area currently holds; the snapshot only carries how many. +answers `areas` with each area's name and ring count; the snapshot only carries +how many. ### Finding a node you cannot type -Imported names carry emoji and accents — `🏔️ West Lomond 📡` is one real node — +Imported names carry emoji and accents, `🏔️ West Lomond 📡` being one real node, so `nodes.search {"query": "west lomond"}` is how you get a handle on one. It answers `matches[]` ranked best first with a `score`; the tighter name wins, so the exact one beats the one that merely starts the same way. **Check the score.** @@ -79,7 +104,7 @@ that shared one word with the query, silently. ### Getting your own build in `firmware.import {path, role, board, label}`. The `label` is what the library -will know it by and what a node pins — leave it out and it is a timestamp. +will know it by and what a node pins; leave it out and it is a timestamp. Never assume two imports of the same file are the same build: they are two, on purpose, so you can move a node onto the new one and then `firmware.delete` the old by its `path`. Delete only *after* the node is on the replacement; a pin @@ -88,17 +113,18 @@ nothing can honour does not fail until the node next starts. ### The repeater console `console.type {"node": …, "command": …}` runs a line on a node's CLI and returns -what it said — the fastest way to find out what a node actually believes, rather -than what you think you configured. `get name`, `get repeat`, `get flood.max`, -`get path.hash.mode`, `get loop.detect` all read back; `region put `, -`region allowf `, `region default `, `region save` configure regions. +what it said, which is the fastest way to find out what a node actually +believes rather than what you think you configured. `get name`, `get repeat`, +`get flood.max`, `get path.hash.mode`, `get loop.detect` all read back; +`region put `, `region allowf `, `region default `, `region save` +configure regions. -**The command reference is at ** — check +**The command reference is at **; check it before concluding a setting did not apply. There is no `region list` and no `help`; both answer `Err - ??`, which looks like a broken node and is just a command that does not exist. -Console replies come back empty while a sweep is driving the engine — the reply +Console replies come back empty while a sweep is driving the engine: the reply is collected after a 50 ms step, and the experiment owns the clock. Call `experiment.stop` first. @@ -109,7 +135,7 @@ about. A freshly imported node has none, so a scoped message is transmitted by its sender and dropped by all 300 repeaters: **8 transmissions, 0 relays, and a ledger of 137 events.** That reads exactly like a network with no propagation. -Regions are not in the node API — they are **inferred from days of CoreScope +Regions are not in the node API. They are **inferred from days of CoreScope packet traffic**: `infer.run {"hours": 168}`, wait for its job, then `infer.apply`. Applying is a separate call and returns how many nodes it touched; "0 applied" means you inferred and walked away. On ScotMesh a week of @@ -121,15 +147,15 @@ reading goroutine's own callback and refuses when called from anywhere else; before it refused, calling it from outside replaced a finished inference with an empty one. The job is marked finished rather than removed, so a waiter that waits for the job list to *empty* waits for ever, and one that ends when the -list stops changing has to check `failed` — a read that could not reach the +list stops changing has to check `failed`: a read that could not reach the feed ends the job too. The `hours` window is honoured. It used to be accepted, echoed back and discarded, every import reading the most recent 40,000 packets whatever it -said — under two days on ScotMesh, which drops the quiet regions entirely and +said, which is under two days on ScotMesh, drops the quiet regions entirely and reads as a mesh that has gone silent. -### The `#` asymmetry — write the scope with it, the region without +### The `#` asymmetry: write the scope with it, the region without This one cost a whole session. A region is spelled **two different ways** and both are correct: @@ -140,14 +166,14 @@ both are correct: | scope on the wire | **`#`-prefixed** | scope `#sco` | The key in the packet is `sha256("#sco")[:16]`. Ask a companion to send with -scope `"sco"` and it keys its packets `sha256("sco")` — which matches no +scope `"sco"` and it keys its packets `sha256("sco")`, which matches no repeater in existence. Every repeater receives the packet, derives a different key, and declines to forward. **There is no error anywhere.** The senders transmit, the ledger fills with "first time this node heard the message", and nothing relays: 8 transmissions, 0 relays, 137 events. It reads exactly like a mesh with no propagation, and the -temptation is to go hunting through RF, firmware roles and regions — all of +temptation is to go hunting through RF, firmware roles and regions, all of which will look correct, because they are. `experiment.define {"scope": "#sco"}`. The workbench now canonicalises it, but @@ -155,16 +181,42 @@ say it with the `#` anyway. Then **send on a scope the nodes actually hold**. `experiment.define`'s `scope` is applied to the senders only; the repeaters relay it or not according to what -inference gave them. Check the holder counts `infer.apply` reports before choosing — -`#sco` and `#ioi` are the two big ones, and a scope only a handful hold will -look like a dead mesh for the same reason as above. +inference gave them. Check the holder counts `infer.apply` reports before +choosing: `#sco` and `#ioi` are the two big ones, and a scope only a handful +hold will look like a dead mesh for the same reason as above. + +## Antennas: a scalar gain is a wrong answer, not a rough one + +Every node stands under a real pattern now, and the engine is directional in +azimuth, so what a node is pointing at changes the result. + +- `node.antenna {node}` reads one back. A node with **no** antenna answers + `pattern: ""` and `peak_dbi: 0`, deliberately distinguishable from an omni at + 0 dBi. Check which you have before quoting a gain. +- `nodes.antenna` is the only verb that changes one, for a node, a kind, or + everything. It is a **partial overlay**: named fields replace, the rest stay, + so a bearing sweep is one call per step rather than a full restatement. An + unrecognised `polarisation` is refused rather than stored, because an + unrecognised value reads as orthogonal to everything and would take the link + off the air. +- `node.aim {node, at}` computes the great-circle bearing, sets it, and returns + `gain_dbi`, which is what the turn actually won. On an omni that is nothing, + and saying so is the point. + +Two limits to quote with any directional result: the patterns are analytic +Gaussians with a flat front-to-back floor, right on boresight and roughly right +for the first 20 degrees or so, with no side lobes and no nulls; and +polarisation mismatch is charged once per pair in the engine and the link +budget but **not at all in the coverage raster**, so a raster and a budget over +the same crossed pair disagree by up to 20 dB and the raster is the optimistic +one. ## Read the firmware before explaining a result MeshCore is public and the tags match our firmware refs exactly (`repeater-v1.17.0`, `companion-v1.17.0`). Clone `github.com/meshcore-dev/meshcore`, check out the tag under test, and read the -code before writing down a mechanism — a plausible story about what the firmware +code before writing down a mechanism: a plausible story about what the firmware "probably does" is worth nothing next to twenty lines of it. Worth knowing, from `examples/simple_repeater/MyMesh.cpp` at v1.17.0: @@ -175,21 +227,21 @@ Worth knowing, from `examples/simple_repeater/MyMesh.cpp` at v1.17.0: not explaining. - The loop thresholds are **indexed by path-hash size**: `minimal {_,4,2,1}`, `moderate {_,2,1,1}`, `strict {_,1,1,1}`. At a **3-byte - hash all three settings are 1 and therefore identical by construction** — arms - that vary `loop.detect` at 3-byte are measuring nothing, and if they differ, - the simulator has a reproducibility problem rather than a finding. + hash all three settings are 1 and therefore identical by construction**, so + arms that vary `loop.detect` at 3-byte are measuring nothing, and if they + differ, the simulator has a reproducibility problem rather than a finding. - `isLooped()` counts how many times *this node's own hash* already appears in - the packet's path — not whether a hash repeats generally. + the packet's path, not whether a hash repeats generally. - `getPathHashSize() = (path_len >> 6) + 1`, `getPathByteLen() = count × size`, which is where the per-hop airtime cost of a wider hash comes from. **Design a control into the matrix.** Two arms the firmware guarantees are -identical are free reproducibility checks, and one of ours failed — which is how +identical are free reproducibility checks, and one of ours failed, which is how we learned the seed does not capture all the run-to-run variation. ## Regions come from GeoJSON, and there are saved ones -`~/.config/meshbench/boundaries/*.geojson` — Scotland and Ireland are already +`~/.config/meshbench/boundaries/*.geojson`; Scotland and Ireland are already there. `boundary.set` searches for a place, `boundary.accept` adds it to the chosen set, `boundary.prune` deletes every node outside it. **The chosen set unions**, so a two-region scenario is two accepts and one prune. Never @@ -197,9 +249,9 @@ hand-roll a lat/lon rectangle: it silently keeps null-island nodes and cuts real coastline wrong. The import source is **not persisted between launches** and has to be set each -time: `import.set_source` → `import.fetch` → `import.commit`. ScotMesh's +time: `import.set_source`, `import.fetch`, `import.commit`. ScotMesh's CoreScope is `https://scotmesh-corescope.mm7roq.compute.oarc.uk`, and it covers -Scotland, northern England *and* Ireland — around 640 nodes, of which some tens +Scotland, northern England *and* Ireland, around 640 nodes, of which some tens sit at lat/lon 0 and some have no position at all. Say how many you dropped. ## Experiments: the Bench workspace @@ -210,18 +262,17 @@ constants. Then `experiment.seeds`, `experiment.senders`, `experiment.start`, poll `experiment.state`, and `experiment.export` writes an HTML report. **Pin the firmware even when you are not varying it.** `experiment.base` takes -`repeater_version` / `companion_version`, and builds are cached at -`~/.cache/meshbench/firmware/native/` — currently `repeater-` and -`companion-v1.16.0`, `v1.17.0`, and `-faultyirq` variants of both. Freshly +`repeater_version` / `companion_version`. **List the cache rather than trusting +a written list**: `ls ~/.cache/meshbench/firmware/native/`, or ask +`firmware.library`, which now returns `builds` as well as a count. Freshly imported nodes carry no firmware ref at all, which resolves to MeshCore `main`, for which nothing is published; a sweep that varies something else then dies on -its first run with "firmware on 0 of N nodes". The clients expose the same -builds if you would rather ask than list the directory. +its first run with "firmware on 0 of N nodes". **Role is the MeshCore application, not the node kind.** Repeaters run `simple_repeater`; companions run **`companion_radio`**; room servers run **`simple_room_server`**. "companion" is not a role, and `firmware.set` used to -take it without complaint — the run then failed minutes later with " runs +take it without complaint; the run then failed minutes later with " runs no firmware", which reads as a firmware problem and is a typo. The binary names in the cache are the authority (`meshcore--linux-amd64`). @@ -233,7 +284,7 @@ points at the release rather than at the string. A scenario mixing roles has to pin each one separately. **A companion has no command line.** It speaks the companion protocol over its -serial link, so typing `advert` at one does nothing at all — no error, no +serial link, so typing `advert` at one does nothing at all: no error, no packet. That reads as a mesh dropping the first hop. Use a repeater when a test needs to originate from a console, or the companion transport when it needs to be a companion. @@ -253,18 +304,18 @@ mesh of one application. output at `console.log` in its work directory. Read it before theorising: a stale published build stalling after three seconds and a firmware bug look identical from the ledger, and the log tells them apart in one line. A current -build prints `radio_init: entering std_init` — its absence means the binary +build prints `radio_init: entering std_init`; its absence means the binary predates the host radio shim and needs rebuilding upstream. **Check the radio preset after an import.** Imported nodes take the app default, -`EU/UK (Narrow)` — 869.618 MHz, 62.5 kHz, SF8, CR4/8 — which is what ScotMesh +`EU/UK (Narrow)`: 869.618 MHz, 62.5 kHz, SF8, CR4/8, which is what ScotMesh runs, and what the earlier CAD and hash studies used too. Quote it as provenance rather than assuming; a scenario built by hand may sit on the deprecated 869.525 / 250 kHz / SF10, and results either side of that are not comparable. **Diff against a scenario that worked.** When a run produces nothing and the configuration all looks right, load the last project that *did* work and compare -the two JSON files under `~/.config/meshbench/projects/` — radio, regions, +the two JSON files under `~/.config/meshbench/projects/`: radio, regions, firmware refs, scopes. It is far faster than reasoning forwards, and the answer is usually one field. @@ -278,96 +329,79 @@ Three traps, all paid for: and watch be ignored. Firmware was exactly that. - **Measure the thing the change is supposed to affect.** Reach was a set of nodes per message, so an arm that suppressed nine duplicate copies scored the - same as one that suppressed none — every loop-detection comparison came back + same as one that suppressed none: every loop-detection comparison came back "no difference" for a whole session before anyone noticed the metric could not see it. ## Emulation: running the bytes people flash -Two backends run MeshCore. **Native** compiles it for the host and is what -everything so far is built on. **Emulated** runs the published board image -unmodified, under QEMU, and exists as the cross-check on the native one -(ADR-0010). - -An emulated node is now a node on the mesh. A published `Generic_E22_sx1262` -v1.17.0 image boots, adverts, and a native repeater decodes it off the same -channel (`TestEmulatedAndNativeShareAChannel`). A node runs emulated when its -`Firmware.Board` is set; empty means the host build. - -Two constraints that follow from an emulator being in the loop. It runs on -**wall time**, so the engine cannot race the clock ahead — pace `Run` roughly -1:1 or it will look as though no frames arrived. And two runs of one seed will -not produce identical ledgers, so the determinism the rest of the simulator -guarantees does not hold for a scenario containing one. - -**Only the repeater role can be emulated today, and it is board coverage rather -than role code.** The one verified board publishes the repeater alone. Every -plain-ESP32 SX1262 board that publishes all three roles is a T-Beam, and all of -them stall *before* `radio_init`: the I2C bus never comes up, the AXP PMU init -spins, and the radio is never touched — zero SPI transactions, which reads as a -broken emulator rather than a missing PMU model. T-Beam SX1262 also publishes a -BLE-only companion. Every board publishing all three with a USB companion is an -ESP32-S3, so the unlock is an `esp32s3` machine, not more board entries. +Two backends run MeshCore. **Native** compiles it for the host and is what most +of the above is built on. **Emulated** runs the published board image +unmodified, under QEMU or Renode, and exists as the cross-check on the native +one (ADR-0010). A node runs emulated when its `Firmware.Board` is set; empty +means the host build. + +**The toolchain is a download now, not a build.** `resource.fetch` with +`kind: "toolchain"` fetches `radioserver`, `qemu-system-xtensa` and `renode` +into `~/.cache/meshbench/tools/`, which is step three of the lookup a boot +already performs, so nothing has to be set afterwards. The `kind` parameter +**defaults to `softdevice`**, so a fetch that omits it asks for the wrong thing. +`resource.list` says what is present and what it cost; `setup.check` says the +same beside everything else that is missing. QEMU and Renode are published for +linux/amd64 only, macOS gets `radioserver` alone, and Windows nothing, each with +its reason. An emulated nRF52 board additionally needs the Nordic s140 +SoftDevice, which is its own `softdevice` resource. + +**`EmulatableBoards()` is the authority on what runs**, and it returns both the +boards that do and the reason each of the rest does not. Do not carry a list in +your head: it has already moved twice. As things stand it covers plain ESP32, +ESP32-S3 under the fork's `esp32s3` machine, and nRF52840 under Renode. **There +is no role gate**: `emulated.Runnable` filters on image format, on whether the +board has verified wiring, and on transport. Anything that once read as "only +repeaters" was board coverage, and board coverage moves. **BLE companions are excluded deliberately.** There is no Bluetooth here, so one boots and then waits forever for a phone, which looks like a hang rather than an unsupported build. Published companion assets carry their transport in the name (`..._companion_radio_usb-v1.17.0-...`), and the USB one is the only usable one. +Two constraints follow from an emulator being in the loop. It runs on **wall +time**, so the engine cannot race the clock ahead; pace `Run` roughly 1:1 or it +will look as though no frames arrived. And two runs of one seed will not produce +identical ledgers, so **the determinism the rest of the simulator guarantees +does not hold** for a scenario containing one. Run boards one at a time: several +at once will take a twelve-core machine down. + ### Where the pieces live | | | |---|---| | QEMU with our SX1262, GPIO and fixes | `MeshBench/qemu` branch `meshbench-sx1262` | +| Renode with the SEVONPEND fix | `MeshBench/renode` and `MeshBench/tlib` | | The chip model, and the socket server | `meshcore-native`, `VirtualSX1262` + `bridge/radioserver.cpp` | -| Per-board wiring | `internal/firmware/board/boards.go`, `QEMUWiring` | - -Build QEMU with **`--enable-gcrypt`** or the `esp32` machine dies with -`unknown type 'misc.esp32.rsa'`: the RSA device is gated on gcrypt while the -machine references it unconditionally. - - ./configure --target-list=xtensa-softmmu --disable-werror --enable-slirp --enable-gcrypt - -### Only plain ESP32, and only some boards - -`hw/xtensa/esp32.c` instantiates SPI0 to SPI3, so a radio has a bus to sit on. -**ESP32-S3 models only `spi1`**, the flash controller, so an S3 board needs a -GP-SPI controller written before anything can be attached. Published nRF52 -images are linked above a proprietary Nordic SoftDevice and are out of scope. +| Per-board wiring | `internal/firmware/board/board_.go` | Board wiring is **per board and verified per board**, never inferred from the MCU. `Heltec_v2` carries an **SX1276**, not an SX1262, despite sitting beside -the V3 in every shop — its firmware speaks SX127x register access, and the +the V3 in every shop; its firmware speaks SX127x register access, and the giveaway is the firmware sending `0x42`, which is RegVersion on an SX127x. -`EmulatableBoards()` returns what can run and why the rest cannot. ### Three things that are not obvious and cost hours **RadioLib drives NSS as an ordinary GPIO**, not the SPI controller's chip select. Without NSS the controller clocks bytes one at a time and the chip gets -an unframed byte stream it cannot answer, so the driver reports no chip. The -GPIO model had an empty write handler and had to be implemented. +an unframed byte stream it cannot answer, so the driver reports no chip. -**Arduino's default-constructed `SPIClass` is HSPI**, controller 2 — not VSPI. +**Arduino's default-constructed `SPIClass` is HSPI**, controller 2, not VSPI. `std_init(NULL)` picks the global `SPI` (VSPI); `static SPIClass spi;` does not. +Which controller a board's radio sits on is a property of that board's +*firmware*, not of the chip, so read the variant rather than generalising. **A merged image's flash-size header is at 0x1000**, not 0, because the image starts with padding. Read it from the wrong offset and you pad to the wrong size and get `Detected size(4096k) smaller than the size in the binary image header(8192k)`. QEMU accepts only 2/4/8/16 MB images. -### Two bugs in Espressif's QEMU - -Both in the SPI model, both affecting any non-flash peripheral: - -- **RX bounds check** indexed by the transferred byte's value instead of the - loop position, so replies were dropped whenever the byte just sent was - numerically larger than the read length. Already fixed by their open PR #144, - which is cherry-picked onto our branch with authorship intact. -- **Stale command-phase bitlen**: the USR path enabled a command phase when - `SPI_USER.COMMAND` *or* a leftover `COMMAND_BITLEN` was set, injecting a - spurious `0x00` in front of every transfer. Ours, not reported yet. - ### The chip model is clocked two ways and must agree `VirtualSX1262` takes a whole buffer for the native path and single bytes for @@ -381,7 +415,7 @@ strictly, so `signalRssiPkt` read zero on every native run. ## Working on the desktop app **Never launch it with `go run`.** It relinks the cgo in Gio and wgpu every -time, which costs far more per restart than a prebuilt binary — and that gap +time, which costs far more per restart than a prebuilt binary, and that gap decides whether a layout gets checked or guessed at: go build -o msim ./cmd/meshbench && ./msim workbench @@ -393,17 +427,18 @@ looks exactly like a crashed application. Use the compositor's own tool. **Look at the window before claiming it works.** One pass over the firmware library found it taking a viewport of its own and being sized to the display, two tables with fixed pixel heights that would not scale, and a window created -without `WindowFlagsMenuBar` — which silently costs it `panelChrome`, and with +without `WindowFlagsMenuBar`, which silently costs it `panelChrome`, and with it the pop-out and dock verbs. ## Before you report any number **The model is optimistic, and you must say so.** It omits multipath, fading, body loss, oscillator error, and non-LoRa interference beyond what is loaded. -Every omission makes real links *worse*. The bias is one-directional. +Every omission makes real links *worse*. The bias is one-directional, and +`docs/shortcomings.md` is the current list. So: never present a simulated margin as a measurement. "Predicted +2.5 dB, which -is marginal — real conditions will be worse" is honest. "It works" is not. +is marginal, and real conditions will be worse" is honest. "It works" is not. ## The four rules that make results meaningful @@ -416,86 +451,110 @@ is marginal — real conditions will be worse" is honest. "It works" is not. producing a confident number from an unconfident input. 3. **One run is not evidence.** The channel is stochastic. Vary the seed and report the spread, or say explicitly that you ran once. -4. **Quote provenance.** Firmware ref, seed, terrain zoom, region. A figure - without provenance is an anecdote. +4. **Quote provenance.** Firmware ref, seed, terrain zoom, region, radio preset, + and whether terrain was allowed. A figure without provenance is an anecdote. ## Workflows ### "Will this link work?" ``` -evaluate_link A B +meshbench link -from-lat … -from-lon … -to-lat … -to-lon … -freq 869.618 ``` -Read the *whole* budget, not the verdict. Report: margin in both directions, the -dominant loss term, and — if it fails — which diffracting edge cost the most. -"That ridge at 4.2 km costs 31 dB" is actionable; "no path" is not. -### "Why did that packet not arrive?" +It prints the distance, the path loss, the margin in **both** directions, and +which of workable / one-way / fails it is. Report the weaker direction and the +dominant loss term; if it fails, `meshbench profile` over the same path names +the worst obstruction, and "that ridge at 4.2 km costs 31 dB" is actionable +where "no path" is not. -``` -reception_ledger -``` -Distinguish the five outcomes; they have different fixes: +From a session, `link.pair {a, b}` and `link.profile` compute the same thing and +draw it. **They do not hand the numbers back**: the reply is only the endpoints, +`budget.for_selection` answers a bare count, and the margins reach no snapshot +and no client. So read a budget with the CLI, or from the panel, and say plainly +that the socket cannot yet return one rather than inventing a value. -| Outcome | What it means | -|---|---| -| out of range | nothing arrived — terrain or distance | -| too weak to demodulate | raise power, lower SF, better antenna | -| demodulated, CRC failed | marginal — interference or collision | -| received, dropped (dedup) | working as designed, not a fault | -| received, relayed | fine | +### "Why did that packet not arrive?" -Never say "collision" without checking the waterfall — a weak signal and a -collision look identical in delivery statistics and have opposite fixes. +Every miss now carries the cause the engine *established*, on `class`, and it is +a closed set (`internal/sim/engine/events.go`, mirrored into both clients by +`tools/clientgen`). Group on the class; never match on the detail sentence, +which is prose and is reworded. + +| class | what it means | what fixes it | +|---|---|---| +| `sent` | this node transmitted it | nothing, it is the origin | +| `received` | decoded, first time | nothing | +| `half-duplex` | its own transmitter was keyed | timing, not power | +| `interference` | would have decoded alone; something louder took it | less traffic, or separation | +| `collision` | header decoded, then more symbols destroyed than the CR repairs | less traffic | +| `receiver-busy` | the demodulator was already following another packet | less traffic | +| `floor` | under the threshold **on its own** | power, antenna, lower SF | +| `unclassified` | the engine did not establish a cause | investigate, do not guess | + +**`floor` is no longer the catch-all, and that is the point.** It used to absorb +receiver-lock and collision misses, and an operator reading the floor card +bought antennas for a mesh that was actually too busy. `unclassified` exists so +that an unestablished cause reads as a question rather than as a confident wrong +answer; waveform mode produces it often, because the receive chain reports what +it did and not what beat it. A path with no terrain data is `unclassified` too, +not `floor`: nothing about that signal was measured. + +Read them with `events.recent {limit}` or `events.dump {path}`, which writes +NDJSON with `class` on every line. Never say "collision" without checking, and +never average `unclassified` into anything. ### "Where should the next repeater go?" -``` -find_sites --region --objective coverage,redundancy,energy -``` -Report the per-term scores, not the aggregate. A site that gains coverage but +`internal/study/planning` places sites and scores them, and `meshbench coverage` +writes a raster from one station, but **the site search is reachable from no +verb and no subcommand today**. Say so rather than describing a workflow that +does not exist. What can be done from outside is to propose candidates, compute +a coverage raster for each with `coverage.compute`, and compare, reporting the +per-term consequences rather than one aggregate: a site that gains coverage but flattens in December is not a site, and a site that adds interference to two -existing repeaters may be a net loss. Both are visible in the terms. - -Check `energy_forecast` on the winner before recommending it. +existing repeaters may be a net loss. ### "Will this solar node survive winter?" -``` -energy_forecast --months 12 -``` -The answer is not a percentage. It is *when it flattens and what fixes it* — -usually a bigger panel, not a bigger battery. Say which, because people reliably -buy the wrong one. +`node.energy` (which takes the node's name as its whole parameter, not an +object) and `energy.for_selection` produce the budget, and both refuse when the +energy model is disabled. The answer is not a percentage. It is +*when it flattens and what fixes it*, usually a bigger panel rather than a +bigger battery. Say which, because people reliably buy the wrong one. ### "Did that firmware change break relaying?" -``` -run --firmware-ab --seed --traffic scripted -compare -``` -Lock the seed and script the traffic, or the comparison means nothing. Report the -first diverging event, not just the totals. +Pin `repeater_version` on `experiment.base`, vary it with `experiment.vary`, +lock `experiment.seeds` and script the traffic with `schedule.add`, or the +comparison means nothing. Report the first diverging event, not just the totals, +and remember that `experiment.export` is the only place the matrix comes back as +a document: `sweep.run` answers with arm and seed counts and leaves the matrix +where only a panel can read it. ### "Is my site deaf?" -Load emitters, then `evaluate_link`. If the noise floor is raised, check whether -the interferer is **in band or out of band** before suggesting a filter — a +Load emitters, then compute the link. If the noise floor is raised, check whether +the interferer is **in band or out of band** before suggesting a filter: a filter cannot help in-band interference, and saying so plainly saves real money. ## Do not - Do not report a coverage raster as ground truth. At terrain zoom 11 a pixel is - ~30 m; buildings, hedges and vans are invisible to it. + ~30 m; buildings, hedges and vans are invisible to it, and the raster charges + no polarisation mismatch even where the link budget does. - Do not average away an asymmetry. - Do not run one seed and call it a result. - Do not recommend a site without checking energy and interference. -- Do not silently drop nodes excluded for position uncertainty — say how many. +- Do not silently drop nodes excluded for position uncertainty; say how many. +- Do not quote a margin without knowing whether terrain was allowed. ## When the simulator disagrees with reality -`validate` compares predictions against observer data. If agreement is poor, -that is a **finding about the model**, not a data problem to explain away. Report -the residual and its sign; per ADR-0003 we expect to read optimistic, so a -negative bias is more interesting than a positive one and worth investigating -rather than smoothing. +`validate.fetch` and `validate.compare` put predictions against observer data. +If agreement is poor, that is a **finding about the model**, not a data problem +to explain away. Report the residual and its sign. ADR-0015 is why the +comparison exists at all: the omissions in `docs/rf-chain.md` section 9 all +make reality worse than simulation, so we expect to read optimistic, and a +residual in the *other* direction is the interesting one, to be investigated +rather than smoothed. diff --git a/.claude/skills/wb2-design-language/SKILL.md b/.claude/skills/wb2-design-language/SKILL.md index 89f7600a..bf617200 100644 --- a/.claude/skills/wb2-design-language/SKILL.md +++ b/.claude/skills/wb2-design-language/SKILL.md @@ -1,25 +1,30 @@ --- name: wb2-design-language -description: The workbench2 design language - load before building or changing any Gio panel, control, menu, or map drawing so new work matches what shipped +description: The workbench design language - load before building or changing any Gio panel, control, menu, or map drawing so new work matches what shipped --- -# The workbench2 design language +# The workbench design language How MeshBench's Gio interface is built. These are decisions Alex has made, mostly by rejecting something that did not follow them; breaking one recreates a reported bug. +Almost none of it is enforced by a linter, because most of it is about what a +control *means* rather than about what compiles. Treat every rule below as a +convention you are expected to keep, not as something the build will catch. + ## Colour and type - **Every colour and size comes from `theme.Theme`** (`internal/ui/theme`). - Nothing outside that package writes a literal colour or pixel count. Palette is semantic: `Ink/Dim/Faint` for text, `Panel/Sunk/Ground` for surfaces, `Rule` for borders, one `Accent`, `Good/Warn/Bad` for states. - Need a new colour? Add a token to both palettes, then use it. + Need a new colour? Add a token to both palettes, then use it. Nothing + checks this, and `comp/mapbuildings.go` and `comp/mapcoverage.go` both + break it today, so the existence of a literal is not permission for another. - **Text drawn on the map uses `MapInk`/`MapInkDark`, never theme ink.** The basemap layer says whether its ground is dark (`basemap.Layer.Dark`); - `MapView.baseInk` picks. Theme ink on a light basemap was invisible - no - plates, no halos; the font changes, per Alex. + `MapView.baseInk` (in `comp/mapscale.go`) picks. Theme ink on a light + basemap was invisible - no plates, no halos; the font changes, per Alex. - **Mono (`comp.Mono`, `Column.Mono`) for anything compared by eye down a column**: numbers, versions, identifiers, hex. @@ -28,15 +33,17 @@ a reported bug. - **Card + StatCell + CellGrid** (`comp/cards.go`) for settings and overview pages: a labelled value with the "why it matters" caption underneath. The caption is content, not decoration - it is the reason the number exists. -- **Chips** (`comp.Chip`) for filters and tabs: capsule, count beside the - label, tinted when active. Cards above, chips below, table underneath - - the events panel and the firmware library both follow this page shape. +- **Chips** (`comp.Chip`, `comp/chips.go`) for filters and tabs: capsule, + count beside the label, tinted when active. Cards above, chips below, table + underneath - the events panel and the firmware library both follow this + page shape. - **Dropdowns own no list.** `comp.Dropdown` shows the value and a drawn - chevron; pressing hands the choosing to the shell's chooser - (`Prompt.Choose`) via a `choose func(title, opts, pick)` callback wired in - main.go. One way to pick from a list, everywhere. -- **Switches are `comp.Check` drawn with `LayoutSwitch`** - same widget.Bool - underneath, so the control audit still finds them. + chevron; pressing hands the choosing to a `choose func(title, opts, pick)` + callback. Build it with `chooserIn(panel)`, not by reaching for `sh.Ask` + directly: that routes the question through `windows.promptFor`, so a + popped-out panel asks in **its own** window rather than in the main shell. +- **Switches are `comp.Check` (in `comp/comp.go`) drawn with `LayoutSwitch`** - + same widget.Bool underneath, so the control audit still finds them. - **Pills** (`comp.Pill`) for status words (Ready to run / Warming / Running). Capsule radii are `size.Y/2`, never a big constant: Gio does not clamp RRect radii, and an oversized radius smears fill across the window. @@ -46,16 +53,39 @@ a reported bug. (events, firmware library) must force every cell to its declared column width - `d.Size.X = px` - or a 12px tick slides everything after it off its header. Column labels and widths live in one table-of-columns per panel. -- **Event classes** map to colour in exactly one place: `comp.ClassColour`. - Cards, chips, pills and cause text all read it. + +## One table, or the key drifts + +**A colour and the word for it come from the same table, read by both the +drawing and the legend.** `comp.ClassColour` and `comp.ClassLabel` are that +pair for event classes; `timelineKinds()` in `comp/timeline.go` is the same +move for the timeline, feeding `legend()` and `marks()` from one list. A key +maintained beside the thing it describes is a key that will disagree with it, +and a wrong legend is worse than none because it is believed. + +The event classes grew from five to eight (`sent`, `received`, `half-duplex`, +`interference`, `collision`, `receiver-busy`, `floor`, `unclassified`), which +is what a table absorbs and a hand-written key does not. ## Behaviour - **Panels never mutate state.** Controls fire verbs through `do(verb, params)`; the store owns the world; panels draw snapshots. A control that - needs input the button cannot carry asks through `sh.Ask` (prompt or - chooser) - the verb itself must still refuse when the parameter is missing, - because scripts call it directly. + needs input the button cannot carry asks through the prompt - the verb + itself must still refuse when the parameter is missing, because scripts call + it directly. +- **Widget state belongs to the panel, never to the package.** A map keyed by + action name at package scope was shared by every window in the process; two + pop-outs writing it was a fatal concurrent map write, and a mutex would not + have saved it, because they would still be sharing one `Clickable`. +- **Widget identity is address**: never rebuild a widget per frame; pool + per-row widgets in a map keyed by a stable row key, and **bound the pool**. + The events panel kept one clickable per distinct event for ever while the + store's tail dropped them; it now rebuilds against what is on screen, with + slack so typing in a filter does not rebuild every frame. +- **Nothing touches the disk on the frame goroutine.** The compare and runs + panels share `runloader.go`, which loads off-frame and hands the result to + whichever frame asks next. - **Every long operation announces itself** in the jobs strip (`job.progress` / `job.done`) or is not long. Estimates are said before spending ("fetching 412 of 500 tiles, roughly 25 MB"). Three healthy waits were reported as @@ -67,34 +97,86 @@ a reported bug. modal. - **Honesty lines are content**: "results are a best case: no multipath, bare earth, ideal demodulator" stays visible; "no data" is never drawn as zero; - "did not apply" is a dash, not a cross. + "did not apply" is a dash, not a cross. The same rule is why a miss shows + the cause the engine established and `unclassified` where it established + none, rather than a plausible default. - **Settings that survive a restart** go through `session.Prefs` (`workbench2.json`) - loaded by the command, never by `Register`, so tests stay hermetic. The scenario itself deliberately stays in the fixture. +## It has to read docked, not just popped out + +A panel is laid out in a narrow rail far more often than in a window of its +own, and a layout that is only checked popped out fails in the way that reads +as a rendering fault rather than as a layout one: the Link panel drew nothing +docked because a rigid header ate the width and the flexed chart was left zero +height. + +The pattern to copy is in `workbench/linkprofile.go`: a width breakpoint, the +header stacking below it, the chart *measured* with a floor rather than +guessed, and the whole panel scrolling when the floor wins. Clip a chart to its +own box. **Capture both widths before believing it.** + ## Menus and windows - Menu structure is data on `shell.MenuItem` (Section/Icon/Shortcut); the table in `workbenchMenus()` feeds the rows, the key filter and the tests, so caption and binding cannot drift. Shortcuts match on exact modifiers. -- The Window menu lists the curated daily set (`Panel.InWindowMenu`); - everything else stays one step away behind "Show all panels...". +- **Every panel names its `Menu` and its `Section`, and the entries are + generated** by `Shell.PanelItems`. There is no curated daily set and no + "Show all panels..." any more: the curated thirteen left twenty panels + reachable only through a chooser that then threw them out of the window. + A panel with no menu is a panel nobody finds. +- The Window menu is about windows and layouts (`layout.reset`, + `window.raise_all`, `window.dock_all`), not about which panels exist. - One entry lives in one menu. +## A file that hits 500 lines is split along a seam + +The hard limit is a build failure (`tools/file-length.sh`), and `mapworld.go` +sitting at 499 blocked two unrelated changes. Splitting is a first-class move, +but split on meaning: what draws the **world** stayed in `mapworld.go`, what +draws a **study's answer laid over it** left for `mapcoverage.go`. A split by +line count alone leaves two files nobody can name. + ## Verification (non-negotiable) -- **Nothing is done until it is seen running.** Build on elite, drive through - the control socket (`/tmp/ctl2.py`), capture window-targeted screenshots - (`/tmp/shot.sh`), and look at them. Green tests alone have let bugs - survive; the pill smear, the invisible hillshade and the drifting columns - were all caught only in captures. +- **Nothing is done until it is seen running.** Green tests alone have let + bugs survive; the pill smear, the invisible hillshade and the drifting + columns were all caught only in captures. - **Everything reachable by flag**: a panel, section, menu, or layer that - only opens on a click is one nobody can capture (`-panel`, - `-config-section`, `-drop-menu`, `-layers`, ...). -- **The control audit** (`audit_test.go`) walks panel structs for - Button/Check/Field and presses everything. New panels join `auditTargets`; - panels whose sections hide controls provide a flat `auditDraw`. Controls - that change the view rather than the world (sidebar rows) are plain + only opens on a click is one nobody can capture. `internal/ui/workbench/main.go` + carries around thirty of them (`-panel`, `-view`, `-config-section`, + `-drop-menu`, `-layers`, `-look`, `-node-tab`, `-coverage`, ...) plus + `-quit-after` and `-control-socket`. A new view adds one. +- **There is no blessed screenshot tool in the repository.** Driving is the + control socket through `pkg/client-python` (`tools/soak/` is the worked + example); capturing is the compositor's own grabber, because under Wayland + the app does not render on Xwayland and an X11 grab returns a solid black + screen that looks exactly like a crash. The closest thing to a committed + capture path is the render tests, `workbench/brandshot_test.go` and + `hardwareshot_test.go`, which write PNGs. `board.screenshot` is not this: it + captures an emulated board's own display. +- **The control audit** (`workbench/audit_test.go`, targets in + `audittargets_test.go`) walks panel structs for Button/Check/Field and + presses everything. New panels join `auditTargets`; panels whose sections + hide controls provide a flat `auditDraw`. It now *waits* for a control's + effect rather than assuming two frames are enough, so a control that defers + its work passes - and a destructive control that asks before acting spends + the whole waiting budget, which is why the suite takes near two minutes. +- Controls that change the view rather than the world (sidebar rows) are plain `widget.Clickable` with their own test. -- Widget identity is address: never rebuild a widget per frame; pool per-row - widgets in a map keyed by a stable row key. + +## A panel that opens itself + +The Setup panel is the only one that does, and the rules it had to keep are +worth reusing: + +- **It is a report, not a wizard.** No row acts on its own; a row nothing can + fix carries the steps in words rather than pointing at a document. +- **It opens only when something is blocking or waiting to be told.** A + machine that is set up sees nothing, which is what stops it being a splash + screen. +- **It waits three seconds first.** A panel docked before the layout exists + lands nowhere, and a check run before the fixture opens misreads every + missing firmware as optional.