components_testing_TaskLedgerBench.bs

import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/tasks.bs"

' The render-thread half of the task-ledger cost model. See TaskLedgerBench.xml for
' why this is a SceneGraph component rather than another Rooibos suite.
'
' Every function here was a deliberate mirror of the same-named one in the Rooibos
' suite `tests/source/unit/utils/taskLedgerCost.spec.bs` — same iteration count, same
' fixture, same apparatus floor subtracted — so the two tables differed in exactly
' ONE variable: which thread the code ran on. Any other difference would have made the
' comparison a different experiment.
'
' ⚠️ That mirror is GONE (deleted 2026-08-23; its numbers are recorded in
' docs/architecture/threading.md, and only its platform-premise gate survives, in
' tests/source/unit/utils/tasks.spec.bs). So the off-thread column can no longer be
' re-measured by re-running it. If you need that comparison again, restore the mirror
' from git history rather than writing a fresh one — a re-derived apparatus would not
' hold the other variables fixed, which is the entire basis of the comparison.
#if ENABLE_RTA

  sub init()
    m.top.id = "taskLedgerBench"
  end sub

  ' Runs one cell of the bench grid and returns its mean microseconds.
  '
  ' @param args - { cell: string, depth: integer, iterations: integer }
  ' @return an AA carrying the reading(s) for that cell, plus the floor it was
  '         measured against so the caller never has to pair them up itself.
  function runCell(args as object) as object
    cell = "floor"
    depth = 0
    iterations = 200
    if isValid(args)
      if isValid(args.cell) then cell = args.cell
      if isValid(args.depth) then depth = args.depth
      if isValid(args.iterations) then iterations = args.iterations
    end if

    floor = benchFloor(iterations)

    if cell = "floor" then return { cell: cell, depth: depth, iterations: iterations, floorUs: floor, us: 0 }
    if cell = "reads" then return benchReads(iterations, floor)

    us = 0
    if cell = "recordGlobal"
      us = benchRecord(m.global, depth, iterations)
      ' Leave m.global as we found it — this writes a real field on the shared
      ' global node and the app keeps running after the bench returns.
      m.global["taskLedger"] = []
    else if cell = "recordLocal"
      us = benchRecord(CreateObject("roSGNode", "Node"), depth, iterations)
    else if cell = "count"
      us = benchCount(depth, iterations)
    else if cell = "prune"
      us = benchPrune(depth, iterations)
    else if cell = "aaAppend"
      us = benchAaAppend(depth, iterations)
    else if cell = "counterOnly"
      us = benchCounterOnly(m.global, iterations)
    else if cell = "countNoLCase"
      us = benchCountNoLCase(depth, iterations)
    else if cell = "aaRecord"
      us = benchAaRecord(depth, iterations, false)
    else if cell = "aaRecordNoLCase"
      us = benchAaRecord(depth, iterations, true)
    else if cell = "aaAppendVerified"
      return benchAaAppendVerified(depth, iterations, floor)
    else if cell = "nodeArrayPush"
      return benchNodeArrayPush(m.global, depth, iterations, floor)
    else
      return { cell: cell, error: "unknown cell" }
    end if

    return { cell: cell, depth: depth, iterations: iterations, floorUs: floor, us: us - floor }
  end function

  ' The live Task-thread count right now, derived from the ledger `launchTask()`
  ' maintains in EVERY build — there is no conditional hook to be missing.
  '
  ' Returns `tracked` alongside `live` deliberately: a `tracked` of 0 means the ledger
  ' recorded nothing, while a `live` of 0 with a non-zero `tracked` means the app
  ' genuinely runs no Task threads right now. Those are opposite findings and a bare
  ' `live` conflates them — the peak gate asserts on `tracked` for exactly that reason.
  function liveCount() as object
    ' Reads the SHARED ledger on m.global — the same one `launchTask()` maintains for
    ' every component. An earlier cut read GetGlobalAA and returned 0 here while the
    ' app was plainly running Task threads, because GetGlobalAA is per COMPONENT.
    ledger = []
    if isValid(m.global) and isValid(m.global.taskLedger) then ledger = m.global.taskLedger
    return { live: countLiveTaskThreads(ledger), tracked: ledger.count() }
  end function

  ' What the ceiling has cost so far, in real launches on real screens.
  function ledgerCost() as object
    us = 0
    launches = 0
    instrumented = false
    if isValid(m.global) and m.global.hasField("taskLedgerUs")
      instrumented = true
      us = m.global["taskLedgerUs"]
      launches = m.global["taskLedgerLaunches"]
    end if
    return { us: us, launches: launches, instrumented: instrumented }
  end function

  function resetLedgerCost() as boolean
    if not isValid(m.global) then return false
    if not m.global.hasField("taskLedgerUs") then return false
    m.global["taskLedgerUs"] = 0
    m.global["taskLedgerLaunches"] = 0
    return true
  end function

  ' THE DISCRIMINATOR the earlier probe could not be.
  '
  ' gaa-thread-scope varied the THREAD and the COMPONENT at the same time, so an empty
  ' result was equally explained by "GetGlobalAA is per thread" and by "GetGlobalAA is
  ' per component" — and the ledger reading 0 across two render-thread components says
  ' the second is what is happening. This holds the thread fixed (render) and the
  ' component fixed (this one): launch through `launchTask` here, read the ledger here.
  ' A non-zero count means the ledger works WITHIN a component and the earlier
  ' conclusion was wrong.
  function selfLaunchAndCount() as object
    node = CreateObject("roSGNode", "ServerReachableTask")
    node.baseUrl = "http://192.0.2.1:8096" ' RFC 5737 TEST-NET-1 — parks on connect
    admitted = launchTask(node)
    ledger = []
    if isValid(m.global) and isValid(m.global.taskLedger) then ledger = m.global.taskLedger
    countHere = countLiveTaskThreads(ledger)
    node.control = "STOP"
    return { admitted: admitted, countInsideThisComponent: countHere }
  end function

  ' Writes this (render) thread's GetGlobalAA sentinel and starts the probe Task.
  function gaaProbe() as boolean
    gaa = GetGlobalAA()
    gaa.gaaSentinelFromRender = "render-wrote-this"
    gaa.gaaSentinelFromTask = invalid
    if not isValid(m.gaaTask) then m.gaaTask = CreateObject("roSGNode", "GaaProbeTask")
    m.gaaTask.seen = ""
    return launchTask(m.gaaTask)
  end function

  ' Reads BOTH directions once the probe Task has finished.
  function gaaProbeResult() as object
    gaa = GetGlobalAA()
    seenByTask = ""
    taskState = "none"
    if isValid(m.gaaTask)
      seenByTask = m.gaaTask.seen
      taskState = m.gaaTask.state
    end if
    renderSeesTaskSentinel = ""
    if isValid(gaa.gaaSentinelFromTask) then renderSeesTaskSentinel = gaa.gaaSentinelFromTask
    return {
      taskState: taskState,
      taskSawRenderSentinel: seenByTask,
      renderSeesTaskSentinel: renderSeesTaskSentinel
    }
  end function

  ' `ServerReachableTask`'s init() only sets functionName — no thread, no server.
  ' A never-launched Task node reports `state = "init"`, which `taskThreadIsLive`
  ' counts as live, so depth is built without starting a single thread.
  function freshTask() as object
    return CreateObject("roSGNode", "ServerReachableTask")
  end function

  function seedLedger(store as object, depth as integer) as void
    seeded = []
    for i = 1 to depth
      seeded.push(freshTask())
    end for
    if not store.hasField("taskLedger") then store.addField("taskLedger", "array", false)
    store["taskLedger"] = seeded
  end function

  function benchRecord(store as object, depth as integer, iterations as integer) as float
    seedLedger(store, depth)
    node = freshTask()
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      recordTaskLaunch(store, node)
    end for
    return probe.totalMicroseconds() / iterations
  end function

  function benchCount(depth as integer, iterations as integer) as float
    ledger = []
    for i = 1 to depth
      ledger.push(freshTask())
    end for
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      countLiveTaskThreads(ledger)
    end for
    return probe.totalMicroseconds() / iterations
  end function

  function benchPrune(depth as integer, iterations as integer) as float
    ledger = []
    for i = 1 to depth
      ledger.push(freshTask())
    end for
    node = freshTask()
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      pruneTaskLedger(ledger, node)
    end for
    return probe.totalMicroseconds() / iterations
  end function

  ' CANDIDATE (b) — the ledger held in GetGlobalAA(), BrightScript thread-local
  ' memory. No node field is touched at all, so this pays neither a rendezvous nor
  ' the array-marshalling cost of writing an roSGNode array field. This is the
  ' per-launch cost of an APPEND-ONLY ledger; the walk moves to the audit.
  function benchAaAppend(depth as integer, iterations as integer) as float
    gaa = GetGlobalAA()
    gaa.benchLedger = []
    for i = 1 to depth
      gaa.benchLedger.push(freshTask())
    end for
    node = freshTask()
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      gaa.benchLedger.push(node)
    end for
    us = probe.totalMicroseconds() / iterations
    gaa.benchLedger = invalid
    return us
  end function

  ' The same append, but RETURNING THE RESULTING LENGTH so the caller can prove the
  ' pushes actually happened. The first run of this cell read -0.84 us — i.e. below
  ' the apparatus floor — and "too cheap to measure" and "optimised away entirely"
  ' are indistinguishable from a negative number alone. `finalCount` decides it: it
  ' must equal depth + iterations exactly.
  function benchAaAppendVerified(depth as integer, iterations as integer, floor as float) as object
    gaa = GetGlobalAA()
    gaa.benchLedger = []
    for i = 1 to depth
      gaa.benchLedger.push(freshTask())
    end for
    node = freshTask()
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      gaa.benchLedger.push(node)
    end for
    us = probe.totalMicroseconds() / iterations
    finalCount = gaa.benchLedger.count()
    gaa.benchLedger = invalid
    return {
      cell: "aaAppendVerified",
      depth: depth,
      iterations: iterations,
      floorUs: floor,
      us: us - floor,
      finalCount: finalCount,
      expectedCount: depth + iterations
    }
  end function

  ' CANDIDATE (e) — mutate the node's array field IN PLACE instead of rebuilding and
  ' writing it back. `recordTaskLaunch` today does read + rebuild + WRITE, and the
  ' write is what marshals. If reading an array field yields a reference rather than
  ' a copy, `push` alone is enough and the ledger can stay on `m.global` — which
  ' sidesteps GetGlobalAA's thread-scope question entirely.
  '
  ' `verifiedGrowth` is the whole point: if the field read returns a COPY, the pushes
  ' land on a throwaway and the field never grows. Cheap AND wrong would otherwise
  ' look exactly like cheap AND right.
  function benchNodeArrayPush(store as object, depth as integer, iterations as integer, floor as float) as object
    if not store.hasField("taskLedger") then store.addField("taskLedger", "array", false)
    seeded = []
    for i = 1 to depth
      seeded.push(freshTask())
    end for
    store["taskLedger"] = seeded
    node = freshTask()

    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      store["taskLedger"].push(node)
    end for
    us = probe.totalMicroseconds() / iterations

    finalCount = store["taskLedger"].count()
    store["taskLedger"] = []
    return {
      cell: "nodeArrayPush",
      depth: depth,
      iterations: iterations,
      floorUs: floor,
      us: us - floor,
      finalCount: finalCount,
      expectedCount: depth + iterations
    }
  end function

  ' THE COMBINED CANDIDATE — the ledger held in GetGlobalAA (free storage, no node
  ' field read or write) but still pruned on EVERY launch.
  '
  ' Pruning every launch is not gold-plating: `pruneTaskLedger` is what de-duplicates
  ' a relaunched node against the ledger, and a persistent Task node gets relaunched
  ' many times (`ExtrasRowList` alone relaunches its nodes 38 times). Skip it and the
  ' same live thread is counted 38 times, which would trip the watermark on an app
  ' running 11 threads. So this measures the honest floor for a CORRECT ledger that
  ' pays no node-field cost.
  function benchAaRecord(depth as integer, iterations as integer, skipLCase as boolean) as float
    gaa = GetGlobalAA()
    ledger = []
    for i = 1 to depth
      ledger.push(freshTask())
    end for
    node = freshTask()
    gaa.benchLedger = ledger

    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      if skipLCase
        gaa.benchLedger = pruneLedgerNoLCase(gaa.benchLedger, node)
      else
        gaa.benchLedger = pruneTaskLedger(gaa.benchLedger, node)
      end if
    end for
    us = probe.totalMicroseconds() / iterations
    gaa.benchLedger = invalid
    return us
  end function

  ' `pruneTaskLedger` without the LCase() fold, to price that defence separately.
  function pruneLedgerNoLCase(ledger as object, node as object) as object
    kept = []
    if isValid(ledger)
      for each tracked in ledger
        st = tracked.state
        if st <> "run" and st <> "init" then continue for
        if isValid(node) and tracked.isSameNode(node) then continue for
        kept.push(tracked)
      end for
    end if
    if isValid(node) then kept.push(node)
    return kept
  end function

  ' CANDIDATE (a) — an O(1) monotonic counter on m.global. Needs no decrement
  ' because it counts LAUNCHES SINCE THE LAST AUDIT, not live threads, which is
  ' what killed the original counter design (it would have needed an
  ' observeField("state") per launch to decrement).
  function benchCounterOnly(store as object, iterations as integer) as float
    if not store.hasField("benchCounter") then store.addField("benchCounter", "integer", false)
    store["benchCounter"] = 0
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      store["benchCounter"] = store["benchCounter"] + 1
    end for
    return probe.totalMicroseconds() / iterations
  end function

  ' CANDIDATE (c) — the liveness walk without LCase(), which allocates a string per
  ' entry per call. Roku documents the state values as lowercase; this measures what
  ' dropping the defensive fold would actually buy before anyone trades correctness
  ' for it.
  function benchCountNoLCase(depth as integer, iterations as integer) as float
    ledger = []
    for i = 1 to depth
      ledger.push(freshTask())
    end for
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      live = 0
      for each tracked in ledger
        st = tracked.state
        if st = "run" or st = "init" then live++
      end for
    end for
    return probe.totalMicroseconds() / iterations
  end function

  ' The apparatus floor: the same loop doing the cheapest possible body. Whatever
  ' this reads is measurement overhead present in every cell above, and it is
  ' SUBTRACTED rather than argued to be negligible.
  function benchFloor(iterations as integer) as float
    node = freshTask()
    total = 0
    probe = CreateObject("roTimespan")
    probe.mark()
    for i = 1 to iterations
      if isValid(node) then total++
    end for
    return probe.totalMicroseconds() / iterations
  end function

  ' THE DISCRIMINATOR, and the single number this whole component exists to take.
  '
  ' Off the render thread the spec measures 1.56 us for a node the reading thread
  ' owns against 91-118 us for a render-owned Task node and 93 us for `m.global`
  ' (Stick 4K). If those three collapse onto each other HERE, every node the ledger
  ' touches is render-owned and the production hot path pays no rendezvous at all.
  ' If `globalUs` stays high, `m.global` is NOT render-owned and a production ledger
  ' pays a crossing per launch — which is the design fork this measurement decides.
  function benchReads(iterations as integer, floor as float) as object
    localNode = CreateObject("roSGNode", "Node")
    localNode.id = "bench"
    taskNode = freshTask()

    ' One sink PER read, summed at the end. A single shared sink measured only the
    ' LAST read, and `m.global.id` is legitimately the empty string — so the
    ' "did these reads execute?" guard read 0 and failed a run whose numbers were
    ' fine. That guard was testing whether the global node happens to have an id,
    ' which is not what it claims to test. `taskNode.state` is never empty, so the
    ' sum is a real execution signal.
    sinkL = ""
    sinkT = ""
    sinkG = ""

    probeL = CreateObject("roTimespan")
    probeL.mark()
    for i = 1 to iterations
      sinkL = localNode.id
    end for
    localUs = probeL.totalMicroseconds() / iterations - floor

    probeT = CreateObject("roTimespan")
    probeT.mark()
    for i = 1 to iterations
      sinkT = taskNode.state
    end for
    taskUs = probeT.totalMicroseconds() / iterations - floor

    probeG = CreateObject("roTimespan")
    probeG.mark()
    for i = 1 to iterations
      sinkG = m.global.id
    end for
    globalUs = probeG.totalMicroseconds() / iterations - floor

    return {
      cell: "reads",
      iterations: iterations,
      floorUs: floor,
      localNodeUs: localUs,
      taskNodeUs: taskUs,
      globalUs: globalUs,
      sinkLen: Len(sinkL) + Len(sinkT) + Len(sinkG)
    }
  end function

#end if