fix(statistics): daypart dwell multiplier, usual daypart labels, per-day excluded flag (#106) #126
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!126
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/statistics-dwell-labels-excluded"
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 #106
Summary
Step 4 of the pre-deck work (2026-09-25 decisions). This fixes three things the deck would show wrongly:
excludedandtrusted), so the deck's data-quality marker doesn't need a trust-score threshold.Architectural impact
One rule for "which multiplier did this past cycle use". Two private helpers in
AnalyticsServicereplace three separate copies:_trusted_cycle_log_async(day, bounds)returns the cycle's calibration log only if it is trusted and itscomputed_exit_multiplieris aboveMIN_PLAUSIBLE_EXIT_MULTIPLIER; otherwise it returnsNone._cycle_exit_multiplier_async(day, bounds, active)returns that log's multiplier, else the active multiplier.The daily series, dayparts (day and week views) and usual-weekday daypart samples use the multiplier helper. The hourly series uses the log helper, because it also takes the guard count from the trusted log.
MIN_PLAUSIBLE_EXIT_MULTIPLIER = 0.5(app/schemas/occupancy_models.py) names the cut-off calibration already used when learning k.occupancy_service.pynow uses the constant in both of its places. Behaviour change: a trusted cycle multiplier of 0.5 or below (including0.0) is no longer applied by any statistics view. The view falls back to the active multiplier and the hourly series ignores that log's guard count. Before, the daily series accepted such values and the hourly series ignored only0.0.Contract additions (required fields):
UsualDaypart.label: str | None, e.g.10:00–14:20. It is set when every source day has the same clock span (normal since #105) andnullif the spans differ.DailyStatistics.excluded: the current nocturnal audit isAUTO_EXCLUDED,MANUAL_EXCLUDEDorANOMALOUS_QUARANTINE. This is the same rule as the picker'sexcluded_days.DailyStatistics.trusted: the cycle's highest-priority current, unsuperseded verdict (retroactive audit, then manual, then automatic). No verdict means untrusted.Verdict priority in one place.
_CYCLE_VERDICT_PRIORITY_SQLinoccupancy_repository.pyis shared byget_counted_cycle_quality_range_async(newverdictsCTE,is_trustedcolumn) and the trusted-history query, which used to repeat the CASE.is_cycle_data_trusted_asyncis removed, so there is no longer one query per candidate day.API docs updated. No migration.
Resolves the related items in #97 (duplicated verdict CASE), #99 (one trusted-multiplier helper; dayparts multiplier) and #124 (per-candidate trust reads; range-query ordering). Split out: #127 (the per-cycle log lookup can return a neighbouring cycle's log).
Verification
tests/test_statistics_dwell_labels_excluded.py(8 tests, real ASGI + in-memory SQLite, no mocks):trustedfollows the verdict matrix: trusted, auto-excluded, quarantine, pending, no verdict.pytest: 376 passed, 1 skipped. Frontend: 80/80. Ruff, pre-commit andscripts/check_docs.pypassed. Reviewed in two passes (see comments); pass-2 P3s are filed as a follow-up issue.Checklist
UsualDaypart.label(null when source days disagree).DailyStatistics.excludedandDailyStatistics.trusted.🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed
git diff origin/master...origin/fix/statistics-dwell-labels-excluded(fd12979) against #106. Standards and Spec ran independently and are reported separately. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all fixed on the branch.Standards
Checked at
fd12979in a throwaway worktree:ruff check/ruff format --checkclean;pytest367 passed, 1 skipped. No hard violations of AGENTS.md, code-standards.md or git-and-workflow.md: no inline SQL, the helper is typed with a docstring, tests included, API docs updated.Bugs
app/services/analytics_service.py:281: a multiplier of0.0is now accepted. Going from truthiness tois not Nonemakes the hourly series use a trustedcomputed_exit_multiplierof 0, and the guard-count override at:1067now applies too. k = 0 removes every exit, so occupancy becomes cumulative ingress — nonsense.occupancy_service.py:909already ignores values ≤ 0.5 when learning k. The helper should reject non-positive values (or values outsideOPERATIONAL_GUARDRAIL_MIN/MAX) and fall back to the active multiplier, aligning all three views on the safe side. No test covers the hourly change.:278→occupancy_repository.py:2171): the log lookup can return another cycle's log. The query matchescycle_date = ? OR timestamp_epoch BETWEEN start AND endand ranks manual types first, so aMANUAL_OVERRIDEfor cycle D written during D+1's window beats D+1's own automatic log, and D+1 gets D's k and guard count; same when D's nocturnal audit runs after the reset. Not introduced here, but dayparts now inherit it. (bounds.endvsnext_startis consistent:endis inclusive and the repo compares with<=.) Follow-up: key the lookup oncycle_dateonly.:867: seven extra queries for the week view (one per day) — small next to the ~6 per day the daypart loop already makes; could be batched later.:989:usable[0][1][index].labelis safe today (usablekeeps only 3-part days; volume mode with a baseline is rejected). Labels match only because the schedule comes from the current rules (#113); with per-day schedules, source days could differ. Assert, or omit the label on mismatch.Judgement / smells
app/schemas/statistics.py:63:excluded: bool = Falsedefaults a field the service always sets; a missing assignment would silently read "not excluded". Make it required.:865-866:cycle_start, cycle_end, _ = cycle_boundsunpacks right after binding; usecycle_bounds.start/.end.:272, possible Mysterious Name / Middle Man: the helper returns(multiplier, log | None)and two of three callers discard the log. Consider a multiplier-only helper plus a trusted-log accessor for the hourly series.1.0/8defaults at:857,:1045pre-exist).Tests
Three new real ASGI + SQLite flows, no mocks. Gap: no test for the hourly guard-count override or the
0.0path.Spec
Verdict: all three decisions implemented; no scope creep; one partial requirement on the data-quality marker (P2).
(a) Missing or partial
app/schemas/statistics.py:63,app/db/occupancy_repository.py:1473). Spec: "The data-quality marker is triggered by the audit verdict (excluded/untrusted), gap estimates and missing days — not by a trust-score threshold." Daily rows carryexcluded,gap_estimated,has_data(missing days derivable as!has_data && !is_closed) anddata_trust(a score) — but no untrusted-but-not-excluded verdict. APENDING_AUDITcycle and a cycle with no current nocturnal verdict both returnexcluded=false; the deck could only catch them with adata_trustthreshold, which the spec rules out. The usual-weekday baseline already applies this rule viais_cycle_data_trusted_async(docs: "Days with no current audit verdict or a current untrusted verdict are excluded"). Addtrusted: boolfrom the same verdict (or an audit-status enum) next toexcluded, or record in the issue thatexcludedalone triggers the marker.excludedflag (analytics_service.py:945-951); the daypart week view has no per-day quality fields at all. The spec only requiresDailyStatistics.excluded, so this is a note for the deck.(b) Scope creep
None. The hourly series now uses
is not None(a trusted0.0is no longer ignored) — exactly the drift the shared helper was asked to fix.(c) Implemented but wrong
None found.
_cycle_exit_multiplier_async(analytics_service.py:272-283): daily series (:750), dayparts day and week views (:867, per day), hourly (:1064), usual-weekday daypart samples (via the recursiveget_dwell_dayparts_async), the summary's peak and mean dwell (from the daily series), and the multi-day overlay (via hourly). Remainingactive_exit_multiplierreads in statistics paths are only the fallback passed into the helper.bounds.endfor the log window, as both old copies did.excludedcomes from the sameget_counted_cycle_quality_range_asyncrow (is_excluded) as the picker'sexcluded_days.UsualDaypart.labelfromusable[0](:989) is valid since #105 guarantees identical opening hours.get_calibration_log_for_cycle_asyncalso matches any current log whosetimestamp_epochfalls in the cycle window and doesn't filtersuperseded_by; live-day dayparts can pick up the previous cycle's audit if it was stamped after the reset. Follow-up.Tests
The 3 new tests pass and each fails without its change (swapping the helper back to the active multiplier fails the dayparts test; the label and
excludedtests fail on the missing field). Not covered: the hourly0.0behaviour change;AUTO_EXCLUDEDandANOMALOUS_QUARANTINE(onlyMANUAL_EXCLUDED); the week-view daypart path.Summary: Standards 9 (2×P2, 7×P3), worst: a trusted multiplier of 0.0 is now accepted by the hourly series. Spec 5 (1×P2, 4×P3), worst: daily rows don't expose an untrusted-but-not-excluded verdict for the data-quality marker.
🤖 Generated with Claude Code
Pass-1 findings addressed in the latest commit. Full suite 376 passed, 1 skipped; frontend 80/80; ruff and docs check clean.
Standards
0.0acceptedMIN_PLAUSIBLE_EXIT_MULTIPLIER(the 0.5 calibration already used, now named and used in both of its places);_trusted_cycle_log_asynconly returns a trusted log whose multiplier is above it, so a0.0falls back to the active k in all three views. Tests: implausible multiplier falls back (and doesn't apply the guard override); a plausible one sets k and guards. Mutation-checked.usable[0]label assumption_shared_labelreturns the label only if every source day agrees, elsenull(UsualDaypart.label: str | None); unit-tested.excluded: bool = Falsedefaultexcludedand the newtrustedare required.cycle_bounds.start/.end._trusted_cycle_log_async(hourly needs the log for guards) and_cycle_exit_multiplier_async(multiplier only).0.0pathSpec
DailyStatistics.trustedfrom the cycle's highest-priority current verdict (retroactive > manual > automatic; no verdict = untrusted), the same rule the usual-weekday baseline uses. It's computed in the batchedget_counted_cycle_quality_range_asyncwith one shared_CYCLE_VERDICT_PRIORITY_SQLfragment, which also replaced the duplicated CASE in the trusted-history query. Baseline selection now reads it from the batch, so the per-candidate trust query (andis_cycle_data_trusted_async) is gone. Test matrix: trusted, auto-excluded, quarantine, pending, no verdict.excludedsuperseded_by/ matches neighbouring logsAUTO_EXCLUDED,ANOMALOUS_QUARANTINE, week viewSide effects noted for the follow-up issues: #124 (per-candidate trust reads; range-query ordering precondition) and #97 (duplicated verdict-priority CASE) are resolved by this commit; #99 (one trusted-multiplier helper; dayparts multiplier) is resolved by this PR.
🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at
57fc02aagainst #106. Each axis checked every pass-1 finding against the code (not the resolution text), then reviewed the fix commit for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined or split out with a reason. Per policy, P1/P2 block merge; P3s go to one follow-up issue.Standards
Checked in a throwaway worktree:
ruff check/ruff format --checkclean;pytest376 passed, 1 skipped.Pass-1 findings
0.0acceptedanalytics_service.py:279-301requires> MIN_PLAUSIBLE_EXIT_MULTIPLIER(occupancy_models.py:201, reused atoccupancy_service.py:910,1172); the hourly guard override follows the plausible log (:1083-1088).usable[0]label assumption_shared_label(:116);UsualDaypart.label: str | None.excluded: bool = Falsedefaultexcludedandtrustedare required (statistics.py:63-64).:885._trusted_cycle_log_async/_cycle_exit_multiplier_async.0.0pathNew findings
(multiplier, log)and that "a trusted multiplier of0.0is no longer ignored"; the code now does the opposite. It doesn't mentionDailyStatistics.trusted,MIN_PLAUSIBLE_EXIT_MULTIPLIER,_CYCLE_VERDICT_PRIORITY_SQLor the removedis_cycle_data_trusted_async, and Verification still says 3 tests / 367 passed.analytics_service.py:230-231says "oldest first, as the range query expects; the trust verdict is then read only until enough days pass". Neither is true now, and thesorted(candidates)is no longer needed.docs/api/README.md:128says the label "is the same for all of them"; it can now benull.occupancies(response)has no annotations;_shared_labeltakeslist[Any]instead of the daypart model type.seed_day; extract aseed_events(day)helper.> 0still passes (nothing pins the 0.5 boundary), and reverting tousable[0][1][index].labelstill passes (the helper's wiring into the response isn't tested). Caught: dropping the plausibility guard, derivingtrustedfromexcluded, and the baseline ignoring trust.excludedandtrustedcome from different verdict rules; see Spec.Merge readiness (this axis): ready once the PR description is updated.
Spec
pytest376 passed, 1 skipped.Pass-1 findings
DailyStatistics.trustedis required, filled from theverdictsCTE (occupancy_repository.py:1462-1470,1494-1495), and a missing verdict counts as untrusted; test matrix attests/test_statistics_dwell_labels_excluded.py:161.excludedsuperseded_by/ matches a neighbouring cycleget_calibration_log_for_cycle_asyncis unchanged.Baseline equivalence: the batch reproduces the old
is_cycle_data_trusted_asyncrule exactly: same filter (is_current=1 AND superseded_by IS NULL), same priority order and tie-breaks, and no verdict counts as untrusted. The only change is dropping the early stop, which affects speed only. TheMIN_PLAUSIBLE_EXIT_MULTIPLIERfallback also applies to the daily series; that is justified by the pass-1 P2 and documented.New findings
excludedandtrustedcan disagree. Spec: "The data-quality marker is triggered by the audit verdict (excluded/untrusted)".is_excludedreads the latestAUTOMATIC_NOCTURNALlog (occupancy_repository.py:1471-1480), whileis_trustedreads the highest-priority verdict. Supersession only runs between nocturnal logs, so anAUTO_EXCLUDEDday that an admin overrides readsexcluded=true, trusted=true, and the baseline would use it. Either deriveexcludedfrom the same ranked verdict or document which flag wins. This fits with #127.null(docs/api/README.md:128). Spec: "eachUsualDaypartcarries its start/end clock labels". Returning null is a reasonable safeguard, but the contract should say so.analytics_service.py:231(same as Standards).get_calibration_audit, which doesn't exist; the drift it describes was between the daily-series and hourly lookups, and both now go through_trusted_cycle_log_async.No scope creep. Merge readiness (this axis): ready.
Summary: Standards: 1 P2 + 7 P3; worst is the out-of-date PR description. Spec: 3 P3; worst is that
excludedandtrustedcan disagree. After the description is updated, the P3s go to one follow-up issue and the PR can merge.🤖 Generated with Claude Code