feat(statistics): usual weekday baseline matches opening hours; configurable sampling (#105) #122
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!122
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/statistics-baseline-rules"
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 #105
Summary
Step 3 of the pre-deck work (2026-09-25 decisions). The Usual Weekday Baseline:
baseline=usual_weekday.Architectural impact
opening_hours_by_schedule(exception, weekday, holiday_hours)inoccupancy_service.pyis used byget_active_schedule_info_asyncand byScheduleCalendar.opening_hours(the calendar now carries the configured holiday hours). This mirrorsis_open_by_schedulefrom #110. The weekday fallback hours are named constants instead of literals.get_usual_weekday_baseline_dates_async(day, reset, rules, calendar)takes aUsualWeekdayRules(max_samples, lookback_weeks, min_samples)built from the config. It skips candidates whose opening hours differ from the target day's. All three callers (summary, presenter/admin hourly, dayparts) userules.min_samples.statistics_baseline_max_samples(4),statistics_baseline_lookback_weeks(8) andstatistics_baseline_min_samples(2):BASELINE_LOOKBACK_WEEKS_LIMITof 52;OccupancyConfigSchemaenforces min ≤ max ≤ lookback.POST /api/occupancy/confignow validates the merged configuration before a partial save, so a save that sends one field can't break the rule (returns422 VALIDATION_ERROR).UsualWeekdayBaselineKind = Literal["usual_weekday"]on the four routes.same_weekday_4wis now rejected; no client used it yet.Known limitation: schedules have no history yet, so past days are classified with the current opening hours. Until #113 lands, the hours rule only excludes days when the target day is a dated exception with its own hours.
Verification
tests/test_statistics_baseline_rules.py(8 tests):same_weekday_4wvalue is rejected.pytest: 362 passed, 1 skipped. Frontend: 80/80. Ruff, pre-commit andscripts/check_docs.pypassed.Checklist
baseline=usual_weekdayon all four routes.🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed
git diff origin/master...origin/feat/statistics-baseline-rules(6f0882f) against #105. 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
No hard violations of the documented standards. Ruff clean; the three baseline and closed-day test files pass (25). The 422 follows the
{detail, error_code}contract (main.py:95mapsX-Error-CodetoVALIDATION_ERROR).Bugs and risks
app/controllers/occupancy_controller.py:127-135: read config → validate merged → write is not atomic. Two concurrent admin saves can each pass and still store rows breaking min ≤ max ≤ lookback (A sets min=4 while B sets max=3). ThenGET /config(:108) and the POST's own response (:135) raise an unhandledValidationError→ 500, and the admin form can only be fixed in the DB. Old rows are safe (migration defaults 4/8/2 are consistent). Fix: validate inside the repository write transaction, or make GET degrade gracefully.occupancy_controller.py:131:detailbecomes "Value error, usual weekday baseline needs…" — Pydantic's prefix leaks, and only the first error is reported.app/services/analytics_service.py:213-231, performance: each surviving candidate costs 3 sequential queries (flow,_cycle_quality_async, trusted). With a 52-week lookback and sparse clean data that's up to ~156 queries per call, and the summary re-queries each selected day's flow.get_counted_cycle_quality_range_asyncalready takes a batch.occupancy_service.py:135,ScheduleCalendar.holiday_hoursdefault: constructing without it silently uses 10:00–18:00 instead of the configured hours. The only constructor (:276) passes it, so the default is Speculative Generality that can hide bugs. Make it required.opening_hours_by_schedulevs the old live-schedule code: holidays use custom hours else the configured holiday hours; weekdays use the row else 08:00–21:00;Nonepasses through as before.analytics_service.py:218: candidates with an exception are skipped earlier, soopening_hours(candidate)is always the same weekday's hours; the check only matters when the target is an exception (as the PR says) and could be computed once outside the loop.Smells (judgement calls)
database.py:248-250,from_configfallbacks,index.html:805-809min/max/value). Holiday hours"10:00"/"18:00"are still literals inoccupancy_repository.py:276-292,335,404,523next to the newDEFAULT_HOLIDAY_HOURS. The migration could interpolate the constants.DEFAULT_WEEKDAY_HOURS/DEFAULT_HOLIDAY_HOURS(occupancy_service.py:99-100) sit in the service layer, while otherDEFAULT_*live inschemas/occupancy_models.py(the repository can't import from services).tuple[str, str]read by[0]/[1]; a smallOpeningHoursNamedTuple would name the parts..get(..., DEFAULT)fallbacks inconfigured_holiday_hoursandUsualWeekdayRules.from_configcan't trigger (the repository always fills these keys).Spec
Checked against every settled decision in #105. The glossary text matches the spec word for word, all four routes are renamed, the defaults stay 4/8/2, and nothing in the frontend or docs still uses
same_weekday_4w, "four" or "eight weeks". One bug and one missing doc item.(c) Implemented but wrong
app/services/analytics_service.py:955:if len(usable) < 2:). Spec: "Replace the fixed numbers with config keys (no magic numbers): … minimum samples (default 2)." and "so the hourly and daypart baselines stay consistent." The first check (:935) usesrules.min_samples; the second, after dropping sample days with no entries, still uses 2. Withmin_samples=1, dayparts returnsINSUFFICIENT_USUAL_WEEKDAYSwhere hourly returns a baseline; withmin_samples=3and one sample dropped, dayparts builds a baseline from 2 days. Userules.min_samplesand add a daypart test with a non-default minimum (the only minimum test goes through hourly,tests/test_statistics_baseline_rules.py:104).(a) Missing or partial
CONTEXT.md:97correctly drops "Daypart dwell is weighted by each day's entries", butdocs/api/README.md:128never picked it up — it has the volume-daypart rule but nothing on weighting by entries.(b) Scope creep / agent-made decisions (for audit)
POST /api/occupancy/config(occupancy_controller.py:123); the 52-week limit (BASELINE_LOOKBACK_WEEKS_LIMIT,occupancy_models.py:208); admin form fields and es/en labels (index.html:804). Reasonable, but confirm them explicitly.Stated limitation (#113)
analytics_service.py:218) only changes anything when the target day is itself an exception, and the test (test_statistics_baseline_rules.py:147) can only cover that case. Make #113 nameScheduleCalendar.opening_hoursas the call site to switch to per-day records.Confirmed correct
_Avoid_.baseline=usual_weekday(presenter/admin hourly and dayparts covered intests/test_statistics_baselines.py); the retired value is rejected (:207).ScheduleCalendar(now carrying configured holiday hours) for summary, hourly and dayparts.app/,docs/,tests/,CONTEXT.md(therange(1, 9)intests/test_statistics_baselines.py:64only seeds data).Summary: Standards 10 (1×P2, 9×P3), worst: concurrent config saves can store an inconsistent baseline rule, after which GET /config returns 500. Spec 5 (2×P2, 3×P3), worst: the daypart baseline still has a hard-coded minimum of 2.
🤖 Generated with Claude Code
Pass-1 findings addressed in the latest commit. Full suite 364 passed, 1 skipped; frontend 80/80; ruff and docs check clean.
Agent-made decisions confirmed by the maintainer: min ≤ max ≤ lookback validation on the merged config, the 52-week lookback limit, and the three admin form fields with es/en labels.
Standards
update_config_fields_asyncnow doesBEGIN IMMEDIATE, reads the stored row, validates the merged configuration withOccupancyConfigSchema, then writes, all in one transaction. The controller only mapsValidationErrorto 422. Newtest_concurrent_saves_cannot_store_inconsistent_sampling_rules: two saves that are valid alone but not together → exactly one refused, stored rules consistent.detailjoins every error message with the "Value error, " prefix removed.get_counted_cycle_quality_range_asynccall; the trust verdict is read per candidate only untilmax_samplespass.ScheduleCalendar.holiday_hoursdefault"10:00"/"18:00"literals now useDEFAULT_HOLIDAY_HOURS. The HTMLmin/max/valueattributes stay literal, as with the other config inputs (HTML can't import them).DEFAULT_WEEKDAY_HOURS/DEFAULT_HOLIDAY_HOURSmoved toschemas/occupancy_models.py.tuple[str, str]OpeningHours(opens, closes)NamedTuple..getfallbacksconfigured_holiday_hoursandUsualWeekdayRules.from_configread the keys directly; tests useUsualWeekdayRules.defaults().Spec
rules.min_samples. Newtest_hourly_and_daypart_baselines_use_the_same_configured_minimum(min 1, one usable day → baseline on both routes); fails with the hard-coded 2 (mutation-checked).ScheduleCalendar.opening_hours(andis_closed) as the place to switch to per-day records.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at
67a37e3. Each axis verified every pass-1 finding against the code, then reviewed the fix commit for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per policy, P1/P2 block merge; P3s go to one follow-up issue.Standards
Pass-1 findings
67a37e3)occupancy_repository.py:467-482:BEGIN IMMEDIATE, read row, validate merged withOccupancyConfigSchema, UPDATE, commit in one transaction; rollback onBaseException. In sqlite3's legacy transaction mode the explicit BEGIN means no implicit BEGIN before the UPDATE; theget_async_dbPRAGMAs don't open a transaction; a second writer waits onbusy_timeout=5000instead of an immediate SQLITE_BUSY. Mutation-checked: removingBEGIN IMMEDIATE, or moving validation back before the transaction, failstest_concurrent_saves_cannot_store_inconsistent_sampling_rules3/3; with the fix the file passes 5/5.occupancy_controller.py:127-133analytics_service.py:222-243: one batched quality read, then one trust query per candidate untilmax_samplespass (up tolookback_weekswhen many days are untrusted). The summary still re-reads each selected day's flow (:452-457). Not blocking.ScheduleCalendar.holiday_hoursdefaultoccupancy_service.py:128database.py:253-268); repository literals useDEFAULT_HOLIDAY_HOURS; HTML kept literal with a reason. The seed row still hard-codes'10:00', '18:00'(database.py:284) and the weekday seed"08:00", "21:00"(:300-306) next toDEFAULT_WEEKDAY_HOURS.schemas/occupancy_models.py:205-215tuple[str,str]OpeningHours, used throughoccupancy_service.py:99-132.getfallbackscfgfromget_config_async(migrated row +setdefaults; the no-row fallback dict has every key); the only test fakingget_config_asyncnever reaches these functions; tests useUsualWeekdayRules.defaults().New findings
occupancy_repository.py:1449:get_counted_cycle_quality_range_asyncbounds its audit CTE withBETWEEN cycles[0][0] AND cycles[-1][0], silently requiring oldest-first input. Only a caller comment records it (analytics_service.py:221); correct today viasorted(candidates), but a future newest-first caller would silently lose audit rows. Usemin/maxin the repository or document the precondition.occupancy_repository.py:472:dict(stored)raisesTypeError→ 500 if row 1 is missing (the old UPDATE was a no-op).init_schemaalways seeds the row, so theoretical.database.py:284,300-306duplicateDEFAULT_HOLIDAY_HOURS/DEFAULT_WEEKDAY_HOURS.Merge readiness (this axis): Ready. P3s → follow-up issue.
Spec
Fix commit
67a37e3checked against pass-1 head6f0882fand #105. The three baseline test files pass (27) in a throwaway worktree; mutation check: restoring the daypart minimum to< 2failstest_hourly_and_daypart_baselines_use_the_same_configured_minimum.Pass-1 findings
analytics_service.py:965isif len(usable) < rules.min_samples:; summary (:446), hourly (:1216) and daypart (:945,:965) all userules.min_samples. Test attests/test_statistics_baseline_rules.py:220(min 1, one usable day → baseline on both routes) fails with the old2.docs/api/README.md:128: "Daypart dwell in the baseline is weighted by each source day's entries (Σ dwell × entries / Σ entries)", matchinganalytics_service.py:985-989; the glossary still leaves it out, as specified.occupancy_repository.py:449-483), covered bytest_concurrent_saves_cannot_store_inconsistent_sampling_rules.ScheduleCalendar.opening_hours(day),ScheduleCalendar.is_closed(day)andget_active_schedule_info_async, and restates the limitation.New findings
None at P1/P2. The reworked candidate selection keeps every source-day condition in the glossary ("A day counts only if it has counted flow, is not a holiday or Closed Day, has no ingestion gap, is trusted in its current audit, and had the same opening hours."):
analytics_service.py:209-219: holiday, Closed Day, same hours, before the current business day) still run before any database read.get_counted_cycle_quality_range_asyncread (occupancy_repository.py:1441):has_data(EXISTS on counted-camera events) matches the oldCOUNT(*) > 0;has_gapuses the same overlap rule as the old_cycle_quality_async; candidates are sorted oldest first for the query'sBETWEEN.max_samples— same selected days and order as before.Glossary, the rename to
baseline=usual_weekdayon all four routes and the 4/8/2 defaults are unchanged; the migration now builds its defaults from the named constants.Merge readiness (this axis): Ready.
Summary: Standards: no P1/P2; 3 P3 (worst: hidden oldest-first precondition in the batched quality query). Spec: no findings; ready.
🤖 Generated with Claude Code
Pass 2: no P1/P2 on either axis. Remaining P3s filed as #124. Tested merged with current
master: 364 passed, 1 skipped. Merging.🤖 Generated with Claude Code
gabogg referenced this pull request2026-09-25 23:07:50 +00:00