[data-veracity] Little's Law dwell time is inflated by the guard floor across closed hours #32

Closed
opened 2026-09-21 13:43:32 +00:00 by gabogg · 2 comments
Owner

Filed from a data-veracity audit of the ingestion and aggregation pipeline on master, carried out against the KPI set that the Executive Statistics Deck (PR #20 / RFC-ARCH-2026-004) intends to publish. Each issue names the deck KPIs it corrupts.

Problem

The deck publishes Mean Dwell as a headline KPI, computed per RFC-ARCH-2026-004 §3.3:

W_mean = Ō · |T| / I

Ō comes from get_cycle_average_occupancy_async (app/db/occupancy_repository.py:1065), which Riemann-integrates occupancy across the full 24-hour cycle and floors every sample at the patrol guard count:

current_occ = guards                     # initial value at cycle_start
...
current_occ = calculate_proportional_occupancy(cum_in, cum_out, k_hat, guards)
# -> max(N_patrol, round(I - k*E + N_patrol))

Two biases, both upward, both in the same direction:

  1. The guard floor is integrated over the closed hours. From roughly 22:00 to 08:00 the true customer occupancy is zero, but the integral accumulates guards × ~10 h. With |T| = 86,400 s and the default 8 guards that is a constant additive bias on Ō, and therefore on W_mean.
  2. The max(...) clamp truncates the negative tail. If k is even slightly over-estimated, the late-night accumulator would go negative; clamping it at the guard count discards that negative area. The result is that Ō can only ever be biased up by calibration error, never down — so a drifting k and a rising dwell time look identical.

Why it matters for this deck specifically

Little's Law (L = λW) assumes a stationary or cyclically-closed system. A 24-hour window in which eight to ten hours are a clamped constant floor is neither. The RFC's own §3.3 states the stationarity precondition and then applies the formula over the full cycle anyway.

"Mean dwell 94 minutes" will be read by an executive as "the average visitor stays an hour and a half". It is not that number — it is an integral over a window that includes the mall being shut.

KPIs corrupted

Mean Dwell (Day), Avg Dwell (Week ledger and KPI strip), the per-day average_dwell_minutes column in DayOfWeekProfile, and any cycle-over-cycle dwell-stability trend built on them.

Suggested fix

  1. Compute Ō and W_mean over the retail open window (open_time → close_time, already resolved per cycle by resolve_schedule_context / get_active_schedule_info_async), not over the 04:00→04:00 accounting cycle. |T| becomes the open duration; I becomes ingress within it.
  2. Subtract the patrol baseline before integrating, so Ō measures visitors, not visitors plus security staff.
  3. Report the two separately if the closed-hours figure is wanted: dwell_open_window_minutes and resting_headcount. One number cannot mean both.
  4. Label the KPI with its window in the deck (94 min · 10:00–22:00), so the figure is self-describing on a boardroom wall.

Decision needed

This changes a published number's definition. Worth agreeing the window before Phase 2 implements the aggregation, because changing it afterwards makes historical comparisons discontinuous.

> Filed from a data-veracity audit of the ingestion and aggregation pipeline on `master`, carried out against the KPI set that the Executive Statistics Deck (PR #20 / `RFC-ARCH-2026-004`) intends to publish. Each issue names the deck KPIs it corrupts. ## Problem The deck publishes **Mean Dwell** as a headline KPI, computed per `RFC-ARCH-2026-004` §3.3: ``` W_mean = Ō · |T| / I ``` `Ō` comes from `get_cycle_average_occupancy_async` (`app/db/occupancy_repository.py:1065`), which Riemann-integrates occupancy across the **full 24-hour cycle** and floors every sample at the patrol guard count: ```python current_occ = guards # initial value at cycle_start ... current_occ = calculate_proportional_occupancy(cum_in, cum_out, k_hat, guards) # -> max(N_patrol, round(I - k*E + N_patrol)) ``` Two biases, both upward, both in the same direction: 1. **The guard floor is integrated over the closed hours.** From roughly 22:00 to 08:00 the true customer occupancy is zero, but the integral accumulates `guards × ~10 h`. With `|T| = 86,400 s` and the default 8 guards that is a constant additive bias on `Ō`, and therefore on `W_mean`. 2. **The `max(...)` clamp truncates the negative tail.** If `k` is even slightly over-estimated, the late-night accumulator would go negative; clamping it at the guard count discards that negative area. The result is that `Ō` can only ever be biased *up* by calibration error, never down — so a drifting `k` and a rising dwell time look identical. ## Why it matters for this deck specifically Little's Law (`L = λW`) assumes a stationary or cyclically-closed system. A 24-hour window in which eight to ten hours are a clamped constant floor is neither. The RFC's own §3.3 states the stationarity precondition and then applies the formula over the full cycle anyway. "Mean dwell 94 minutes" will be read by an executive as "the average visitor stays an hour and a half". It is not that number — it is an integral over a window that includes the mall being shut. ## KPIs corrupted Mean Dwell (Day), Avg Dwell (Week ledger and KPI strip), the per-day `average_dwell_minutes` column in `DayOfWeekProfile`, and any cycle-over-cycle dwell-stability trend built on them. ## Suggested fix 1. Compute `Ō` and `W_mean` over the **retail open window** (`open_time` → `close_time`, already resolved per cycle by `resolve_schedule_context` / `get_active_schedule_info_async`), not over the 04:00→04:00 accounting cycle. `|T|` becomes the open duration; `I` becomes ingress within it. 2. Subtract the patrol baseline before integrating, so `Ō` measures **visitors**, not visitors plus security staff. 3. Report the two separately if the closed-hours figure is wanted: `dwell_open_window_minutes` and `resting_headcount`. One number cannot mean both. 4. Label the KPI with its window in the deck (`94 min · 10:00–22:00`), so the figure is self-describing on a boardroom wall. ## Decision needed This changes a published number's definition. Worth agreeing the window before Phase 2 implements the aggregation, because changing it afterwards makes historical comparisons discontinuous.
Author
Owner

✅ Design settled (grilling session)

Mean Dwell → open-window, visitors, floored at zero

  • Compute Ō / W_mean over open_time → close_time, inside the configured accounting cycle (per-mall daily_reset_time, CONTEXT.md:79 — not hardcoded 04:00). |T| = open duration; I = ingress within it.
  • Occupancy floor: conform the code to CONTEXT.md's own model O(t) = max(0, E − X_adj) — drop the daytime +N_patrol offset and the max(N_patrol, …) clamp. This fixes bias #2 (the clamp hides k over-estimation) and resolves a live code/doc contradiction: code runs max(N_patrol, round(I − k·E + N_patrol)) while CONTEXT.md:91 specifies max(0, E − X_adj + offset).
  • patrol_guard_count stays calibration-only (nocturnal quiet-window convergence, CONTEXT.md:83). It no longer contributes to the daytime metric — guards drift through the sensors unpredictably, so no fixed daytime baseline is meaningful. Bias #1 (guard floor × closed hours) disappears automatically once we stop integrating closed hours.
  • KPI self-labels its window on the deck: 94 min · 10:00–22:00.

Time-of-day dwell (new, from the interview)

  • Split the open window into 3 dayparts. Equal-duration is the default; a volume-tercile mode is also provided. Equal-duration keeps clock bands stable and comparable day-over-day / week-over-week; volume-tercile balances sample size but moves boundaries daily (non-comparable) — hence default equal-duration.
  • Label each daypart by its resolved clock span. A low-traffic band raises a low-sample flag; the boundary does not move. Applies to day and week views.
  • Caveat recorded: Little's Law dwell over a sub-window is weaker than over the full open window; per-hour dwell is out of scope (needs cohort/tracking, future RFC). Per-daypart is the accepted granularity.

Added to scope

  • CONTEXT.md: correct the occupancy-floor formula; add open-window dwell and dayparts glossary; use canonical business cycle wording.
  • ADR (shared with #36): author "Published occupancy figures — measurement window & calibration honesty", recording the dwell redefinition, the historical-discontinuity trade-off at cutover, and the calibration-honesty policy from #36. Author once; the later of #32/#36 references it.

Acceptance criteria (supersede "Decision needed")

  • Ō/W_mean computed over open_time→close_time within the configured accounting cycle.
  • Occupancy floored at 0, +N_patrol daytime offset removed; code matches CONTEXT.md's max(0,…).
  • patrol_guard_count used only for nocturnal calibration convergence.
  • 3 open-window dayparts, equal-duration default + volume-tercile option, clock-labelled, low-sample flag; day + week.
  • Deck KPI self-labels its window.
  • CONTEXT.md occupancy formula corrected; open-window dwell + dayparts documented.
  • Shared ADR authored/referenced.
  • Tests: closed hours no longer inflate Ō; a slightly-high k can now drive occupancy below the old guard floor (negative tail not truncated).

Re-tagged ready-for-agent.

## ✅ Design settled (grilling session) ### Mean Dwell → open-window, visitors, floored at zero - Compute `Ō` / `W_mean` over **`open_time → close_time`**, inside the **configured** accounting cycle (per-mall `daily_reset_time`, CONTEXT.md:79 — not hardcoded 04:00). `|T|` = open duration; `I` = ingress within it. - **Occupancy floor**: conform the code to CONTEXT.md's own model `O(t) = max(0, E − X_adj)` — **drop** the daytime `+N_patrol` offset **and** the `max(N_patrol, …)` clamp. This fixes bias #2 (the clamp hides `k` over-estimation) and resolves a live **code/doc contradiction**: code runs `max(N_patrol, round(I − k·E + N_patrol))` while CONTEXT.md:91 specifies `max(0, E − X_adj + offset)`. - `patrol_guard_count` stays **calibration-only** (nocturnal quiet-window convergence, CONTEXT.md:83). It no longer contributes to the daytime metric — guards drift through the sensors unpredictably, so no fixed daytime baseline is meaningful. Bias #1 (guard floor × closed hours) disappears automatically once we stop integrating closed hours. - KPI **self-labels its window** on the deck: `94 min · 10:00–22:00`. ### Time-of-day dwell (new, from the interview) - Split the open window into **3 dayparts**. **Equal-duration is the default**; a **volume-tercile** mode is also provided. Equal-duration keeps clock bands stable and comparable day-over-day / week-over-week; volume-tercile balances sample size but moves boundaries daily (non-comparable) — hence default equal-duration. - Label each daypart by its **resolved clock span**. A low-traffic band raises a **low-sample flag**; the boundary does **not** move. Applies to day and week views. - Caveat recorded: Little's Law dwell over a sub-window is weaker than over the full open window; per-**hour** dwell is out of scope (needs cohort/tracking, future RFC). Per-daypart is the accepted granularity. ### Added to scope - **CONTEXT.md**: correct the occupancy-floor formula; add **open-window dwell** and **dayparts** glossary; use canonical **business cycle** wording. - **ADR (shared with #36)**: author *"Published occupancy figures — measurement window & calibration honesty"*, recording the dwell redefinition, the historical-discontinuity trade-off at cutover, and the calibration-honesty policy from #36. Author once; the later of #32/#36 references it. ### Acceptance criteria (supersede "Decision needed") - [ ] `Ō`/`W_mean` computed over `open_time→close_time` within the configured accounting cycle. - [ ] Occupancy floored at 0, `+N_patrol` daytime offset removed; code matches CONTEXT.md's `max(0,…)`. - [ ] `patrol_guard_count` used only for nocturnal calibration convergence. - [ ] 3 open-window dayparts, equal-duration default + volume-tercile option, clock-labelled, low-sample flag; day + week. - [ ] Deck KPI self-labels its window. - [ ] `CONTEXT.md` occupancy formula corrected; open-window dwell + dayparts documented. - [ ] Shared ADR authored/referenced. - [ ] Tests: closed hours no longer inflate `Ō`; a slightly-high `k` can now drive occupancy below the old guard floor (negative tail not truncated). Re-tagged `ready-for-agent`.
Author
Owner

Being addressed in draft PR #70, one of four [data-veracity] drafts declared on 2026-09-23 (#68, #69, #70, #71). Each will be triaged, reviewed and implemented in order; the PR description lists the open design points to settle first.

Being addressed in draft **PR #70**, one of four [data-veracity] drafts declared on 2026-09-23 (#68, #69, #70, #71). Each will be triaged, reviewed and implemented in order; the PR description lists the open design points to settle first.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
gabogg/hikcentral#32
No description provided.