diff --git a/BUILT.md b/BUILT.md index e3b35c2..696d81e 100644 --- a/BUILT.md +++ b/BUILT.md @@ -4815,3 +4815,75 @@ now read out before the check, and the loop advances to a saved `match-end`. `emacs/*.el` is already a dependency of that dune stanza, so a new `.el` needs no build change. Nothing in it needs a daemon — ghost text is a function from a table of rows and the text in a buffer to overlays, and both halves are fixtures. + +## A hot loop keeps five numbers, and the window is the editor's + +The scalar watch above keeps one value per name. From a hot inner loop that is nearly useless: you see whichever of +the 91,200 cells happened to run last, and `watch.clj`'s own docstring says so — "from a hot loop you only ever see +whichever cell happened to run last — use `spy-long` there instead." This is that other half, and it is the piece +`PORTING.md` item 5 calls least obvious and most valuable. + +`(watch-num-i64 "cell" (at grid i))` reaches `flan_dev_watch_num_i64` through the same plain `declare-c` the four +scalars use. No arm in the checker, no special form, nothing the compiler learns — the same deliberate non-ownership +of `check.ml` that the scalars were built under. + +**What a slot keeps: count, min, max, last, mean.** The argument for those five is that each answers a question you +can ask without building a query. `n` is how many times the expression ran, which is the first thing that is wrong +when a loop is wrong — a count that tracks the frame counter rather than the cells is a loop that is not running. +`min` and `max` are the range, which is the thing a single sample can never show you and the thing you are looking +for when you suspect an index or a velocity is leaving the region it should stay in. `last` is the one sample, kept +because it is what the scalar watch would have given you and losing it would be a regression. `mean` is carried as a +running `sum` and divided at read time, because a mean accumulated as a mean drifts and a sum does not. + +**What it deliberately does not keep: a ring, or a history.** A small ring of the last N samples was the other +candidate and it loses on the only ground that matters: N samples out of 91,200 is a sample of the *tail* of the +loop, not of the loop, so it answers "what did the last few cells do" when the question is "what did the cells do". +Beyond five numbers every richer answer is a UI for building a query, and a query builder is the one thing this +design exists not to be — the same reason the ghost text section gives for showing the last value in a loop rather +than offering to pick one. + +**The write path does no formatting, and that is the whole feature.** An `snprintf` per sample at thousands of +samples a frame is a HUD that costs more than the game. A sample is a relaxed load, five compares and stores, and the +slot's seqlock; the *reader* — the agent's listener thread, once per editor tick — turns the five numbers into text. +`watch.clj` reaches the same place with a `double-array` per label and a `render` that Emacs calls, and the reasoning +is the author's rather than ours. It is also why the slot grew a `num` flag instead of a second table: the read path +already copies under a seqlock, so it copies five doubles instead of 192 bytes and renders them out of locals after +the counter check. + +**The window is since the last reset, and this is a deliberate divergence from `watch.clj`.** There the stats are +cumulative until `reset-spies!` is called by hand. Cumulative is the wrong default for a frame loop: a `min` and a +`max` over a whole session reach the session's extremes within a few seconds of play and then never move again, so +the two most useful of the five go dead exactly when you start interacting with the thing you are debugging. This +tool exists to show you a number while you drag the mouse. So `flan-watch--tick` sends `:reset t` beside its read and +the displayed range is "since you last looked" — a fifth of a second, a dozen frames. A caller that wants the +cumulative numbers gets them by not resetting; the setting lives in the editor, not in the runtime. + +**Reset is its own message, and it is not a side effect of reading.** A destructive read was the tempting shape and +is wrong: it makes *looking* change what is there, so anything that polls — a test's `await`, a second editor, a +person reading twice — silently shortens the window and gets a count that is noise. This was caught before the test +was written rather than after, which is the only reason the test has assertions about `n` at all. `watch reset` is a +line in `flan_agent.c` beside `watch on`/`watch off`, `:reset t` is a field on the read op, and `dev.ml` sends it +*after* the read so the tick reports the window it just closed. + +**The reader never writes the table.** Reset bumps one global epoch counter and touches no slot; a slot clears itself +on its next sample, when it notices the epoch has moved, and does that *inside* its own odd-generation window so a +reader can never catch a half-cleared slot. The game thread stays the only writer of the table, which is the +invariant the whole of `flan_dev.c`'s watch section rests on. The cost is that a new window begins when the program +next runs rather than at the instant of the reset — and for a frame loop that is the only moment it could sensibly +begin. The test waits for it rather than reading once, and says why. + +**An `i64` accumulates as a double**, so a magnitude past 2^53 loses precision in the sum and in the ends of the +range. Recorded rather than designed around: a count, a coordinate and a tile index are what this is pointed at, and +a second integer accumulator for a case nobody has would be two code paths for one tool. `watch-num-i64` and +`watch-num-f64` are two entry points only so a program need not cast at the call site, which is noise in the one +place this is meant to be droppable into. + +**A whole number prints as one.** `watch.clj`'s `fmt-num` does this and the reason survives the port: a watch on an +array index that reads `66.0000` sends you looking for a rounding bug that is not there. + +**Ghost text needed one character.** `flan-watch-ghost-call-regexp` was `watch\(?:-[[:alnum:]]+\)?` — one optional +hyphenated segment, which matches `watch-i64` and backtracks to failure on `watch-num-i64`, so a numeric watch got +no inline value at all while appearing normally in the buffer. A `*` for the `?` is the whole fix. It is worth +recording as the predictable cost of the decision that section defends: anchoring on the *name* rather than on the +head of the call is what makes the head a `defcustom`, and a `defcustom` with an enumerated default is a default that +needs widening when the set of heads grows. That is a cheaper failure than the alternative and it is not a free one. diff --git a/HANDOFF-f3.md b/HANDOFF-f3.md new file mode 100644 index 0000000..9e35de7 --- /dev/null +++ b/HANDOFF-f3.md @@ -0,0 +1,234 @@ +# Handoff — a watch for a running program (`PORTING.md` Tier 1, item 5) + +Written because the session was wound down for budget. **The work is finished and `dune test` is green**, run twice. +This file exists so the reasoning is not re-derived, and so the two things I would have done next are named. + +--- + +## The finding that reframes the task + +**Most of item 5 was already built before this session.** The brief reads as though a watch had to be built from +nothing; it did not. What existed at `344e571`: + +- `runtime/flan_dev.c` — the watch table: 64 slots, a name and rendered text per slot, a per-slot seqlock, an + `on`/`off` flag so a watch call costs a load and a not-taken branch when nobody is looking, and four scalar entry + points (`flan_dev_watch_i64`/`_u64`/`_f64`/`_str`). +- `vendor/agent/flan_agent.c` — `watch`, `watch on`, `watch off` on the agent socket. +- `lib/dev.ml` — `watch_read`, `watch_enable`, and the `:op "watch"` / `:op "watch-enable"` arms. +- `emacs/flan-watch.el` — the watch buffer *and* the inline ghost text, both fed from one reply. +- `test/test_dev.ml` — a block driving a real daemon and a real running program. + +That is `watch.clj`'s `spy`, end to end. **What was missing was `spy-num`** — the numeric accumulator for hot loops, +which is precisely the part `PORTING.md` calls "least obvious and most valuable". That is what this session built. + +--- + +## The accumulator decision + +This is the expensive part to re-derive, so it is stated in full. The long-form version is in `BUILT.md`, "A hot loop +keeps five numbers, and the window is the editor's"; `runtime/flan_dev.c` carries it at the code. + +### What a slot keeps + +**Count, min, max, last, mean.** Five numbers per label. + +The scalar watch keeps one value per name. Sampled from a hot inner loop that is nearly useless — you see whichever +of the 91,200 cells happened to run last. `watch.clj`'s own docstring says so and is why `spy-num` exists there. + +Each of the five answers a question you can ask *without building a query*: + +- **`n`** — how many times the expression actually ran. This is the first thing that is wrong when a loop is wrong. A + count that tracks the frame counter rather than the cell count is a loop that is not running. +- **`min` / `max`** — the range. This is the thing a single sample can never show you, and it is what you are looking + for when you suspect an index or a velocity is leaving the region it should stay in. +- **`last`** — the one sample. Kept because it is what the scalar watch would have given you, and losing it on the + way to something richer would be a regression. +- **`mean`** — carried as a running `sum` and divided at read time. A mean accumulated as a mean drifts; a sum does + not. + +### What it deliberately does not keep + +**A ring, or a history.** A small ring of the last N samples was the serious alternative. It loses on the only ground +that matters here: N samples out of 91,200 is a sample of the *tail* of the loop, not of the loop. It answers "what +did the last few cells do" when the question is "what did the cells do". Past five numbers, every richer answer is a +UI for building a query — and a query builder is the one thing this whole design exists not to be. The ghost text +section of `BUILT.md` already settled the same question the same way for the scalar case ("a watch inside a loop +shows the last value written... every better answer is a UI for building a query. Settled, not open."). + +### The write path does no formatting + +This is the feature, not an optimisation. An `snprintf` per sample at thousands of samples a frame is a HUD that +costs more than the game. A sample is: one relaxed load of the epoch, five compares and stores, and the slot's +seqlock. The **reader** — the agent's listener thread, once per editor tick — turns the five numbers into text. +`watch.clj` reaches the same place with a `double-array` per label and a `render` function Emacs calls. + +This is also why the slot grew a `num` flag rather than getting a second table: the read path already copies under a +seqlock, so it copies five doubles instead of 192 bytes and renders them out of locals *after* the counter check. + +### The window is since the last reset — a deliberate divergence from `watch.clj` + +`watch.clj` is cumulative until `reset-spies!` is called by hand. **This is not**, and the disagreement is on purpose. + +Cumulative is the wrong default for a frame loop. A `min` and a `max` over a whole session reach the session's +extremes within a few seconds of play and then never move again — so the two most useful of the five numbers go dead +*exactly* when you start interacting with the thing you are debugging. This tool exists to show you a number while +you drag the mouse. So `flan-watch--tick` sends `:reset t` beside its read, and what you see is "since you last +looked": a fifth of a second, about a dozen frames, which keeps the range tracking the present. + +A caller who wants cumulative numbers gets them by not resetting. The setting lives in the editor, not the runtime. + +### Reset is its own message, never a side effect of reading + +A destructive read was the tempting shape and is wrong: it makes *looking* change what is there. Anything that polls +— a test's `await`, a second editor, a person reading twice — would silently shorten the window and come back with a +count that is noise. So `watch reset` is its own agent command, `:reset t` is a field on the read op, and `dev.ml` +sends the reset *after* the read so a tick reports the window it just closed. + +This was settled before the test was written rather than after, which is the only reason the test can assert anything +about `n` at all. + +### The reader never writes the table + +Reset bumps one global epoch counter and touches no slot. A slot clears itself on its **next sample**, when it +notices the epoch moved, and does so *inside* its own odd-generation window so a reader can never catch a +half-cleared slot. The game thread stays the only writer of the table — the invariant the whole watch section of +`flan_dev.c` rests on. + +The cost: a new window begins when the program next runs, not at the instant of the reset. For a frame loop that is +the only moment it could sensibly begin. **This bit me in the test** (see "What did not work" below) and the test now +waits for it and says why. + +### `i64` accumulates as a double + +A magnitude past 2^53 loses precision in the sum and in the ends of the range. Recorded rather than designed around: a +count, a coordinate and a tile index are what this is pointed at, and a second integer accumulator for a case nobody +has would be two code paths for one tool. `watch-num-i64` and `watch-num-f64` are two entry points only so a program +need not cast at the call site. + +### A whole number prints as one + +`watch.clj`'s `fmt-num` does this and the reason survives the port: a watch on an array index that reads `66.0000` +sends you looking for a rounding bug that is not there. + +--- + +## What was built, file by file + +All of it is **working** — built, run against a real daemon and a real running program, and covered by the test. +Nothing is stubbed and nothing is half-written. + +| File | Change | State | +|---|---|---| +| `runtime/flan_dev.c` | `watch_slot` grew `num`, `epoch` and five doubles; `watch_begin` clears `num`; new section "A number sampled thousands of times a frame" with `watch_record`, `flan_dev_watch_reset`, `flan_dev_watch_num_i64`, `flan_dev_watch_num_f64`, `watch_num_str`, `watch_render_num`; `flan_dev_watch_read` renders a num slot instead of copying its (unused) text | working | +| `vendor/agent/flan_agent.c` | `flan_dev_watch_reset` declaration; a `watch reset` command beside `watch on`/`watch off` | working | +| `lib/dev.ml` | `watch_read` takes `~reset` and sends `watch reset` *after* the read; the `:op "watch"` arm reads a `:reset` field the same way `watch-enable` reads `:on` | working | +| `emacs/flan-watch.el` | `flan-watch-ghost-call-regexp` `?` → `*`; docstring updated; `flan-watch--tick` sends `(:op "watch" :reset t)` | working | +| `test/programs/dev-watch.flan` | `declare-c watch-num-i64`; a `loop-cells` hot loop sampling `"cell"` eight times a step at 0,3,…,21 | working | +| `test/test_dev.ml` | assertions inside the existing watch block: the row exists, `n > 8` (per-sample not per-step), `min < max` (a range, not just `last`), a plain read does not reset, `:reset t` reopens the window | working | +| `BUILT.md` | new section "A hot loop keeps five numbers, and the window is the editor's", after the ghost text one | working | +| `PORTING.md` | item 5 struck in house style with the accumulator decision | working | +| `NEXT.md` | "What is left on `PORTING.md`'s list" corrected: item 5 done, item 6 is next | working | + +### Verified end to end, not just compiled + +Against a real `flan dev` daemon and the real running program, the `"cell"` row read back: + +``` +n=184 min=0 max=21 last=21 mean=10.5000 (first read) +n=368 min=0 max=21 last=21 mean=10.5000 (second read — a plain read does not reset) +n=16 min=0 max=21 last=21 mean=10.5000 (after :reset t — the window reopened) +``` + +The loop is 0,3,…,21, so `min`, `max`, `mean` and the per-step count of 8 are each pinned by a different part of it. + +### Test result + +`dune test` is **green**, run twice in full. `test_dev.ml`'s first block — the separately-known-flaky socket bind race +— failed on one earlier run with "the daemon never listened" and passed on both final runs. That is exactly the +documented flake and another agent is on it; it is not related to anything here. + +--- + +## The ghost text path — the brief's assumption, checked + +The brief said to reuse the ghost text path rather than invent a second one. **It fits, and it needed one character.** + +The important property is in `BUILT.md`: ghost text anchors on the **string literal in the source**, not on a source +location carried in the table. `flan-watch--ghost-sites` scans buffers shown in a window for +`( ""` and matches the name against the rows. So a new *kind* of watch needs nothing new in the daemon, +nothing new in the wire, and no new display path — the accumulator's rendered value is a string like +`n=368 min=0 max=21 last=21 mean=10.5` and flows through the existing `:watch` rows untouched. + +**Is it pull-based?** No, and the brief's phrasing is worth correcting for whoever reads this next. The watch is +**push-based** and that is load-bearing: `watch.clj` is pull (Emacs polls a render function), and `flan_dev.c`'s +comment explains at length why that does not port — an evaluation here compiles a module and `dlopen`s it, so a 5Hz +poll would be hundreds of `.so` files a minute. So the program pushes into a table and the editor reads *memory*. +Ghost text is not a poller at all: it is painted from `flan-watch--absorb`, off the same single reply that paints the +buffer, which is what keeps the two from disagreeing and preserves the one-request-in-flight invariant. The +accumulator sits inside that unchanged. + +**The one thing that did not fit.** `flan-watch-ghost-call-regexp` was `watch\(?:-[[:alnum:]]+\)?` — *one* optional +hyphenated segment. `[[:alnum:]]` does not match a hyphen, so on `(watch-num-i64 "cell" ...` the regexp matched +`watch-num`, then required whitespace, found `-i64`, and backtracked to failure. A numeric watch therefore appeared +normally in the watch buffer and got **no inline value at all** — a silent half-failure. `*` for `?` is the whole +fix, verified in `emacs --batch` against all four head shapes. + +That is worth recording as the predictable cost of the decision the ghost text section defends. Anchoring on the name +rather than on the head is what makes the head a `defcustom`, and a `defcustom` with an enumerated default is a +default that needs widening whenever the set of heads grows. Cheaper than the alternative, but not free. + +--- + +## What remains, in the order a fresh session should do them + +Item 5 is complete. These are the two gaps I would close next, both small. + +1. **`emacs/test-flan-watch.el` — add a `watch-num-i64` fixture site.** The regexp fix is covered by *nothing* + automated; I verified it by hand in `emacs --batch`. Add a fixture whose buffer text contains + `(watch-num-i64 "cell" x)` and assert `flan-watch--ghost-sites` returns `("cell" . …)`, alongside the existing + `watch-i64` fixtures. No daemon needed — the file is already in the suite via `test-flan-cider.el` and, per + `BUILT.md`, ghost text is a pure function from rows plus buffer text to overlays. **This is the highest-value + remaining item**: it is the one change in this commit with no regression test behind it. + +2. **`test/test_dev.ml` — assert the `n=0` render.** `watch_render_num` in `runtime/flan_dev.c` has a branch for a + window with no samples that emits `n=0 last=` (the range and the mean have nothing behind them, but `last` is + still the last value the name ever had). No test reaches it: the program in `dev-watch.flan` samples every step, + so a window is never empty in practice. Reaching it needs a reset followed by a read inside one step, or a + program with a watch call that runs only sometimes. + +Neither blocks anything. Item 6 (frame rollback as a worked example) is the next real piece of work and `NEXT.md` now +points at it. + +--- + +## What I tried that did not work + +- **Two Flan type errors in `dev-watch.flan`.** `(* i 3)` over a `let`-bound `0` is `i32`, and `watch-num-i64` wants + `i64` — fixed with an explicit `(i64 …)`. Then `loop-cells` was declared `i64` and returned the `i32` counter; + declared `i32`. Both are ordinary, but they cost two daemon round trips to find because a build error goes to the + daemon's stdout, not to the client — worth knowing if you are driving `flan dev` by hand. + +- **My first probe script spoke the wrong protocol.** `Wire.send` is **length-prefixed** (`"%d\n%s"`), not + newline-delimited. A newline-delimited client gets the connection closed with no error text. If you write a + throwaway client, read `lib/wire.ml:32` first. + +- **Two attempts at the reset assertion failed before the third stuck**, and both failures were informative rather + than noise: + 1. *"the window did not reopen: 8 after 8"* — one step writes eight samples, so two reads back to back leave no + room *under* the count for a reset to show in. The test now runs the accumulator up past 32 first (which is + itself the other half of the claim: every one of those polls is a plain read, and a plain read must not reset, + or the count could never climb). + 2. *"the window did not reopen: 40 after 40"* — reading immediately after `:reset t` still sees the old count, + because the reset bumps an epoch and the **slot clears on its next sample**. That is the design and not a bug; + the test now `await`s the drop and the comment explains why the laziness is what keeps the reader out of the + table. + +- **Not attempted, deliberately:** the `(watch "hp" hp)` arm in `check.ml`. Both `PORTING.md` and `flan_dev.c` say + it is only wanted for *composites* (a struct, a slice, a union), which need `Render.render` pointed at the + `flan_dev_watch_emit_*` emitters. A scalar and an accumulator both reach the runtime by plain `declare-c` and need + no compiler change, which is the property that kept this change out of `check.ml` entirely. + +- **One worktree note.** This worktree was checked out at `2c232dd`, an ancient commit, rather than at `dev-loop`'s + tip — none of `PORTING.md`, `NEXT.md`, `lib/dev.ml` or `runtime/` existed in it. `git reset --hard dev-loop` on a + clean tree fixed it. Also, `dune` finds the parent repo's root from inside a worktree, so every build here is + `dune build --root .` / `dune test --root .`. diff --git a/NEXT.md b/NEXT.md index 76083c8..cd6658e 100644 --- a/NEXT.md +++ b/NEXT.md @@ -116,10 +116,12 @@ would not read freed memory. The map path was left alone rather than converted h ### What is left on `PORTING.md`'s list -Tier 0 is finished. Of Tier 1, items 5 and 6 are the next two and they are both dev-loop work rather than language -work: **a watch for a *running* program** (the stopped-stack inspector is a different tool for a different moment — -`watch.clj` + `spy-num` is the shape, and the numeric accumulator for hot loops is the part that is least obvious), -and **frame rollback as a worked example** — `snapshot`/`restore` callbacks beside the `continue` restart, which is +Tier 0 is finished, and so is item 5. **The watch for a running program is done** — the `spy` half was already +built (a pushed table, the watch buffer, inline ghost text) and the `spy-num` half landed 2026-09-13: a hot-loop slot +keeps count/min/max/last/mean, the write path does no formatting, and the window is since the editor's last tick +rather than cumulative, which is a deliberate divergence from `watch.clj` argued in `BUILT.md`, "A hot loop keeps +five numbers, and the window is the editor's". So of Tier 1 **item 6 is the next one**: **frame rollback as a worked +example** — `snapshot`/`restore` callbacks beside the `continue` restart, which is now genuinely reachable from a bad index and so is worth more than it was yesterday. `bounds-condition.flan` shows an abandoned frame leaving half-written state behind; rollback is what finishes that thought. diff --git a/PORTING.md b/PORTING.md index 9289b55..1bfcd48 100644 --- a/PORTING.md +++ b/PORTING.md @@ -513,11 +513,35 @@ not compete for the same slot. `continue` instead of ending the process, the missing half is the rollback, because an abandoned frame leaves the grid half-written. That is the next thing on this list. -5. **A watch for a running program.** `watch.clj` + `watch.el` + `spy` + `spy-num` is a - tool the author built deliberately and uses constantly, and the stopped-stack inspector - is a different tool for a different moment. Given that the dev loop is the priority, - this outranks every language feature below it. The numeric accumulator for hot loops is - the part that is least obvious and most valuable. +5. ~~**A watch for a running program.**~~ **Done, 2026-09-13.** The `spy` half — a + pushed table of labelled values, read as memory while the program runs, and still + readable while it is stopped — was already built, along with the watch buffer and the + inline ghost text. What landed today is the `spy-num` half, which is the part this item + called least obvious and most valuable, and it is the part that was missing. + + **A slot keeps five numbers: count, min, max, last, mean.** Each answers a question you + can ask without building a query — `n` is the first thing wrong when a loop is wrong, + the range is what one sample can never show you, `last` is what the scalar watch would + have given you, and the mean is carried as a sum and divided at read time because a + mean accumulated as a mean drifts. A small ring of the last N samples was the other + candidate and loses: N out of 91,200 is a sample of the *tail* of the loop rather than + of the loop. **The write path does no formatting** — a sample is a load, five compares + and the slot's seqlock, and the listener thread renders once per editor tick, which is + the whole reason `spy-num` exists rather than a second `spy`. + + **One deliberate divergence from `watch.clj`**, recorded because it is a real + disagreement and not an oversight: there the stats are cumulative until `reset-spies!`. + Here the window is since the editor's last tick. Cumulative min and max reach the + session's extremes within a few seconds of play and then never move again, so the two + most useful of the five go dead exactly when you start interacting with the thing you + are debugging — and this tool exists to show you a number while you drag the mouse. + Reset is its own message rather than a side effect of reading, so anything that polls + cannot shorten the window under the editor that owns it. + + Full reasoning in `BUILT.md`, "A hot loop keeps five numbers, and the window is the + editor's". One thing this did *not* need and is worth saying: no arm in `check.ml`. The + accumulator is reached by the same plain `declare-c` the scalars use, so the `(watch + "hp" hp)` form is still unbuilt and still only wanted for composites. 6. **Frame rollback in the engine pattern.** Not a language feature — the pieces are all there (`restart-case`, struct assignment, fixed arrays as values). What is missing is diff --git a/emacs/flan-watch.el b/emacs/flan-watch.el index bcca93a..029d7db 100644 --- a/emacs/flan-watch.el +++ b/emacs/flan-watch.el @@ -204,12 +204,14 @@ was the only consumer and wrong the moment it was not.") ;; does not need to: you opened it deliberately and `flan:stopped(...)' is ;; already in view. -(defcustom flan-watch-ghost-call-regexp "watch\\(?:-[[:alnum:]]+\\)?" +(defcustom flan-watch-ghost-call-regexp "watch\\(?:-[[:alnum:]]+\\)*" "Regexp matching the head of a call that writes the watch table. Matched against the symbol after an open paren, with the watched name as a -string literal after it. The default covers `watch', `watch-i64', `watch-f64' -and `watch-str'. It is a setting because the Flan name is the program author's +string literal after it. The default covers `watch', `watch-i64', `watch-f64', +`watch-str' and the two-segment accumulator names `watch-num-i64' and +`watch-num-f64' — hence the `*\=' rather than a `?\=', which stopped at one +hyphenated segment and so found no site for a numeric watch at all. It is a setting because the Flan name is the program author's own `declare-c' binding — only the C symbol behind it is fixed, and the editor never sees that." :type 'regexp) @@ -385,7 +387,15 @@ sends the next question, leaving at most one request in flight — the invariant (flan-watch--absorb reply))) (unless flan-watch--pending (condition-case nil - (progn (flan-dev--send flan-dev--connection '(:op "watch")) + ;; `:reset t' opens a new accumulation window for the numeric + ;; slots, so their count, range and mean are "since the last tick" + ;; rather than since the program started. That is the whole of the + ;; accumulator decision as the editor sees it: a min and a max over + ;; a session go dead within seconds of play, and this tool exists to + ;; show a number while you drag the mouse. The reset happens after + ;; the read, on the daemon's side, so this tick's numbers are the + ;; last tick's window and nothing is lost between the two. + (progn (flan-dev--send flan-dev--connection '(:op "watch" :reset t)) (setq flan-watch--pending t)) (error (flan-watch-stop))))))) diff --git a/lib/dev.ml b/lib/dev.ml index f4aa5ac..8e763f5 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -1633,11 +1633,22 @@ let watch_enable t ~on = said nothing about it would be lying by omission. It is a flag and not a count on purpose: the only number available is of write *attempts* that missed, which at frame rate says "3847 names" about one name. *) -let watch_read t = +(* [~reset] opens a new accumulation window for the numeric slots, *after* the + read rather than instead of it. A read that reset as a side effect would + make looking change what is there, so anything that polls would silently + shorten the window and come back with a count that means nothing. Two + messages down a unix socket, one of which is four bytes of reply. *) +let watch_read t ~reset = match request t "watch" with | text -> + let finish result = + if reset then + (try ignore (request t "watch reset") with Unix.Unix_error _ -> ()); + result + in let lines = String.split_on_char '\n' text in - (match lines with + finish + @@ (match lines with | [] -> error "the program gave an empty watch reply" | hdr :: rows when not (String.length hdr >= 3 && String.sub hdr 0 3 = "err") -> @@ -1757,7 +1768,15 @@ let handle t req = ~on:(match Wire.field req "on" with | Some { Form.v = Form.Sym "nil"; _ } | None -> false | Some _ -> true) - | Some "watch" -> watch_read t + | Some "watch" -> + (* Same shape as [:on] above: absent or [nil] is false, anything else is + true. Absent is the important half — a reader that does not ask to reset + must not, so a second editor or a test polling the table cannot cut the + window short under the one that does. *) + watch_read t + ~reset:(match Wire.field req "reset" with + | Some { Form.v = Form.Sym "nil"; _ } | None -> false + | Some _ -> true) | Some "disassemble" -> (match Wire.string_field req "name" with | Some name -> diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index c2011fd..3c439ea 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -382,6 +382,12 @@ typedef struct { char val[WATCH_VAL]; uint32_t len; int full; /* the value did not fit */ + /* An accumulator slot. [val]/[len] are unused; the five doubles below are + * the value, and the *reader* turns them into text. See "A number sampled + * thousands of times a frame" below. */ + int num; + uint64_t epoch; /* the window [n]/[lo]/[hi]/[sum] belong to */ + double n, lo, hi, last, sum; uint64_t gen; /* this slot's own seqlock */ } watch_slot; @@ -389,6 +395,12 @@ static watch_slot watch_table[WATCH_MAX]; static uint32_t watch_used; /* claimed slots; only ever grows */ static int watch_overflowed; /* some name found no slot; see above */ +/* The current accumulation window. Bumped by [flan_dev_watch_reset], which is + * the editor saying "start again from here"; a slot notices on its next + * sample. The counter is the reader's to move and the slots are the writer's + * to clear, so no thread ever writes the other's memory. */ +static uint64_t watch_epoch; + /* One counter per slot rather than one for the table. * * A table-wide counter would make a read all-or-nothing: the reader would have @@ -480,6 +492,11 @@ int flan_dev_watch_begin(const char *name) { __atomic_thread_fence(__ATOMIC_RELEASE); s->len = 0; s->full = 0; + /* A name written as text is a text slot from now on. The table's rule + * everywhere else is that the last writer wins and this is the same rule; + * a program that watches one name both ways gets whichever ran last, and + * that is not worth a guard on the frame thread. */ + s->num = 0; return 1; } @@ -620,6 +637,149 @@ int32_t flan_dev_watch_str(const char *name, const char *s) { return 1; } +/* ── A number sampled thousands of times a frame ─────────────────────── */ + +/* The scalar watch above keeps one value per name, and from a hot inner loop + * that is nearly useless: you see whichever of the 91,200 cells happened to + * run last. This is the other half — the port of [spy-num] from the author's + * watch.clj, which exists for exactly that reason and is the piece PORTING.md + * calls the least obvious and the most valuable. + * + * **What a slot keeps: count, min, max, last, mean.** Five numbers, and the + * argument for them is that they are the ones you can ask for without building + * a query. [n] says how many times the expression ran, which is the first + * thing that is wrong when a loop is wrong. [min] and [max] are the range, + * which is what you are looking for when you suspect an index or a velocity is + * leaving the region it should stay in — and a range is the thing a single + * sample can never show you. [last] is the one sample, kept because it is what + * the scalar watch would have given you and losing it would be a regression. + * [mean] is carried as a running [sum] and divided at read time, because a + * mean accumulated as a mean drifts and a sum does not. + * + * **What it deliberately does not keep: a ring, or a history.** A small ring + * of the last N samples was the other candidate. It loses on the only ground + * that matters here: N samples out of 91,200 is a sample of the *tail* of the + * loop, not of the loop, so it answers "what did the last few cells do" when + * the question is "what did the cells do". Every richer answer than five + * numbers is a UI for building a query, and a query builder is the one thing + * this design exists not to be. + * + * **The write path does no formatting.** That is the entire point and is where + * the naive version dies: an [snprintf] per sample at thousands of samples per + * frame is a HUD that costs more than the game. A sample here is a load, five + * compares and stores, and the slot's seqlock. The *reader* — the agent's + * listener thread, once per editor tick — turns the five numbers into text. + * watch.clj reaches the same place with a double-array per label and a render + * function Emacs calls; the reasoning is the author's, not ours. + * + * **The window is since the last reset, not since the program started.** This + * is the one place this deliberately *diverges* from watch.clj, whose stats + * are cumulative until [reset-spies!] is called by hand. Cumulative is the + * wrong default for a frame loop: a min and a max over a whole session reach + * the session's extremes within a few seconds of play and then never move + * again, so the two most useful of the five go dead exactly when you start + * interacting with the thing you are debugging. This tool exists to show you a + * number while you drag the mouse. So the editor bumps + * [flan_dev_watch_reset] on its tick and each slot clears itself on its next + * sample, which makes the displayed range "since you last looked" — a fifth of + * a second, a dozen frames — and keeps it tracking the present. A caller that + * wants cumulative numbers gets them by not resetting; that is the setting, + * and it lives in the editor rather than here. + * + * **i64 accumulates as a double**, so a magnitude past 2^53 loses precision in + * the sum and in the ends of the range. Said rather than designed around: a + * count, a coordinate and a tile index are what this is pointed at, and a + * second integer accumulator to cover the case nobody has would be two code + * paths for one tool. */ +static void watch_record(watch_slot *s, double v) { + uint64_t e = __atomic_load_n(&watch_epoch, __ATOMIC_RELAXED); + /* Odd first, then the change, then even — [flan_dev_watch_begin]'s protocol + * exactly, and the epoch check is *inside* the odd window so a reader can + * never catch a half-cleared slot. */ + __atomic_store_n(&s->gen, s->gen | 1, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + s->num = 1; + if (s->epoch != e) { s->epoch = e; s->n = 0; s->sum = 0; } + if (s->n == 0) { s->lo = v; s->hi = v; } + else { if (v < s->lo) s->lo = v; if (v > s->hi) s->hi = v; } + s->n += 1; + s->sum += v; + s->last = v; + __atomic_store_n(&s->gen, (s->gen | 1) + 1, __ATOMIC_RELEASE); +} + +/* Start a new window. Called by the editor beside its table read, never by the + * program. It moves one counter and touches no slot, which is what keeps the + * game thread the only writer of the table. */ +void flan_dev_watch_reset(void) { + __atomic_add_fetch(&watch_epoch, 1, __ATOMIC_RELEASE); +} + +/* Reachable from a program by [declare-c], the same as the four scalars and + * for the same reason — no arm in the checker, nothing the compiler learns: + * + * (declare-c watch-num-i64 [name string x i64] i32 "flan_dev_watch_num_i64") + * ... + * (watch-num-i64 "cell" (at grid i)) + * + * Two entry points rather than one so a program need not cast an i64 to an f64 + * at the call site, which is noise in the one place this is meant to be + * droppable into. */ +int32_t flan_dev_watch_num_i64(const char *name, int64_t x) { + if (!__atomic_load_n(&watch_on, __ATOMIC_RELAXED)) return 0; + watch_slot *s = watch_find(name); + if (s == NULL) return 0; + watch_record(s, (double)x); + return 1; +} + +int32_t flan_dev_watch_num_f64(const char *name, double x) { + if (!__atomic_load_n(&watch_on, __ATOMIC_RELAXED)) return 0; + watch_slot *s = watch_find(name); + if (s == NULL) return 0; + watch_record(s, x); + return 1; +} + +/* A whole number prints as one. watch.clj's [fmt-num] does this and the reason + * is worth keeping: a spy on an array index that reads 66.0000 makes you look + * for a rounding bug that is not there. */ +static void watch_num_str(char *out, size_t cap, double d) { + if (d >= -9.0e15 && d <= 9.0e15 && d == (double)(long long)d) + snprintf(out, cap, "%lld", (long long)d); + else + snprintf(out, cap, "%.4f", d); +} + +/* Rendered on the listener thread, from numbers already copied out of the + * slot, so nothing here races and nothing here runs per sample. Comfortably + * inside [WATCH_VAL]: five numbers at %.4f and their labels is well under 192 + * bytes, and [snprintf] truncates rather than overruns if a caller's buffer is + * smaller than [flan_dev_watch_val_cap]. */ +static uint64_t watch_render_num(double n, double lo, double hi, double last, + double sum, char *vd, uint64_t vcap) { + if (vcap == 0) return 0; + char b[4][40]; + int k; + if (n == 0) { + /* No sample since the window opened. The range and the mean have nothing + * behind them and showing the previous window's would be a lie, but [last] + * is still the last value this name ever had, so it is kept. */ + watch_num_str(b[0], sizeof b[0], last); + k = snprintf(vd, (size_t)vcap, "n=0 last=%s", b[0]); + } else { + watch_num_str(b[0], sizeof b[0], lo); + watch_num_str(b[1], sizeof b[1], hi); + watch_num_str(b[2], sizeof b[2], last); + watch_num_str(b[3], sizeof b[3], sum / n); + k = snprintf(vd, (size_t)vcap, "n=%lld min=%s max=%s last=%s mean=%s", + (long long)n, b[0], b[1], b[2], b[3]); + } + if (k < 0) return 0; + if ((uint64_t)k > vcap - 1) return vcap - 1; + return (uint64_t)k; +} + /* ── Reading the table back ─────────────────────────────────────────── */ /* How many slots have ever been claimed. Only grows, so a reader walking @@ -664,15 +824,22 @@ int flan_dev_watch_read(uint32_t i, char *nd, uint64_t ncap, for (int attempt = 0; attempt < 64; attempt++) { uint64_t g1 = __atomic_load_n(&s->gen, __ATOMIC_ACQUIRE); if (g1 & 1) continue; /* a write is in progress */ + /* An accumulator slot carries five doubles rather than rendered text, so + * what is copied under the seqlock is the numbers; the formatting happens + * *after* the counter check, out of locals, which is why it is safe to do + * it here at all. See "A number sampled thousands of times a frame". */ + int isnum = __atomic_load_n(&s->num, __ATOMIC_RELAXED); + double an = s->n, alo = s->lo, ahi = s->hi, alast = s->last, asum = s->sum; size_t n = __atomic_load_n(&s->len, __ATOMIC_RELAXED); if (n > WATCH_VAL) n = WATCH_VAL; /* a torn read cannot overrun */ if ((uint64_t)n > vcap) n = (size_t)vcap; - memcpy(vd, s->val, n); + if (!isnum) memcpy(vd, s->val, n); /* Ordered before the second read of the counter, or the check is of a copy * the compiler was free to make afterwards. */ __atomic_thread_fence(__ATOMIC_ACQUIRE); if (__atomic_load_n(&s->gen, __ATOMIC_ACQUIRE) == g1) { - *vlen = (uint64_t)n; + *vlen = isnum ? watch_render_num(an, alo, ahi, alast, asum, vd, vcap) + : (uint64_t)n; return 1; } } diff --git a/test/programs/dev-watch.flan b/test/programs/dev-watch.flan index 69f45c1..5283a5d 100644 --- a/test/programs/dev-watch.flan +++ b/test/programs/dev-watch.flan @@ -15,9 +15,20 @@ (declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64") (declare-c watch-f64 [name string x f64] i32 "flan_dev_watch_f64") (declare-c watch-str [name string s string] i32 "flan_dev_watch_str") +;; The accumulator, for a value sampled from inside a hot loop. A scalar watch +;; there shows whichever iteration happened to run last, which is the case this +;; exists for; see flan_dev.c, "A number sampled thousands of times a frame". +(declare-c watch-num-i64 [name string x i64] i32 "flan_dev_watch_num_i64") (defvar ticks i64) +(defn loop-cells [] i32 + (let [i 0] + (while (< i 8) + (watch-num-i64 "cell" (i64 (* i 3))) + (set i (+ i 1))) + i)) + (defn step [] i64 (set ticks (+ ticks 1)) ;; Three types, because the table stores *rendered text* and the rendering @@ -26,6 +37,11 @@ (watch-i64 "ticks" ticks) (watch-f64 "half" (/ (f64 ticks) 2.0)) (watch-str "label" "sand") + ;; A hot inner loop, and the value *varies* across it — which is what makes + ;; the row a test of the accumulator rather than of the plumbing. A slot that + ;; only kept [last] would report 21 and no range; n, min and max are each + ;; pinned by a different part of this loop. + (loop-cells) ticks) (defn main [] i32 diff --git a/test/test_dev.ml b/test/test_dev.ml index c5bba88..303d57c 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -1963,6 +1963,72 @@ let () = | Some "\"sand\"" -> () | Some v -> fail "watch rendered a string as %s, unquoted" v | None -> fail "watch lost the string"); + (* The accumulator, which is the other half of the watch and the + half a scalar row cannot stand in for. [loop-cells] samples "cell" + eight times per step at 0, 3, ... 21, so the row has to show a + *range* and a count far above the number of steps. A slot that kept + only the last sample would say 21 and nothing else, and a slot that + counted steps rather than samples would say a number near "ticks". + See flan_dev.c, "A number sampled thousands of times a frame". *) + let stat row key = + let parts = String.split_on_char ' ' row in + List.find_map + (fun p -> + let k = key ^ "=" in + let n = String.length k in + if String.length p > n && String.sub p 0 n = k then + float_of_string_opt (String.sub p n (String.length p - n)) + else None) + parts + in + (match List.assoc_opt "cell" t with + | None -> fail "the accumulator never reached the watch table" + | Some row -> + (match stat row "n", stat row "min", stat row "max" with + | Some n, Some lo, Some hi -> + (* More samples than there were steps: the loop is being counted + per iteration, which is the whole reason this is not a scalar + watch. *) + if n < 8.0 then fail "the accumulator counted %g samples" n; + (* And a range, which is what one sample can never show. *) + if not (lo < hi) then + fail "the accumulator kept no range: min=%g max=%g" lo hi + | _ -> fail "the accumulator rendered as %s" row)); + (* And the window is the editor's to close. [:reset t] starts a new + one, so the count drops to what the program has done since — + which is what makes min and max track the present instead of + reaching the session's extremes and going dead. A read *without* + it must not reset, or anything that polls would cut the window + short under the editor that owns it. *) + let cell_n () = + match List.assoc_opt "cell" (table ()) with + | Some row -> stat row "n" + | None -> None + in + (* Let it run up first. One step writes eight samples, so two reads + back to back leave no room under the count for a reset to show in — + the assertion needs a window with something in it. Polling to get + there is itself the other half of the claim: every one of these + reads is a plain [watch], and a plain [watch] must not reset, or + the count could never climb at all. *) + if not (await (fun () -> + match cell_n () with Some n -> n > 32.0 | None -> false)) + then fail "the accumulator never ran up; a plain read must not reset" + else begin + let high = match cell_n () with Some n -> n | None -> 0.0 in + ignore (ask "(:op \"watch\" :reset t)"); + (* Waited for, not read once. A reset moves one counter and clears no + slot — the slot notices on its *next sample*, which is the game + thread's next time round the loop. That is the design and not a + delay to work around: the reader never writes the table, so the + game thread stays its only writer. The cost is that the new window + begins when the program next runs, which for a frame loop is the + only moment it could sensibly begin anyway. *) + if not (await (fun () -> + match cell_n () with Some n -> n < high | None -> false)) + then fail "the window never reopened after a reset; it stayed at %g" + high + end; (* It keeps moving. A table that filled once and froze would pass everything above and be useless — the counter has to be the program's, not a snapshot the daemon took when it armed. *) diff --git a/vendor/agent/flan_agent.c b/vendor/agent/flan_agent.c index 523b960..82e5604 100644 --- a/vendor/agent/flan_agent.c +++ b/vendor/agent/flan_agent.c @@ -82,6 +82,10 @@ uint64_t flan_dev_result_cap(void); * opening and closing a watch buffer is a message that arrives here. */ void flan_dev_watch_enable(int on); int flan_dev_watch_enabled(void); +/* And [reset], which opens a new accumulation window for the numeric slots. + * It moves one counter and touches no slot, so the game thread stays the only + * writer of the table. */ +void flan_dev_watch_reset(void); uint32_t flan_dev_watch_count(void); int flan_dev_watch_overflowed(void); int flan_dev_watch_read(uint32_t i, char *nd, uint64_t ncap, @@ -894,6 +898,21 @@ static void handle_line(char *line, sink *o) { reply(o, "ok\n"); return; } + /* "watch reset" opens a new window for the numeric accumulators: their + * count, range and mean start again from the next sample, while a text slot + * is untouched. + * + * A separate command rather than a side effect of the read, which was the + * tempting shape and is the wrong one. A destructive read makes *looking* + * change what is there, so anything that polls — a test's [await], a second + * editor, a person pressing the read twice — silently shortens the window + * and gets a count that is noise. Reading is free and resetting is a + * decision; the editor makes it once per tick, after the read. */ + if (strcmp(line, "watch reset") == 0) { + flan_dev_watch_reset(); + reply(o, "ok\n"); + return; + } if (strcmp(line, "watch") == 0) { /* One line per slot: NAMEVALUE. The name cannot contain a tab — it is * a C identifier-ish string the program passed — and the value cannot