fix(occupancy): saving the config form no longer resets calibration #115
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!115
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/occupancy-config-save-preserves-calibration"
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?
Summary
Saving the admin occupancy config form reset the live exit multiplier (k) to 1.0 and overwrote the stored trust thresholds with defaults. Found while adding the #104 setting to the same form.
Problem
POST /api/occupancy/configvalidated the body withOccupancyConfigSchemaand wrotepayload.model_dump(): every field the form doesn't send (active_exit_multiplier,calibration_mode,multiplier_variance, …) arrived as its schema default andupdate_config_asyncwrote it. A calibrated k of 1.17 became 1.0 until the next calibration run; FLOW_RATE_DENSITY mode would revert to PROPORTIONAL_RATIO.GET /api/occupancy/configbuilt its response field by field and omitted the trust thresholds and calibration state, so the form's "Calibration and trust thresholds" inputs always showed defaults and saving re-wrote those defaults over stored values.master: stored k = 1.17 andtrust_ratio_min= 0.9 → GET shows 0.8 / 1.0 → form save → stored k = 1.0,trust_ratio_min= 0.8.Production impact (corrected after review): every save of the config form reset k to 1.0, and also reset the seed
initial_exit_multiplier(the form sends it, but GET never returned it, so it was always 1.0) and discarded any non-default trust threshold. The nightly auto-calibration does not rebuild k from history; it blends into the stored value (0.25 × empirical + 0.75 × stored), so a reset k kept pulling towards 1.0 for several nights, shrinking by 0.75 per trusted cycle, and the loggeddrift_valuewas skewed meanwhile. Paths that rebuild k from trusted history (refresh_active_multiplier_async: every app startup via anomaly reconciliation, trust toggles, retroactive audits; not the manual historical-reconciliation endpoint) restore it, but the rebuild seeds frominitial_exit_multiplier, which the same bug reset. A save also clearedlast_reset_date, which nothing reads.Architectural impact
OccupancyConfigSchema.model_validate(stored_config).OccupancyConfigUpdatebody: every admin-editable setting with the same validation, minus the calibration-owned fields (CALIBRATION_OWNED_CONFIG_FIELDS: active exit multiplier, variance, mode, last-calibration markers), which are rejected with422 VALIDATION_ERROR; unknown fields are rejected too.OccupancyRepository.update_config_fields_asyncwrites only the submitted fields in oneUPDATE, so a calibration write between the admin's read and save is not lost.app.js): the baseline offset is sent only when the admin edited it (calibration also writes it overnight), and settings the form does not show (auto reset, auto scroll) are no longer sent.Verification
tests/test_occupancy_config_api.py: a form-shaped save (fields read fromindex.html) keeps k, variance, mode, the seed k, and an offset calibration wrote while the form was open, and saves the edited threshold; a single-field save changes only that field; each calibration-owned field is rejected. Fails onmaster.pytest: 341 passed.937141d), so a second save without reloading can't send a stale value, and never sends the offset if the config failed to load. Ruff, pre-commit and the docs check passed.Checklist
🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed
git diff origin/master...origin/fix/occupancy-config-save-preserves-calibration(0e91125). No originating issue: the Spec axis checked the fix against the bug report in the PR description. 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
Ruff check/format pass;
tests/test_occupancy_config_api.pypasses. Branch name and commit message followgit-and-workflow.md; the test uses named constants.Real bugs / risks
app/controllers/occupancy_controller.py:112–121). The docstring says calibration-daemon values "keep [their] stored value", but only when the client leaves them out.OccupancyConfigSchemastill acceptsactive_exit_multiplier(no bounds),multiplier_variance,calibration_mode(any string, no enum) andlast_calibrated_at/last_calibrated_offset. Withexclude_unset, an admin can set just one of them (e.g. k=50) and it is merged and written. Already possible on master via the full dump. Suggest a separateOccupancyConfigUpdaterequest schema without these fields.:120–121). The endpoint reads the whole row, merges, and writes the whole row back. Ifset_active_exit_multiplier_asyncorset_calibrated_offset_asynccommits between those awaits, stale values overwrite it. Narrow window, far better than master; a partialUPDATEin the repository would close it.:107).model_validatechecks storedinitial_exit_multiplieragainst its 0.90–1.35 guardrails and thetrust_*bounds. No path produces an out-of-range row (legacy seed 1.1162 is in range;trust_*areNOT NULLwith in-range defaults; other writers go through the schema or typed setters). A hand-edited row would now 500 instead of showing defaults. Awareness only.last_reset_date = ""; the merge now keeps the stored value. Nothing reads that column. Mention it in the description.Documented-standard violations
occupancy_controller.py:101, 110: both touched handlers lack a return annotation (AGENTS.md §2, code-standards.md §2: "explicit type hints … parameters and return types"). Add-> OccupancyConfigSchema.Baseline smells (judgement calls)
tests/test_occupancy_config_api.py:17–31:FORM_FIELDScopies the field list fromsaveOccupancyConfiginapp.js; they can drift. Acceptable since the comment names the source.calibration_mode: strshould be aLiteral/enum, which also covers the P2.Test gaps (P3)
No coverage of
calibration_modeormultiplier_variancesurviving a save (named as regressions in the PR), nor of a POST sending a single field.Spec
Verdict: the fix is right for the stated problem; no P1. The "production impact" claim understates the damage.
Claims checked
active_exit_multiplier,calibration_mode,multiplier_variance. The repo writes all three unconditionally (app/db/occupancy_repository.py:429-432) and master'smodel_dump()filled them with defaults. The trust thresholds useCOALESCE, but the defaults are non-null, so they were overwritten too. Confirmed.initial_exit_multiplierwas also reset. The form sends it viadata-config-key(index.html:773), but master's GET omitted it, so it was always saved as 1.0. The PR fixes this but doesn't mention it.assert 0.8 == 0.9) and passes on the branch; it covers both halves (GET-side assertion catches the omission, the finalafter[...]assertions catch the POST clobber).(c) Overstated / understated claims
new = 0.25*empirical_k + 0.75*old_multiplier(app/services/occupancy_service.py:~1150, viacheck_and_run_auto_calibration_async). A k reset to 1.0 carries into later nights, the error shrinking by 0.75 per trusted cycle; untrusted cycles keep 1.0. The loggeddrift_valueis skewed too. Correct the impact statement, and note that a manualrefresh_active_multiplier_async(analytics endpoint) restores k from trusted history.(a) Not fixed / remaining clobbers
app/static/js/app.js:2338. The form still postsbaseline_offsetloaded when the page opened. That field is also written by the calibration daemon (set_calibrated_offset_async,occupancy_repository.py:563) and the daily reset (:1636/1660). An admin tab left open across the quiet window overwrites that night's calibrated offset on the next save — the same kind of bug. Fix, or state out of scope and file an issue.app.js:2340-2342. The form hard-codesauto_reset_midnight: falseand sendsauto_scroll_*from hidden inputsloadOccupancyConfignever fills; both still overwrite stored values. No consumer today; low impact.occupancy_controller.py:120-121. Read-then-write without a transaction; a daemon write to k between the two is lost. Small window.(b) Scope creep
None. GET moving to
model_validateis needed for the fix.Test gaps (P3)
tests/test_occupancy_config_api.py:59: the real form sends all 12data-config-keyfields; the test sends onlytrust_ratio_min, so it doesn't exactly match the UI payload.calibration_mode,multiplier_varianceorinitial_exit_multiplierare preserved, though the PR lists them.Summary: Standards 10 (1×P2, 9×P3), worst: the request schema still lets a client set daemon-owned calibration fields (k, variance, mode). Spec 7 (2×P2, 5×P3), worst: the form still clobbers the nightly calibrated
baseline_offset; also the PR understates impact (k reset persists through the 0.75 blend).🤖 Generated with Claude Code
Pass-1 findings addressed in
01684eb. Full suite 341 passed; ruff and docs check clean. The PR description is corrected (impact, design, verification).Standards
OccupancyConfigUpdaterequest body (extra="forbid") = every admin-editable setting with the same validation, minusCALIBRATION_OWNED_CONFIG_FIELDS(k, variance, mode, last-calibration markers). Sending one returns422 VALIDATION_ERROR; parametrized test per field.OccupancyRepository.update_config_fields_asyncwrites only the submitted fields in oneUPDATE(keys checked againstOccupancyConfigUpdate), so a calibration write in between is no longer overwritten.last_reset_dateside effect-> OccupancyConfigSchemaon both handlers.UPDATE).FORM_FIELDSduplicates app.jsindex.html'sdata-config-keyinputs; the remaining named inputs stay listed with their source.calibration_mode: strstr— follow-up if aLiteralis wanted there.Spec
0.25 × empirical + 0.75 × stored) carried the reset over several nights; paths that rebuild k from trusted history seed frominitial_exit_multiplier, which the same bug reset.initial_exit_multiplierreset unmentionedbaseline_offsetdata-loadedtracking inapp.js, refreshed after a save). Test: an offset written by calibration while the form is open survives the save.auto_reset_midnight/auto_scroll_*from unloaded inputsdata-config-keyfield fromindex.htmlplus the named inputs.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at
01684eb. Each axis verified every pass-1 finding against the code, then reviewed the fix commit(s) 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
01684ebapp/schemas/occupancy_models.py:330–353:OccupancyConfigUpdatewithextra="forbid"omitsCALIBRATION_OWNED_CONFIG_FIELDS; POST uses it (occupancy_controller.py:113); parametrized 422 test per field (tests/test_occupancy_config_api.py:102–111). OpenAPI lists 25 fields,additionalProperties: false, guardrail bounds kept.UPDATE(occupancy_repository.py:414–432); test shows a calibration-written offset survives (:69–82).last_reset_dateside effect unmentionedoccupancy_controller.py:105, 116.FORM_FIELDSduplicates app.jsdata-config-keyfields read fromindex.html(:37–42); the 9 named inputs still copied by hand, source named. Acceptable.calibration_mode: strstr.New findings
app/static/js/app.js:2370). After a save onlydataset.loadedwas updated; the input kept the old value, so a second save without reloading sent it and overwrote the calibrated offset (refreshOccupancyData()doesn't reload the form). Fix: set both from the response.app.js:2349): if the GET failed,dataset.loadedisundefinedand"" !== undefinedsendsbaseline_offset: 0. Guard withdataset.loaded !== undefined. (Clearing the input is a deliberate edit; non-admins get 403.)occupancy_models.py:344, annotations atoccupancy_controller.py:113,occupancy_repository.py:420):create_modelreturns a variable, so pyright/mypy flagpayload: OccupancyConfigUpdateand editors lose completion. No type checker in CI and OpenAPI renders correctly; a static subclass would fix it at the cost of duplication.occupancy_repository.py:423–427): column names only from keys Pydantic accepted withextra="forbid", re-checked againstmodel_fields; values bound with?. TheValueErrorguard has no test (P3).update_config_async(occupancy_repository.py:434) has no caller left inapp/; only tests seed with it.ruff clean; pytest 340 passed, 1 skipped at
01684eb(throwaway worktree).Merge readiness (this axis): Blocked by the P2 (stale offset after a save) — one-line fix. P3s → follow-up issue.
Spec
Pass-1 findings
0.25 × empirical + 0.75 × stored, matchingoccupancy_service.py:1149, and names the seedinitial_exit_multiplier(:904). One precision gap remains (N2).initial_exit_multiplierreset unmentionedtests/test_occupancy_config_api.py:84.baseline_offsetapp.js:2033-2039,:2349-2352send the offset only when edited (maintainer decision); the post-save bookkeeping reopens the hole (N1).auto_reset_midnight/auto_scroll_*from unloaded inputsapp.js:2335-2345);cfg-auto-scroll/cfg-scroll-intervalare hidden andcfg-auto-resetdoesn't exist, so no visible control loses effect.UPDATE, keys whitelisted againstOccupancyConfigUpdate(occupancy_repository.py:414-432).data-config-keyinputs fromindex.htmlplus the named inputs (test_occupancy_config_api.py:24-44).test_occupancy_config_api.py:81-84, plus a parametrized 422 test per calibration-owned field.Description claims checked: blend formula correct;
last_reset_datecorrect (master cleared it; nothing inapp/reads it); flows that rebuild k partly correct (N2).Other clients checked for new 422s: the only
POST /configclients areapp.jsand tests;tests/test_occupancy.py:249,274,628send only admin-editable fields; every write of calibration-owned fields goes through repository methods, not the endpoint; no CLI or script posts to/config.test_occupancy_config_api.py+test_occupancy.py: 25 passed (throwaway worktree).New findings
app/static/js/app.js:2370(fix commit01684eb): a second save still overwrites the calibrated offset. After a save,dataset.loadedis set tosaved.baseline_offset, but the input keeps the old value andrefreshOccupancyData()doesn't reload the form. Scenario: page loads offset 5 → calibration writes 8 overnight → admin saves (offset not sent, correct;loaded = "8", input still5) → admin saves again →"5" !== "8", the form sends 5 and overwrites the calibrated value. Fix: writesaved.baseline_offsetinto the input as well asloaded(also shows the admin the current value). Only the server side is tested.refresh_active_multiplier_asyncis called by the trust toggle (analytics_controller.py:354), the retroactive audit (occupancy_service.py:1538) andreconcile_and_quarantine_historical_anomalies_async(:1468), which runs on every app startup (main.py:63). The manual historical-reconciliation endpoint (analytics_controller.py:331) does not. Replace "historical reconciliation" with "app startup" and note the rebuild still starts from the reset seed.Merge readiness (this axis): Not ready until N1 (one-line JS change) is fixed; N2 goes with the description fix.
Summary: Standards: 1 P2 (form's offset baseline goes stale after a save) + 5 P3. Spec: the same P2 found independently + 1 P3 (flows that rebuild k named imprecisely in the description).
🤖 Generated with Claude Code
Pass-2 blockers fixed; remaining P3s filed as #119. Combined master + #115 + #116: pytest 351 passed, 1 skipped; frontend 80/80. Merging.
🤖 Generated with Claude Code