No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!70
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/calibration-honesty"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #36
Closes #32
Implemented and ready for review.
Problem
#36: an uncalibrated deployment publishes a borrowed multiplier.
k = 1.1162, a result measured at this mall, is the hardcoded fallback incompute_ewma_multiplier(occupancy_service.py:877-878),refresh_active_multiplier_async(:919-920), the config reads (:1031,:1572) and the repository defaults (occupancy_repository.py:91,:107,:133,:179). With no trusted history, a new deployment shows "calibrated" figures scaled by another site's constant. The default variance0.0002(:856,:896-902,:930, repo:93,:109,:135) draws the narrowest CI band exactly when the sample is empty.#32: Mean Dwell is inflated.
get_cycle_average_occupancy_async(occupancy_repository.py:1169) integrates occupancy over the full 24 h cycle, andcalculate_proportional_occupancy(occupancy_models.py:75) floors it at the patrol guard count (max(N_patrol, round(I − k·E + N_patrol))). Closed hours addguards × ~10 h, and the clamp hides anykover-estimate. The code also contradictsCONTEXT.md'sO(t) = max(0, E − X_adj + offset).Approach (settled)
#36
kfromoccupancy_config.initial_exit_multiplierfrom day one: this deployment1.1162, a fresh deployment1.0. Remove the scattered1.1162literals.UNCALIBRATED/SIN CALIBRARchip on calibrated tiles until 14 trusted business cycles.N < 2derived from the guardrail: SD ≈(1.35 − 0.90) / 4 ≈ 0.11.[0.90, 1.35]. Already done:CONTEXT.mdwas corrected in #63.#32
Ō/W_meanoveropen_time → close_timeinside the configured accounting cycle.+N_patroloffset and themax(N_patrol, …)clamp, matchingCONTEXT.md.patrol_guard_countbecomes calibration-only.94 min · 10:00–22:00).Shared: ADR "Published occupancy figures — measurement window & calibration honesty" (dwell redefinition, the historical-discontinuity trade-off, the calibration-honesty policy).
CONTEXT.md: open-window dwell, dayparts, uncalibrated state.Implementation notes
The database migration preserves an existing site's active multiplier as its seed; a fresh database starts at 1.0. The API supplies maturity, open-window dwell and dayparts. The current calibration desk shows the uncalibrated state; Statistics Deck presentation remains assigned to PR #20. The ADR records the historical dwell cutover.
Acceptance criteria
#36
kfromoccupancy_config.initial_exit_multiplier; no hardcoded1.1162in function signatures or repository defaults.1.0; this deployment's config carries1.1162.N < 2derived from the guardrail (SD ≈ 0.11).[0.90, 1.35];CONTEXT.mdmatches (done in #63).k = 1.0; an uncalibrated one reports the uncalibrated state and a wide variance.#32
Ō/W_meanoveropen_time → close_timewithin the configured cycle.+N_patroldaytime offset removed; code matchesCONTEXT.md.patrol_guard_countused only for nocturnal calibration convergence.Ō; a slightly highkcan drive occupancy below the old guard floor.Shared
CONTEXT.mdgains open-window dwell, dayparts, uncalibrated state.Sequencing across the
[data-veracity]draftsDeclared together on 2026-09-23; each one is triaged, then reviewed, then implemented, in this order:
FLAG_INGESTION_GAP(in #68) and on #27's seam forcycle_date.Review the PRs in this order; no PR has been merged into master.
🤖 Generated with Claude Code
Implementation decisions
Recorded after review round 2, which flagged these as scope creep:
occupancy_definition_cutover_at: records when the Mean Dwell / occupancy-floor definition changed (open point 3, historical discontinuity), so charts and the ADR can mark the cutover instead of comparing across it.dwell_window_start_epoch,dwell_window_end_epoch,dwell_window_label,dwell_kpi_label): implement #32's "the KPI self-labels its window (94 min · 10:00–22:00)".WIP: fix(calibration): calibration honesty — seeded k from config, open-window dwell (#36, #32)to fix(calibration): calibration honesty — seeded k from config, open-window dwell (#36, #32)Implementation is pushed at
3221435. This branch includes the preceding ingestion and facility-time commits for its tests. Review #68 and #69 first. The API and current calibration desk expose maturity; the Statistics Deck chip, confidence band and window presentation remain assigned to PR #20. No PR has been merged intomaster.Standards
Documented Standard Violations (Hard)
app/controllers/analytics_controller.py:27&app/services/analytics_service.py:101:docs/standards/code-standards.md§2.3 &AGENTS.md§2 (Pydantic Schemas: Use Pydantic v2 schemas inapp/schemas/for all API request bodies and structured data payloads. Avoid passing untyped, raw dictionaries between service layers).GET /dwell/daypartsand service methodget_dwell_dayparts_asyncreturn untypeddict[str, Any]with nested dictionaries (days,dayparts), bypassing Pydantic response modeling.Baseline Smells (Judgement Calls)
app/schemas/occupancy_models.py:79,app/services/occupancy_service.py):patrol_guard_count: int = 8is retained acrosscalculate_proportional_occupancy,calculate_occupancy, andcalculate_confidence_interval"for migration callers", but is completely unused in the logic following the zero-floor refactor.0.01265625(0.1125^2) and seed default1.0are copy-pasted across 5 files in all three layers (database.py,occupancy_repository.py,occupancy_models.py,occupancy_service.py,analytics_service.py) rather than referencing a single domain constant inoccupancy_models.pyorconfig.py.W = \bar{L} / \lambda) inanalytics_service.pyduplicates dwell calculation logic already encapsulated inoccupancy_service.py.app/services/analytics_service.py):get_dwell_dayparts_asyncreaches directly intoocc_mgrandocc_repoto slice cycle bounds, fetch raw event ingress, and compute dwell times, rather than delegating domain calculation tooccupancy_service.py.app/services/analytics_service.py): Magic constantslow_sample = count_in < 30andtotal >= 3lack explanatory domain constants.Spec
Missing or Partial Requirements
"- [x] Maturity state exposed until 14 trusted business cycles (chip rendered by the deck, see point 1)"and Issue #36: "when trusted_days_count < 7, the calibrated tiles should carry an UNCALIBRATED state chip".OccupancyCalibrationInfoexposesis_uncalibrated: boolvia the API, but zero frontend templates or scripts were updated in this PR (deferred to PR #20).patrol_guard_countwas deactivated from daytime occupancy math, but remains in the function signatures ofcalculate_proportional_occupancy,compute_confidence_interval, and repository cycle queries as dead arguments.0006-published-occupancy-window-and-calibration-honesty.mdis a 9-line prose document lacking standard ADR sections (Context, Decision Drivers, Considered Options, Consequences) and YAML frontmatter.Scope Creep (Unasked Behaviour)
occupancy_definition_cutover_atDB Column & API Fields: Added a new database column and migration indatabase.pyplus schema fields acrossOccupancyCalibrationInfo,/dwell/dayparts, and hourly timeseries to track definition cutover timestamp, which was not requested in #32 or #36.OccupancyLiveResponse: ExpandedOccupancyLiveResponsewith 4 metadata fields (dwell_window_start_epoch,dwell_window_end_epoch,dwell_window_label,dwell_kpi_label).count_in < 30inanalytics_service.py:94without configuration or specification.Incorrect Implementations
analytics_service.py:68-74, if a burst event advances accumulated ingress across both terciles simultaneously, identical timestamps are appended (cuts[0] == cuts[1]), causing thecuts[0] < cuts[1]validation check to fail and silently discarding volume slicing in favor of equal slicing. Slicing also fails ifcuts[0] == open_epoch.analytics_service.py:200-202, bucket data structures pre-initializecumulative_occupancyand margins topatrol_guardsinstead of0.Summary: 5 standards findings (worst: untyped
dict[str, Any]on/dwell/daypartsand shotgun surgery of variance literal0.01265625); 8 spec findings (worst: volume tercile slicing silently falling back to equal duration during ingress bursts, and bucket pre-initialization retaining patrol guard offset).Addressed the review in
981e8dfand3da2ec0(including the earlier PR fixes).UNCALIBRATEDchip, and the ADR now records the model and tradeoffs in full.The responsive statistics deck and its CI presentation belong to PR #20, as the description states; this PR exposes the maturity state and renders it on the current dashboard. The pre-existing patrol parameters and duplicated constants are cleanup candidates outside this review fix. No merge was performed.
Review Follow-up & Verification of Previous Findings
Previous Review Status
app/controllers/analytics_controller.py&app/services/analytics_service.py: Untypeddict[str, Any]on/dwell/daypartsresolved with Pydantic modelDwellDaypartsResponse(docs/standards/code-standards.md §2.3).count_in < 30extracted toDAYPART_LOW_SAMPLE_ENTRIES = 30.min_stepguard added.UNCALIBRATEDstate chip: Rendered inapp.jsandi18n.jswhen sample count< 14.patrol_guard_count: int = 8retained in function signatures for migration callers.0.01265625(0.1125^2) hardcoded across 5 files deferred as general cleanup.analytics_service.get_dwell_dayparts_asyncdeferred.Items Missed by Previous Review
OccupancyManager.get_live_occupancy_async(occupancy_service.py:1680), Little's Law dwell is computed strictly insideif sched_info["is_open"]. When the facility closes at 22:00,is_openbecomes false, zeroingavg_occupancyand wiping dwell to0.0for the rest of the night (0 min · 10:00–22:00) instead of preserving the completed open-window dwell.get_dwell_dayparts_async(analytics_service.py:88),count_inqueries[start, end - 0.001], whereasget_cycle_average_occupancy_asyncqueries[start, end]. Boundary events are integrated into occupancy for that daypart but excluded from its ingress denominator.Standards
Documented Standard Violations (Hard)
/dwell/daypartswas resolved.Baseline Smells (Judgement Calls)
app/schemas/occupancy_models.py:112):patrol_guard_countis unused incalculate_proportional_occupancyandcalculate_confidence_intervalfollowing the zero-floor refactor, yet remains in public signatures.database.py:220,occupancy_repository.py:270,occupancy_models.py:223, 237,occupancy_service.py:868):The float literal
0.01265625(0.1125^2, guardrail variance) is hardcoded in 5 separate locations across schemas, repository defaults, and services instead of referencing a single domain constant.app/services/analytics_service.py:35-128):get_dwell_dayparts_asyncreaches directly intoocc_repoandocc_mgrto slice bounds, fetch events, and re-implement Little's Law dwell math (W = \bar{L}/\lambda) already encapsulated inoccupancy_service.py.Spec
Missing or Partial Requirements
is_openbecomes false after closing, rather than reporting the completed open-window value until the next day's opening.Scope Creep (Unasked Behaviour)
occupancy_definition_cutover_atDB Column & API Fields: Added database column and schema fields acrossOccupancyCalibrationInfoand dwell responses.dwell_window_start_epoch,dwell_window_end_epoch,dwell_window_label,dwell_kpi_label) toOccupancyLiveResponse.Incorrect Implementations
sched_info["is_open"]wipes the metric during evening and nocturnal review.end - 0.001while Riemann average usesend, creating off-by-boundary discrepancies between ingress denominator and integrated headcount.Summary: 3 standards findings (worst: duplicated float literal
0.01265625and deadpatrol_guard_countparameter across multiple layers); 6 spec findings (worst: live dwell collapsing to 0.0 after closing hours whenis_openbecomes false instead of displaying the completed open-window dwell).Review round 2 addressed —
3c6daeb(plus #68's and #69's fixes merged in)Spec
is_openis a per-day flag, not "open now". The real defect was after midnight: the schedule resolved to the next calendar day, so dwell read 0 from 00:00 to the 04:00 reset. Fixed in #69 (04dddc0) and merged here. Testtest_live_dwell_keeps_completed_open_window_after_closing_and_midnight: 47.6 min after closing and after midnight; a mutation back to calendar-day lookup gives 0.0.[start, end). The old mismatch was measure-zero for the time integral, but the windows now match exactly.patrol_guard_count: removed fromcalculate_proportional_occupancy,calculate_confidence_interval,calculate_occupancyand the repository peak/average queries, along with thebaseline_offsetthose queries only used to compute it. Callers and tests updated.Standards
0.01265625duplicated: fixed. It was 18 copies (not 5) across schemas, repository defaults, the DB seed and services, now oneDEFAULT_MULTIPLIER_VARIANCEwith its derivation documented. The guardrail clamp usesOPERATIONAL_GUARDRAIL_MIN/MAX.analytics_service.get_dwell_dayparts_async(moving the dwell math into the occupancy service is a refactor across both services).Scope-creep findings (
occupancy_definition_cutover_at, dwell window metadata fields): recorded as implementation decisions in the description. They answer this PR's open design point 3 (historical discontinuity) and #32's "the KPI self-labels its window".Verification: pytest 262 passed / 1 skipped, ruff clean, pre-commit passed.
Stacking: #68 → #69 → #70 → #71. Each branch now merges the one below it, so fixes propagate by merge instead of by cherry-pick. Merge in that order.
Code review — round 3 (pre-merge)
Reviewed this PR's own increment in the stack (#68 → #69 → #70 → #71) with two independent passes: Standards (
docs/standards/code-standards.md,AGENTS.md,CONTEXT-FORMAT.md/ADR-FORMAT.md, a code-smell baseline) and Spec (the PR description and its implementation decisions, all previous rounds, and the originating issues). The Spec pass re-ran the suites in a throwaway worktree.Policy for this round: P1 is fixed before merge; P2 is fixed or explicitly accepted; P3 goes to follow-up issues (inline production values → #74, language-specific text → #73).
Standards
Round-2 fixes hold (no dangling
patrol_guard_count/baseline_offset; oneDEFAULT_MULTIPLIER_VARIANCE). No hard violations. ADR 0006 and the four new glossary terms follow the formats.0.90/1.35copied again ininitial_exit_multiplier'sField(...)(occupancy_models.py:234);app.js:3221re-computescount < 14instead of readingsample_maturity.is_uncalibrated, which leaves theMODERATE/INITIALbranches unreachable.14in 3 places (→ #74); the Spanish-only fallback'SIN CALIBRAR'and the server-builtdwell_kpi_label(→ #73); an f-string cache key.Spec
pytest 263, node 67. Round-2 fixes hold.
k.database.py:201-211copies the learnedactive_exit_multiplierintoinitial_exit_multiplier, so the EWMA is re-seeded with its own output.master's implicit seed was1.1162.n_staff_target;k = 1.35, in = out = 1000 gives 180), while ADR 0006 claims a zero floor. The behaviour predates this PR./dwell/daypartsdefaults to the calendar day after midnight; N = 2–13 can still draw a narrow band whileUNCALIBRATED;occupancy_definition_cutover_atis questionable, because dwell is recomputed rather than stored; operators get no in-app explanation of the night occupancy dropping from 12 to 0 on deploy day; there is no UI forinitial_exit_multiplier.Standards: 6 findings, worst P3. Spec: 7 findings, worst P2 (wrong seed on upgrade).
Resolution: the seed P2 and the two one-line P3s are being fixed in this PR. The 180-floor P2 is accepted: ADR 0006 is corrected now, and the model change goes to the follow-up issue.
Review round 3 addressed —
5841296(plus #68/#69 round-3 fixes merged in)LEGACY_IMPLICIT_SEED_MULTIPLIER(1.1162, the constant every function defaulted to before #36), not their learned multiplier, so the EWMA is no longer re-seeded with its own output. Fresh databases keep 1.0. Test: a pre-seed database upgraded twice keepsactive = 1.0842and seeds1.1162.initial_exit_multiplierbounds useOPERATIONAL_GUARDRAIL_MIN/MAX; the maturity badge branches on the backend'sis_uncalibrated, which removes the unreachableMODERATE/INITIALbranches.'SIN CALIBRAR'anddwell_kpi_label→ #73.Verification: pytest 268 passed / 1 skipped, node 67/67, CI green on
5841296.