source_utils_cellLoad.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"

' The cell-load ledger — how much work did a screen's CELLS do, and how much of it was waste?
'
' ## Why this exists as a second ledger rather than more fields on `screenLoad`
'
' [`screenReadiness.bs`](screenReadiness.bs) answers "when did this screen become usable
' and when did it stop changing" — a question with a beginning and an end, whose state
' lives on the SCREEN's `m`. This answers a different one: how many times were cells bound,
' and how much network did that cost. Two things force it apart:
'
'   1. **Cells are separate components**, so they cannot reach the screen's `m` at all.
'      `screenReadiness`'s own header names this constraint and takes the same way out
'      (a field the parent observes) for `ExtrasRowList.contentReady`.
'   2. **The interesting behaviour happens AFTER settle.** A cell rebind storm during
'      scrolling is invisible to a ledger that closes when the screen stops loading.
'
' It deliberately keeps every other convention `screenLoad` established: namespaced free
' functions, the `#if perfTiming` gate INSIDE each sub so a shipped build compiles them to
' empty calls, one uniform emit shape so `scripts/measurements.js` needs ONE entry covering
' every screen present and future, and an `instrumentUs` that measures the instrument's own
' footprint rather than arguing about it.
'
' ## Where the counters live, and why the content root
'
' On the RowList / MarkupGrid content root — the same node `initTextureManager` already
' hangs `loadedRowRange` on, and the one every cell already holds a reference to
' (`m.contentRoot`). Three reasons it is the right home and not `m.global`:
'
'   - **Thread-local.** All SceneGraph component code runs on the render thread, so a cell
'     incrementing a content-root field never crosses a boundary. MEASURED on device rather
'     than assumed, on HomeRows' root specifically because a Task also writes that node:
'     100 writes to a locally-created node took 351 us, 100 to the content root 379 us —
'     ~3.8 us per write, a ratio of 1.08. A rendezvous would be orders of magnitude.
'     ⚠️ An earlier version of this note said `m.global` is "main-thread-owned and WOULD
'     cross". That is WRONG and was refuted by measurement 2026-08-23: `m.global` is
'     RENDER-owned, so from a cell (render thread) it costs 2.0 us against a local node's
'     1.7 us — no crossing at all. It is 93 us from a Task thread. The content root is still
'     the right home, on the two grounds below plus per-screen scoping; only the threading
'     rationale was false. See `docs/architecture/threading.md`.
'   - **Race-free without any locking.** The render thread is single-threaded, so
'     read-modify-write across cells cannot interleave.
'   - **Free live readout.** Node fields are readable over ODC, so a probe can watch the
'     counters mid-scroll without waiting for an emit. There is no second mechanism.
'
' ## The pop-in half — a race, not a count
'
' `appearances` / `popIns` / `popInsCold` and the `loadMs` trio answer a different question
' from the counters above, and it is the one the texture buffer exists for. The buffer
' (±2 rows, ±1 column) loads a cell's image BEFORE the cell is on screen so the user never
' watches a placeholder turn into a poster. Nothing observed whether it wins that race:
' every counter above measures work DONE, none measures work done IN TIME.
'
' A pop-in is scored across two events, deliberately:
'
'   1. `appeared()` — the cell is on screen. If its image is already there, nothing is
'      pending and the race was won. If not, a pop-in is PENDING.
'   2. `loadSucceeded()` — the image arrived. A pending pop-in resolves HERE, which is why
'      the pending flag exists rather than a counter bump at step 1.
'
' Resolving at step 2 is what keeps a broken image out of the number. An image that never
' arrives is not pop-in — the user sees a glyph, not a fade-in — and `loadsFailed` already
' counts it. Scoring at appearance time instead would make `ExtrasRowList`, where 117 of
' 140 loads fail against a real server, read as a total buffer failure when the buffer had
' nothing to do with it.
'
' `popInsCold` splits the pop-ins by whether a request was even in flight when the cell
' appeared (`isTextureUnloaded`). Cold means the buffer never started the load — the cell
' came from outside the managed range, so the user outran the buffer's DEPTH. Not-cold
' means the buffer started in time and the network was slower, which more depth cannot fix.
' Two different remedies, one counter apart.
'
' `loadMs` / `loadMsCount` / `loadMsMax` time each request from issue to ready. That is the
' `image_load_time` term in the buffer's own adequacy condition —
' `buffer_depth × time_per_scroll_step >= image_load_time` — and it is measured for EVERY
' load, including buffer preloads that never became visible, which are exactly the ones a
' depth decision needs. `loadMsCount` is carried rather than reusing `loadsSucceeded`
' because the two differ: a "ready" with no matching issue (Roku re-reporting a URI that was
' never re-requested) is a success with no interval to record. Dividing by the wrong count
' would be the inference-by-subtraction this ledger already refuses once.
'
' ## Lifecycle — no per-screen call sites
'
' Bound to the texture-manager lifecycle, which all five cell-bearing screens already drive:
' `initTextureManager` attaches the counters, `hideTextureManager` / `destroyTextureManager`
' emit them. Every content root already receives both, though often from its PARENT
' (`Home` hides HomeRows' root; `ItemDetails` hides and destroys ExtrasRowList's). So
' instrumenting a sixth screen costs nothing here — using the texture manager is the
' opt-in.
'
' ⚠️ **Known limit, stated rather than hidden:** a screen that REPLACES its content root
' starts a fresh set of counters, and the old root's counts die unemitted.
' `BaseGridView` does this at five call sites (refresh, filter change, …) and `SearchRow`
' at two. So a grid's line covers "since the last rebuild", not "since the screen opened".
' Fixable by emitting before the replace; deliberately not done in the first cut because
' it needs five call sites and the limit is visible in the numbers rather than silent.
namespace cellLoad

  ' Bind triggers. Which one fired is the whole point of splitting them: `content` is a
  ' cell being recycled onto new data (legitimate), `size` is a re-render caused by a
  ' layout change alone. MEASURED 2026-08-20 on ItemDetails + Home: 289 binds, ALL of them
  ' `content` and none `size`. So `bindsFromSize` is expected to be ZERO and is worth
  ' keeping precisely as a standing invariant — if it ever goes positive, a layout change
  ' has started re-issuing image requests, which is a regression with no other symptom.
  const FROM_CONTENT = "content"
  const FROM_SIZE = "size"

  ' Which path wiped a failure glyph back to the loading state. Both are visible flicker,
  ' but they are not equally bad and the split is what tells them apart: a `bind` wipe
  ' lands on a cell whose content is changing anyway, while a `reload` wipe hits a cell
  ' that is SITTING STILL showing a stable glyph — which is what reads to a user as
  ' blinking for no reason.
  const WIPE_BIND = "bind"
  const WIPE_RELOAD = "reload"

  ' Which mechanism evicted a resident texture. The texture manager has TWO, they answer
  ' to different dials, and the total cannot tell them apart:
  '   `range`  - the cell fell outside the managed vertical range (loadedRowRange +/- 2
  '              rows) and renderTracking decided. Driven by how far the user scrolled
  '              DOWN, and it fires on every screen including grids.
  '   `window` - the horizontal item window inside a VISIBLE row evicted the cell, which
  '              only happens on a RowList row holding more than TEXTURE_BUFFER_THRESHOLD
  '              items. This is the ONLY signal that horizontal windowing ran at all.
  ' The split exists because the total is dominated by `range` — Home reads ~57 unloads on
  ' a cell sweep with `Limit: 16`, where no row is long enough for `window` to be reachable
  ' — so "did raising the per-row limit engage windowing?" is unanswerable from `unloads`.
  ' Both are emitted rather than one plus the total, so neither is derived by subtraction.
  const UNLOAD_RANGE = "range"
  const UNLOAD_WINDOW = "window"

  ' Attach counters to a content root. Called by initTextureManager, so every screen that
  ' uses the texture manager is instrumented without its own call site.
  '
  ' @param contentRoot - the RowList / MarkupGrid content root. Typed `as object` so
  '                      BrighterScript does not schema-check the dynamic fields below
  '                      against ContentNode's declared interface.
  ' @param component - the COMPONENT's name, e.g. "homeRows". Same convention as
  '                    `screenLoad.begin` and for the same reason: the screen an operator
  '                    navigated to is established by the nav, not by the app.
  sub attach(contentRoot as object, component as string)
    #if perfTiming
      if not isValid(contentRoot) then return
      if contentRoot.hasField("cellLoadBinds")
        ' Re-attach on an existing root (SearchRow re-inits on new data) — keep the
        ' counts rather than silently zeroing a session in progress.
        return
      end if
      contentRoot.addFields({
        cellLoadComponent: component,
        cellLoadBinds: 0,
        cellLoadBindsContent: 0,
        cellLoadBindsSize: 0,
        cellLoadBindsRedundant: 0,
        cellLoadLoadsStarted: 0,
        cellLoadLoadsFailed: 0,
        cellLoadLoadsSucceeded: 0,
        cellLoadReloads: 0,
        cellLoadUnloads: 0,
        cellLoadUnloadsRange: 0,
        cellLoadUnloadsWindow: 0,
        cellLoadWipesBind: 0,
        cellLoadWipesReload: 0,
        cellLoadAppearances: 0,
        cellLoadPopIns: 0,
        cellLoadPopInsCold: 0,
        cellLoadPopInsReload: 0,
        cellLoadPopInsFirst: 0,
        cellLoadLoadMs: 0,
        cellLoadLoadMsCount: 0,
        cellLoadLoadMsMax: 0,
        cellLoadInstrumentUs: 0,
        cellLoadEmitted: false
      })
    #end if
  end sub

  ' One cell bind. `isRedundant` is the cell's own answer to "did I just bind this exact
  ' item at this exact size again?" — kept on the CELL because its previous bind is
  ' free to remember there and would cost a node round trip to store here.
  sub bind(contentRoot as object, trigger as string, isRedundant as boolean)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
      probe = cellLoad.probe()
      probe.mark()

      ' A bind is a new (cell, item) pairing, so the pop-in question re-opens for the new
      ' item and closes for the old one: this cell has not appeared carrying THIS item yet,
      ' and a load still outstanding for the previous item can no longer resolve into a
      ' pop-in against it.
      state = cellLoad.cellState()
      state.appearCounted = false
      state.popInPending = false
      state.loadPending = false

      contentRoot.cellLoadBinds = contentRoot.cellLoadBinds + 1
      if trigger = cellLoad.FROM_SIZE
        contentRoot.cellLoadBindsSize = contentRoot.cellLoadBindsSize + 1
      else
        contentRoot.cellLoadBindsContent = contentRoot.cellLoadBindsContent + 1
      end if
      if isRedundant
        contentRoot.cellLoadBindsRedundant = contentRoot.cellLoadBindsRedundant + 1
      end if
      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

  ' An image request was issued (the poster URI was assigned a real URL). Also starts this
  ' cell's load clock — see the pop-in section of the header for why the interval matters
  ' more than the count.
  sub loadStarted(contentRoot as object, isReload = false as boolean)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadLoadsStarted") then return
      probe = cellLoad.probe()
      probe.mark()
      contentRoot.cellLoadLoadsStarted = contentRoot.cellLoadLoadsStarted + 1
      state = cellLoad.cellState()
      state.loadPending = true
      ' Which PATH issued this request, kept so a pop-in can say whether the buffer had a
      ' job to do at all — see the three-way split in `loadSucceeded`.
      state.loadFromReload = isReload
      cellLoad.loadTimer().mark()
      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

  ' The cell LEFT the screen. Ends the appearance episode, so a later return counts as a
  ' NEW appearance rather than being swallowed by the once-per-bind gate.
  '
  ' 🚨 This is what makes the re-entry race measurable at all, and its absence silently
  ' hollowed out the metric. A RowList / MarkupGrid does NOT rebind a cell that scrolls off
  ' and back onto the SAME item — `onItemContentChanged` never fires — so with the gate
  ' re-arming only on bind, every scroll-back was skipped. Measured on `cellSweepGrid`:
  ' `binds` 28 against 28 items, i.e. nothing was rebound all sweep, while 7 reloads
  ' happened; all 7 re-entry races went unevaluated and the ledger still published a
  ' confident-looking `popInsReload` 1. **The one thing the buffer exists to win was the
  ' one thing not being counted.**
  '
  ' A pending pop-in is dropped here rather than carried: a cell that appeared without its
  ' image and then went away was never watched fading in, so a late arrival is not a pop-in.
  '
  ' ⚠️ CALLER'S OBLIGATION: only call this while the texture manager is "active". A
  ' `renderTracking` of "none" during `init` is a layout recalculation and during `hidden`
  ' is `visible=false` propagating — neither is a departure, and closing the episode on one
  ' makes the screen's return score a fresh appearance nobody saw. Both cell components
  ' gate the call for this; `evaluateTextureState` refuses the same two states already.
  ' The INVARIANT is what to remember — on a resume with no scrolling, every appearance has
  ' to be paid for by a bind. Measured before the gate against the demo server: 18
  ' appearances against 10 binds, all non-pop-ins, inflating the denominator that
  ' `popIns / appearances` divides by. The counts are that fixture's; the bound is the app's,
  ' and it is gated in tests/rta/specs/cell-load.spec.js.
  '
  ' ⚠️ Deliberately NOT probed into `instrumentUs`, unlike every other entry point here.
  ' Measured on `cellSweepGrid` against a real server (.177, n=5, same fixture and identical
  ' workload in both arms): adding the probe took `instrumentUs` 7371 -> 8411 µs, +1040 µs /
  ' +14.1%. Absolute µs are that fixture's; the RATIO is the point — for a body that is two
  ' AA assignments and no node write. That delta IS the probe — an `roTimespan` pair plus a
  ' render-thread read-modify-write on `cellLoadInstrumentUs` — so probing here would make
  ' the instrument meaningfully more expensive to report a figure that is almost entirely
  ' the measurement apparatus. `instrumentUs` is therefore a LOWER BOUND by this much.
  sub departed(contentRoot as object)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadAppearances") then return
      state = cellLoad.cellState()
      state.appearCounted = false
      state.popInPending = false
    #end if
  end sub

  ' The cell is on screen. One half of the pop-in race; `loadSucceeded` is the other.
  '
  ' `hasTexture` is the caller's read of its own poster's `loadStatus`, and it is
  ' deliberately NOT trusted alone. A request issued and not yet resolved means the texture
  ' currently on screen belongs to the cell's PREVIOUS item while `loadStatus` still reads
  ' "ready" from it — established behaviour, not supposition: `JRRowItem.renderItem`'s
  ' same-URI shortcut exists precisely because Roku leaves `loadStatus` alone until a new
  ' request resolves. Trusting it alone would under-count exactly the recycled-cell case
  ' pop-in is about.
  '
  ' Counted at most ONCE per EPISODE, an episode being bind -> visible -> gone. Ungated
  ' otherwise, deliberately: an appearance is idempotent within an episode, so calling this
  ' from both `renderItem` and `onRenderTrackingChanged` cannot double-count, and first
  ' paint legitimately happens before `activateTextureManager` runs. The episode is closed
  ' only by `bind` and `departed`, and it is `departed` — not this — that carries the
  ' state gate keeping a spurious compositor flip from re-arming it.
  sub appeared(contentRoot as object, hasTexture as boolean, isCold as boolean)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadAppearances") then return
      state = cellLoad.cellState()
      if state.appearCounted then return
      probe = cellLoad.probe()
      probe.mark()
      state.appearCounted = true
      contentRoot.cellLoadAppearances = contentRoot.cellLoadAppearances + 1
      state.popInPending = not (hasTexture and not state.loadPending)
      state.popInCold = isCold
      state.popInFromReload = state.loadPending and state.loadFromReload
      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

  ' An image request came back failed. `loadsFailed` far exceeding the number of distinct
  ' broken images is the signature of a retry with no memory of the last failure.
  '
  ' ⚠️ NAMED `loadErrored` RATHER THAN `loadFailed`, and it must stay that way. Rooibos's
  ' transpiler mistakes a namespaced call whose method name it reads as an assertion for a
  ' call ON THE TEST SUITE, and rewrites its own bookkeeping against the NAMESPACE instead
  ' of `m` — `cellLoad.currentAssertLineNumber = 148`, `cellLoad_done()`. That is a
  ' compile-time rewrite producing code that crashes with "Use of uninitialized variable"
  ' at runtime, and only in the spec, so the app is fine while the suite reports a crash it
  ' cannot explain. The emitted LOG FIELD is still `loadsFailed` — the wire format is what
  ' scripts/measurements.js reads and it is unaffected by this rename.
  sub loadErrored(contentRoot as object)
    #if perfTiming
      ' No contentRoot guard, deliberately: the flags below live on the CELL's `m`, not on
      ' the content root, so their lifetime is independent of it — and `bump` already
      ' guards the root. Guarding here would skip the clear when the root happens to be
      ' invalid and leave a stale pop-in to resolve against the next valid one.
      state = cellLoad.cellState()
      state.loadPending = false
      ' A broken image is not pop-in and must not resolve one — see the header. It is
      ' already counted, on the very next line.
      state.popInPending = false
      bump(contentRoot, "cellLoadLoadsFailed")
    #end if
  end sub

  ' An image request came back ready. Its job is not the count itself but the RESIDUAL:
  ' `loadsStarted - (loadsFailed + loadsSucceeded)` is the number of requests still in
  ' flight when the session was emitted, and at a quiescent boundary it should be zero.
  ' Without it, an outstanding request is indistinguishable from a success — every
  ' "successful loads" figure was an inference subtracting one counter from another, with
  ' nothing able to check it. See `waitCellsQuiet` in `tests/rta/lib/steps.js`, which
  ' watches this counter for the same reason it watches `loadsFailed`.
  sub loadSucceeded(contentRoot as object)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadLoadsSucceeded") then return
      probe = cellLoad.probe()
      probe.mark()
      contentRoot.cellLoadLoadsSucceeded = contentRoot.cellLoadLoadsSucceeded + 1

      state = cellLoad.cellState()
      if state.loadPending
        state.loadPending = false
        ms = cellLoad.loadTimer().totalMilliseconds()
        contentRoot.cellLoadLoadMs = contentRoot.cellLoadLoadMs + ms
        contentRoot.cellLoadLoadMsCount = contentRoot.cellLoadLoadMsCount + 1
        if ms > contentRoot.cellLoadLoadMsMax then contentRoot.cellLoadLoadMsMax = ms
      end if

      ' THREE mutually exclusive buckets, all emitted, none left to be derived. Subtracting
      ' two counters to get the third is the inference this ledger already refuses once, and
      ' the buckets carry different remedies:
      '   cold   — no request in flight. The cell arrived from outside the managed range, so
      '            the user outran the buffer's DEPTH. More depth would help.
      '   reload — a re-entry request was in flight. The buffer had its job and the network
      '            was slower than the scroll. Depth cannot fix this one.
      '   first  — the cell's FIRST render was still loading. The data had only just arrived,
      '            so no buffer could have won: this is first paint, not a buffer failure,
      '            and it is the bucket that dominates a sweep which OPENS its screen.
      if state.popInPending
        state.popInPending = false
        contentRoot.cellLoadPopIns = contentRoot.cellLoadPopIns + 1
        if state.popInCold
          contentRoot.cellLoadPopInsCold = contentRoot.cellLoadPopInsCold + 1
        else if state.popInFromReload
          contentRoot.cellLoadPopInsReload = contentRoot.cellLoadPopInsReload + 1
        else
          contentRoot.cellLoadPopInsFirst = contentRoot.cellLoadPopInsFirst + 1
        end if
      end if

      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

  sub reload(contentRoot as object)
    #if perfTiming
      bump(contentRoot, "cellLoadReloads")
    #end if
  end sub

  ' A resident texture was released. `reason` says which mechanism did it — see UNLOAD_*
  ' for why the split is the whole point of this counter.
  sub unload(contentRoot as object, reason as string)
    #if perfTiming
      ' Inline rather than two `bump` calls, matching `bind`: a total plus its split is one
      ' event, and bumping twice would clock the instrument's own probe twice for it.
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadUnloads") then return
      probe = cellLoad.probe()
      probe.mark()
      contentRoot.cellLoadUnloads = contentRoot.cellLoadUnloads + 1
      if reason = cellLoad.UNLOAD_WINDOW
        contentRoot.cellLoadUnloadsWindow = contentRoot.cellLoadUnloadsWindow + 1
      else
        contentRoot.cellLoadUnloadsRange = contentRoot.cellLoadUnloadsRange + 1
      end if
      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

  ' A failure glyph was reset to the loading state. See WIPE_* for why the source matters.
  sub glyphWipe(contentRoot as object, source as string)
    #if perfTiming
      if source = cellLoad.WIPE_RELOAD
        bump(contentRoot, "cellLoadWipesReload")
      else
        bump(contentRoot, "cellLoadWipesBind")
      end if
    #end if
  end sub

  ' Emit the session's counters and reset them. Called by hideTextureManager (the user
  ' navigated away — a complete viewing session) and destroyTextureManager (teardown).
  ' Emitting on BOTH is why `cellLoadEmitted` exists: a screen that is hidden and then
  ' destroyed must publish one line, not two, and a second line of zeroes would read as a
  ' real session in which nothing happened.
  sub emit(contentRoot as object)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
      if contentRoot.cellLoadEmitted then return
      if contentRoot.cellLoadBinds = 0 then return
      contentRoot.cellLoadEmitted = true

      if not isValid(m.cellLoadLog) then m.cellLoadLog = new log.Logger("CellLoad")

      ' Two lines for the same reason `screenLoad` splits its own: roku-log's Logger.info
      ' takes the message plus at most nine arguments once the BSC plugin has spent one on
      ' the injected pkg path, and exceeding it is NOT a compile error — it faults at
      ' runtime with &hf1 and drops the app into the debugger. Everything rides in the
      ' message so the format is one thing to read, and BOTH lines repeat the component
      ' because `assembleSamples` splits a family on that identity.
      component = contentRoot.cellLoadComponent
      m.cellLoadLog.info("cell-load binds - component " + component + " binds " + contentRoot.cellLoadBinds.toStr() + " fromContent " + contentRoot.cellLoadBindsContent.toStr() + " fromSize " + contentRoot.cellLoadBindsSize.toStr() + " redundant " + contentRoot.cellLoadBindsRedundant.toStr() + " items " + countItems(contentRoot).toStr())
      m.cellLoadLog.info("cell-load popin - component " + component + " appearances " + contentRoot.cellLoadAppearances.toStr() + " popIns " + contentRoot.cellLoadPopIns.toStr() + " popInsCold " + contentRoot.cellLoadPopInsCold.toStr() + " popInsReload " + contentRoot.cellLoadPopInsReload.toStr() + " popInsFirst " + contentRoot.cellLoadPopInsFirst.toStr() + " loadMs " + contentRoot.cellLoadLoadMs.toStr() + " loadMsCount " + contentRoot.cellLoadLoadMsCount.toStr() + " loadMsMax " + contentRoot.cellLoadLoadMsMax.toStr())
      m.cellLoadLog.info("cell-load work - component " + component + " loadsStarted " + contentRoot.cellLoadLoadsStarted.toStr() + " loadsFailed " + contentRoot.cellLoadLoadsFailed.toStr() + " loadsSucceeded " + contentRoot.cellLoadLoadsSucceeded.toStr() + " reloads " + contentRoot.cellLoadReloads.toStr() + " unloads " + contentRoot.cellLoadUnloads.toStr() + " unloadsRange " + contentRoot.cellLoadUnloadsRange.toStr() + " unloadsWindow " + contentRoot.cellLoadUnloadsWindow.toStr() + " wipesBind " + contentRoot.cellLoadWipesBind.toStr() + " wipesReload " + contentRoot.cellLoadWipesReload.toStr() + " instrumentUs " + contentRoot.cellLoadInstrumentUs.toStr())

      reset(contentRoot)
    #end if
  end sub

  ' Zero the counters for the next session. `cellLoadEmitted` is cleared LAST so a hidden
  ' screen that is shown again starts a fresh, emittable session.
  sub reset(contentRoot as object)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
      contentRoot.setFields({
        cellLoadBinds: 0,
        cellLoadBindsContent: 0,
        cellLoadBindsSize: 0,
        cellLoadBindsRedundant: 0,
        cellLoadLoadsStarted: 0,
        cellLoadLoadsFailed: 0,
        cellLoadLoadsSucceeded: 0,
        cellLoadReloads: 0,
        cellLoadUnloads: 0,
        cellLoadUnloadsRange: 0,
        cellLoadUnloadsWindow: 0,
        cellLoadWipesBind: 0,
        cellLoadWipesReload: 0,
        cellLoadAppearances: 0,
        cellLoadPopIns: 0,
        cellLoadPopInsCold: 0,
        cellLoadPopInsReload: 0,
        cellLoadPopInsFirst: 0,
        cellLoadLoadMs: 0,
        cellLoadLoadMsCount: 0,
        cellLoadLoadMsMax: 0,
        cellLoadInstrumentUs: 0,
        cellLoadEmitted: false
      })
    #end if
  end sub

  ' The instrument's own stopwatch, created ONCE per cell and reused.
  '
  ' `m` here is the CALLING component's — these are namespaced free functions, the same
  ' mechanism `screenReadiness.bs` relies on — so this caches on the cell, which is
  ' exactly the right lifetime: one per pooled cell rather than one per event.
  '
  ' Measured before this existed, on a Home load: 15458 us across ~706 counter calls, or
  ' ~22 us each, against ~3.8 us for the field write the call is actually making. So the
  ' STOPWATCH was five times the cost of the thing it was timing. `screenReadiness.bs`
  ' warns about precisely this ("a fresh roTimespan per ledger call would make the
  ' measurement of the overhead a significant part of the overhead") and this repeated it
  ' anyway; `instrumentUs` is what caught it, which is the argument for carrying that field
  ' at all.
  function probe() as object
    if not isValid(m.cellLoadProbe) then m.cellLoadProbe = CreateObject("roTimespan")
    return m.cellLoadProbe
  end function

  ' The cell's LOAD clock, distinct from `probe()` because it spans a network round trip
  ' rather than a single call — one `roTimespan` cannot carry two marks.
  function loadTimer() as object
    if not isValid(m.cellLoadLoadTimer) then m.cellLoadLoadTimer = CreateObject("roTimespan")
    return m.cellLoadLoadTimer
  end function

  ' Per-CELL instrument state, cached on the caller's `m` exactly as `probe()` is.
  '
  ' One AA rather than four bare `m` fields so every flag is a real boolean from its first
  ' read. An unset `m.someFlag` is Invalid, and `if Invalid` is a &h18 type mismatch rather
  ' than a falsy check — the fault class behind the `EnableFallbackFont` crash. Initialising
  ' the whole set together makes that unreachable by construction instead of by every call
  ' site remembering a guard.
  function cellState() as object
    if not isValid(m.cellLoadState)
      m.cellLoadState = {
        loadPending: false,
        loadFromReload: false,
        popInPending: false,
        popInCold: false,
        popInFromReload: false,
        appearCounted: false
      }
    end if
    return m.cellLoadState
  end function

  ' Total ITEMS under a content root, which is the denominator of the rebind rate and is
  ' NOT `getChildCount()`. The two content shapes disagree about what a child is: a
  ' MarkupGrid root is flat, so its children ARE the items, while a RowList root is two
  ' level and its children are ROWS. Using the child count for both would have divided
  ' Home's binds by its ~10 rows instead of its ~68 items and published a "rebind rate"
  ' an order of magnitude wrong — in a field that looks equally plausible either way.
  '
  ' Detected by shape rather than by a flag the caller has to pass correctly: a root whose
  ' first child has children of its own is hierarchical. Walked once per emit, never per
  ' bind.
  function countItems(contentRoot as object) as integer
    rows = contentRoot.getChildCount()
    if rows = 0 then return 0
    first = contentRoot.getChild(0)
    if not isValid(first) or first.getChildCount() = 0 then return rows
    total = 0
    for i = 0 to rows - 1
      child = contentRoot.getChild(i)
      if isValid(child) then total = total + child.getChildCount()
    end for
    return total
  end function

  ' Increment one counter. Extracted so the single-counter call sites above do not each
  ' repeat the guard, and so the instrument's own cost is accumulated in one place rather
  ' than in each of them, where it could drift. A counter that bumps a TOTAL and a SPLIT
  ' together (`bind`, `unload`) does it inline instead — one event should clock the probe
  ' once, not twice. Deliberately no call-site COUNT here: the number this comment used to
  ' carry said nine against an actual four, which is what a hand-maintained enumeration
  ' does. `grep -n 'bump(contentRoot' source/utils/cellLoad.bs` answers it.
  sub bump(contentRoot as object, field as string)
    #if perfTiming
      if not isValid(contentRoot) or not contentRoot.hasField(field) then return
      probe = cellLoad.probe()
      probe.mark()
      contentRoot.setField(field, contentRoot.getField(field) + 1)
      contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
    #end if
  end sub

end namespace