Merge branch 'worktree-agent-a8eecdace6e85881f' into dev-loop

This commit is contained in:
Joseph Ferano 2026-09-13 14:20:51 +07:00
commit 06810e3906
6 changed files with 110 additions and 275 deletions

View File

@ -4872,6 +4872,19 @@ invariant the whole of `flan_dev.c`'s watch section rests on. The cost is that a
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.
**The lazy clear stays lazy, and a stopped program is the case that decides it.** The obvious tidy-up is to make the
reader epoch-aware — compare the slot's epoch with the global one under the seqlock and report an untouched slot as
empty — which would make a reset visible in the very next tick instead of in the tick after the program's next
sample. It is the wrong trade, because **a stopped program does not sample**. An epoch-aware reader would blank the
watch for as long as the program sat in a break loop, and reading the numbers from the moment you stopped is the
entire point of stopping. The lazy clear gives exactly the right answer there. What was actually wrong was narrower
and lives in the editor: `flan-watch--tick` was sending `:reset t` five times a second at a program that could not
answer it. So the reset is now guarded on `flan-dev--stopped` — the read still goes out every tick, only the reset
field drops — and the runtime is untouched. That keeps the policy where the rest of this section already put it:
"since you last looked" is the editor's idea, not the table's. `watch_render_num`'s unreachable `n=0` arm is deleted
rather than commented, since the only way to reach it is the epoch check that was just rejected, and dead code is an
invitation to add one.
**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

View File

@ -1,252 +0,0 @@
# 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
`(<head> "<name>"` 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
Item 5 is complete, and so is the fixture the previous list asked for: `emacs/test-flan-watch.el` now has a block
"Every head the default regexp claims to cover" pinning `watch-num-i64` and `watch-num-f64` beside the one-segment
heads. It was verified by mutation — restoring the old `?\=` in `flan-watch-ghost-call-regexp` makes exactly those
two checks fail and nothing else — so a regression in that regexp is now caught.
One item is left, and it turned into a finding rather than a test.
1. **`watch_render_num`'s `n=0` branch is unreachable, so the test that was asked for cannot be written.**
`runtime/flan_dev.c:764` renders `n=0 last=<v>` for a window with no samples. Nothing can present that state to
the reader:
- `s->num` is set to 1 in exactly one place, `watch_record` (flan_dev.c:701), and that function always falls
through to `s->n += 1`. The clear (`s->n = 0` on an epoch change) and the increment are both inside one
odd-generation window, so a reader cannot see between them.
- `flan_dev_watch_begin` sets `s->num = 0` and never touches `s->n`, so the scalar path cannot leave a num slot
at zero either.
- `flan_dev_watch_read` never compares `s->epoch` to `watch_epoch`. A reset therefore does not make the slot read
as empty.
The user-visible consequence is worth stating on its own, because it is the thing the branch was presumably
written for: **after `:reset t`, a read taken before the program's next sample reports the *previous* window's
five numbers, not an empty one.** That is why `test_dev.ml`'s reset assertion has to `await` the count dropping
rather than read once — the test is already working around this.
Two ways out, and both are decisions rather than test work, which is why neither was taken here (the lane was
scoped to tests):
- **Delete the branch.** Honest about what the design says: a window begins when the program next runs, and until
then the last window is what there is to show.
- **Make the read epoch-aware** — compare `s->epoch` with `watch_epoch` under the seqlock and render `n=0` when
they differ. This makes a reset visible immediately without the reader writing the table, and would let the
existing `await` in the test become a single read. It is a behaviour change to what an editor sees in the tick
after a reset, so it belongs to whoever owns `runtime/flan_dev.c`, not to a test pass.
Item 6 (frame rollback as a worked example) is the next real piece of work and `NEXT.md` 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 .`.

14
NEXT.md
View File

@ -279,7 +279,7 @@ wait a minute now, bounded by the watchdog, with a named `listening` helper in `
re-derive from `HANDOFF-f1.md` is its "lengthening any timeout" bullet — that applies to the `agent.sock` check
only, and reading it as a rule is what kept this open.
### From `HANDOFF-f3.md` — the watch is built; two tests are not
### ~~From `HANDOFF-f3.md` — the watch is built; two tests are not~~
`spy-num` landed and `PORTING.md` Tier 1 item 5 is closed. The `spy` half already existed — the finding that
reframed the task is that the table, the agent commands, the buffer and the ghost text were all there at `344e571`,
@ -291,12 +291,12 @@ writes the table.
The fixture is in: `emacs/test-flan-watch.el` pins both accumulator heads beside the one-segment ones, verified by
putting the old `?` back in `flan-watch-ghost-call-regexp` and watching exactly those two checks fail.
1. **`watch_render_num`'s `n=0` branch is dead, and the choice is whose it is to remove.** No test can reach it:
`s->num` is set only in `watch_record`, which always falls through to `s->n += 1` inside one odd-generation
window, and the reader never compares the slot's epoch to the global one. The live consequence is that a read
after `:reset t` and before the program's next sample reports the *previous* window — which is why the test waits
for the count to drop instead of reading once. Either delete the branch or make the read epoch-aware; both are
`runtime/flan_dev.c` decisions. `HANDOFF-f3.md` has the argument.
**Closed.** The dead `n=0` branch in `watch_render_num` is deleted and the reader stays epoch-blind on purpose: a
stopped program does not sample, so an epoch-aware reader would blank the watch for the whole of a break loop, which
is exactly when the numbers are being read. What was actually wrong was in the editor — `flan-watch--tick` sent
`:reset t` five times a second at a program that could not answer it — and it is now guarded on `flan-dev--stopped`,
with the read still going out every tick. `emacs/test-flan-watch.el` pins both halves. `HANDOFF-f3.md` is deleted;
the reasoning is in `BUILT.md`, "A hot loop keeps five numbers, and the window is the editor's".
### Not started: five more raylib core examples

View File

@ -395,7 +395,23 @@ sends the next question, leaving at most one request in flight — the invariant
;; 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))
;;
;; Except while the program is stopped, when the read still happens
;; and the reset does not. A stopped program takes no samples, so
;; there is no window for a reset to close and none for it to open:
;; the runtime clears a slot lazily, on its next sample, which is
;; what keeps a paused program showing the numbers from the moment
;; you paused it — the whole reason to pause. Resetting anyway
;; would ask for a new window five times a second that nothing can
;; fill. The guard lives here rather than in the runtime because
;; "since you last looked" is the editor's policy, not the table's.
;; `flan-dev--stopped' is the one place that state is tracked, and
;; `flan-dev.el''s background poll keeps it current whether or not
;; anyone is evaluating.
(progn (flan-dev--send flan-dev--connection
(if flan-dev--stopped
'(:op "watch")
'(:op "watch" :reset t)))
(setq flan-watch--pending t))
(error (flan-watch-stop)))))))

View File

@ -210,5 +210,54 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
(test-flan--check "and the last consumer out disarms the table"
(and (null flan-watch--consumers) torn))))
;; --- The reset is guarded on the stop ------------------------------------
;;
;; `flan-watch--tick' asks for "since you last looked" by sending `:reset t'
;; beside the read. A stopped program takes no samples, so there is no window
;; for a reset to close and none for it to open — and the runtime's lazy clear
;; is what keeps a paused program showing the numbers from the moment you
;; paused it. So the tick must drop `:reset' while stopped and keep reading.
;;
;; Asserted at the level the rest of this file works at: no daemon, no socket.
;; The tick is a function from `flan-dev--stopped' to the form it puts on the
;; wire, and that is the whole claim, so `flan-dev--send' and `process-live-p'
;; are stubs. What this cannot reach is the daemon actually honouring the
;; absent field; `test/test_dev.ml' drives a real program for that.
(defun test-flan-watch--tick-form (stopped)
"The form `flan-watch--tick' sends with the program STOPPED or not."
(let ((sent nil)
(flan-dev--stopped stopped)
(flan-dev--connection 'a-process)
(flan-dev--busy nil)
(flan-watch--pending nil)
;; Not `buffer': that consumer checks for a live watch buffer first and
;; would drop the subscription instead of ticking.
(flan-watch--consumers '(ghost)))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
((symbol-function 'flan-dev--take-reply) (lambda (_) nil))
((symbol-function 'flan-dev--send)
(lambda (_proc form) (setq sent form))))
(flan-watch--tick)
(list sent flan-watch--pending))))
(let ((running (test-flan-watch--tick-form nil))
(stopped (test-flan-watch--tick-form "BoundsError")))
(test-flan--check
"a tick while the program runs asks for the window it is closing"
(equal (nth 0 running) '(:op "watch" :reset t)))
(test-flan--check
"a tick while the program is stopped sends no reset"
(null (plist-get (nth 0 stopped) :reset)))
;; Skipping the tick outright would be worse than resetting: the watch would
;; freeze at whatever it held when the program stopped, and a break loop is
;; exactly when the numbers are being read.
(test-flan--check
"but it still reads the table"
(equal (plist-get (nth 0 stopped) :op) "watch"))
(test-flan--check
"and still has a reply in flight, so the cycle survives the pause"
(and (nth 1 running) (nth 1 stopped))))
(provide 'test-flan-watch)
;;; test-flan-watch.el ends here

View File

@ -755,26 +755,35 @@ static void watch_num_str(char *out, size_t cap, double d) {
* 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]. */
* smaller than [flan_dev_watch_val_cap].
*
* **[n] is at least 1 whenever this runs**, so there is no empty-window arm
* and [sum / n] cannot divide by zero. A slot only becomes a num slot inside
* [watch_record], which always falls through to [s->n += 1], and the clear on
* an epoch change sits in the same odd-generation window as that increment, so
* no reader can catch a num slot at zero.
*
* There used to be an "n=0 last=..." arm here, for the window a reset opens
* before the program's next sample. It is deleted rather than kept, because
* the only way to reach it is to compare [s->epoch] with [watch_epoch] in
* [flan_dev_watch_read] and that is the one thing the reader must not do. A
* stopped program does not sample. An epoch-aware reader would blank the watch
* for as long as the program stayed stopped, and looking at the numbers from
* the moment you stopped is the whole point of stopping. The lazy clear is
* right there; its cost is one stale tick in a *running* program, and the
* editor keeps even that honest by not sending [:reset] while the program is
* stopped. See [flan-watch--tick]. */
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]);
}
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;