LLD: AI Audit Freeze — Frontend/Backend Alignment
1. Problem Statement
1.1 Symptom
After the user makes changes in the AI Audit flow and clicks Freeze, the frozen loss% saved by the backend sometimes differs from what the frontend displayed just before freezing.
1.2 Root cause
The frontend and backend use different consumption values for the same installation when computing loss%.
Each ai-audit-installation doc has two consumption fields:
consumption— actual meter reading for the monthavgConsumption6Months— 6-month average (used when actual is unreliable due to a remark)
Backend uses avgConsumption6Months for remark installations (where aiSuggestsConsumption === true) when summing installationConsumption. Frontend, receiving only consumption from fetchAiAuditInstallations (avg and remark flags are excluded by the projection), uses consumption for its local math. Divergence follows.
1.3 Scenarios
Scenario A — tagging change (fixed in Option X) User toggles an installation's tagging in the Installation Tagging screen:
- Frontend:
displayed_ic = computed.installationConsumption ± actual consumption of toggled installation - Backend (pre-Option X): recomputes from scratch, using avg for remark installations → mismatch
- Backend (Option X, shipped): applies override deltas using actual consumption → match ✓
Scenario B — "Add consumption" toggle (NOT FIXED) User keeps an installation tagged but unchecks "Add consumption" in the Remarks screen (intent: use actual, not avg, for this installation):
- Frontend: updates displayed loss% by replacing that installation's avg contribution with actual
- Backend (Option X): since
wasInComputed === isInFinal === true, no delta applied. Loss% computed as if toggle was never changed → mismatch - Scenario B was never covered by Option X
1.4 Why not expand Option X?
Option X deliberately decouples frontend math from backend semantics. Every additional scenario that works frontend-first pushes the backend further from treating avgConsumption6Months as intended. Time to align on a single shared model instead.
2. Goals & Non-Goals
Goals
- Frontend and backend produce the same loss% for every combination of (tagging change × remark consumption toggle).
- Backend remains the source of truth for the formula; frontend just sums pre-computed per-installation values.
- Remove the Option X workaround from the freeze handler.
- Keep
avgConsumption6Monthsas a real, usable field (remark installations get their avg-based contribution by default, overridable by the user).
Non-Goals
- Changing the AI audit run API's
computed/computedTagging/computedRemarksshape (those stay). - Backward compatibility for old
ai-audit-installationdocs — these are short-lived (per-AI-audit-run), so we don't need a backfill migration. - Changing the overall freeze flow (source TC reaudit, audit summary cascade, etc.) — only the target TC's installationConsumption + lossPercentage math changes.
3. New Contract
3.1 The effectiveConsumption field
Each ai-audit-installation doc will carry a new field effectiveConsumption computed at AI audit run time:
effectiveConsumption =
(hasRemark && aiSuggestsConsumption && avgConsumption6Months > 0)
? avgConsumption6Months
: (consumption || 0)
This is the default contribution the installation makes to installationConsumption when it's tagged. The user can override this default on a per-installation basis via the freeze overrides[].useAvgConsumption flag.
3.2 Where it's exposed
GET /audit/transformers/:id/ai-audit/installations→ addeffectiveConsumptionto the responseGET /audit/transformers/:id/ai-audit/remarks→ addeffectiveConsumptionto the responsePOST /audit/transformers/:id/freeze→ freeze handler useseffectiveConsumptionwhen summing the final tagged set
3.3 Frontend math (after fix)
The frontend does three operations for its displayed loss%:
Base: sum of effectiveConsumption across all currently-tagged installations.
On tagging change (user toggles TAG/UNTAG for installation i):
displayed_ic ± i.effectiveConsumption
On "Add consumption" toggle (user unchecks avg for remark installation i):
displayed_ic -= i.effectiveConsumption // remove the avg contribution
displayed_ic += (i.consumption || 0) // add back the actual
(and reverse when re-checking)
Loss%:
lossPercentage = ((effectiveTcConsumption - displayed_ic) / effectiveTcConsumption) * 100
3.4 Freeze override semantics
The overrides[] body in freeze carries the user's deviations from the AI's suggestions:
action: "TAG" | "UNTAG"— tagging decisionuseAvgConsumption?: boolean— optional. If omitted, backend usesaiSuggestsConsumption's default. Iffalse, backend usesconsumption(actual) for that installation's contribution even if it has a remark.
4. Schema Changes
4.1 common/src/schemas/aiAuditInstallation.model.js
Add one field to the schema:
effectiveConsumption: {
type: Number,
default: 0,
},
No index. Storage overhead is negligible (one number per staging doc; staging docs are deleted on each AI audit re-run).
4.2 No changes to aiAuditResult.model.js
The three computed* variants on ai-audit-result remain as-is. They're used by the frontend for initial display and by the freeze handler for source-TC impact tracking — neither use case is affected.
4.3 No changes to transformer.model.js or installation.model.js
The real transformer and installation documents remain untouched. This is a staging-layer change only.
5. Backend Changes
5.1 AI Audit Run (aiAuditTC.js)
In buildInstallationDoc(), compute and store effectiveConsumption:
const effectiveConsumption =
hasRemark && aiSuggestsConsumption && avg > 0
? avg
: (inst.consumption || 0);
return {
...existing fields,
effectiveConsumption,
...
};
No other changes to the AI audit run handler.
5.2 Fetch Installations (fetchAiAuditInstallations.js)
- Remove
effectiveConsumptionfrom the negative projection (or keep the current projection and addeffectiveConsumption: 1on the positive side — either works) - Add
effectiveConsumption: { type: ["number", "null"] }to the response serialization schema
5.3 Fetch Remarks (fetchAiAuditRemarks.js)
Same as 5.2 — include effectiveConsumption in the response.
5.4 Freeze (freezeAiAudit.js) — revert Option X + new logic
Replace the current installationConsumption computation (the Option X delta-based code) with the cleaner reduce over finalTaggedSet, but use effectiveConsumption:
function getConsumptionForFreeze(inst, override) {
// If user explicitly opted out of avg, use actual
if (override && override.useAvgConsumption === false) {
return inst.consumption || 0;
}
// Otherwise use the pre-computed effectiveConsumption
return inst.effectiveConsumption || 0;
}
const installationConsumption = finalTaggedSet.reduce(
(sum, inst) => sum + getConsumptionForFreeze(inst, overrideMap.get(inst.accountId)),
0,
);
The dead getEffectiveConsumption helper can be deleted. Option X logic (the delta math) is removed.
5.5 Everything else in freeze stays the same
finalTaggedSetconstruction (unchanged)billingCount,installationCount,revenueParams,totalInstallationSanctionedLoadInKVA,overloaded— unchanged- Source TC reaudits — unchanged (they use
computeAuditFields.jswhich operates on real installation docs) - Audit summary cascade — unchanged
- Adjacent month loss% propagation — unchanged
6. API Response Changes
6.1 GET /ai-audit/transformers/:id/ai-audit/installations
Before:
{
"accountId": "0001048090",
"consumption": 62,
"distanceFromTC": 120.5,
"currentTaggingStatus": "TAGGED",
"aiSuggestion": "NO_CHANGE",
...
}
After:
{
"accountId": "0001048090",
"consumption": 62,
"effectiveConsumption": 84,
"distanceFromTC": 120.5,
"currentTaggingStatus": "TAGGED",
"aiSuggestion": "NO_CHANGE",
...
}
6.2 GET /ai-audit/transformers/:id/ai-audit/remarks
Before:
{
"accountId": "0001048090",
"remarkType": "ABNORMAL",
"consumption": 62,
"avgConsumption6Months": 84,
"aiSuggestsConsumption": true,
...
}
After:
{
"accountId": "0001048090",
"remarkType": "ABNORMAL",
"consumption": 62,
"avgConsumption6Months": 84,
"aiSuggestsConsumption": true,
"effectiveConsumption": 84,
...
}
6.3 POST /audit/transformers/:id/freeze
No request/response shape changes. Only internal computation changes.
7. Frontend Contract
7.1 Mental model — currentContribution per installation
Track per-installation UI state. The two numbers that drive loss% are:
i.tagged— boolean, true if currently in the tagged seti.currentContribution— number, the valueiadds to the displayedinstallationConsumption
currentContribution is initialized from effectiveConsumption and only mutates in response to the "Add consumption" checkbox. Tagging changes don't mutate it — they just include or exclude i from the sum.
// On initial load, for every installation i (from either fetch endpoint):
i.currentContribution = i.effectiveConsumption;
Displayed installationConsumption is always derived freshly:
installationConsumption = installations
.filter(i => i.tagged)
.reduce((sum, i) => sum + i.currentContribution, 0);
No "subtract X then add Y" deltas. Just compute the sum from current state on every change. Easier to reason about and impossible to drift.
7.2 Cross-screen data merging
The two endpoints expose different field subsets. Frontend must reconcile them by accountId:
| Field | /ai-audit/installations (Tagging) | /ai-audit/remarks (Remarks) |
|---|---|---|
accountId | ✅ | ✅ |
consumption | ✅ | ✅ |
effectiveConsumption | ✅ | ✅ |
currentTaggingStatus, aiSuggestion | ✅ | ✅ |
hasRemark, aiSuggestsConsumption, avgConsumption6Months, remarkType | ❌ | ✅ |
distanceFromTC, withinRange, sourceTcId, sourceTcNumber | ✅ | ❌ |
Implication: the Tagging screen alone doesn't know which installations have remarks (the "Add consumption" checkbox lives on the Remarks screen, so this is fine for rendering). But for the loss% sum to be consistent across both screens, both screens read from the same shared installations store keyed by accountId. Fetch both endpoints, merge by accountId, treat the merged record as the source of truth for that installation everywhere.
7.3 State transitions
Four user actions can change displayed loss%:
(a) User toggles tagging — TAG → UNTAG for installation i (Tagging screen):
i.tagged = false;
// installationConsumption recomputes naturally — i drops out of the sum
(b) User toggles tagging — UNTAG → TAG for installation i:
i.tagged = true;
// installationConsumption recomputes naturally — i enters the sum at i.currentContribution
(c) User unchecks "Add consumption" for remark installation i (Remarks screen, i.tagged unchanged):
i.currentContribution = i.consumption || 0; // swap avg → actual
i.useAvgOverride = false; // remember to send in freeze payload
(d) User re-checks "Add consumption" (back to AI's suggestion):
i.currentContribution = i.effectiveConsumption;
i.useAvgOverride = undefined; // override cleared
After any of (a)-(d), recompute installationConsumption from the sum and rerender. Loss% derives from the new sum.
7.4 Loss% derivation
const effectiveTc = tcConsumption * meterConstant;
// tcConsumption and meterConstant come from the AI audit run response
// (top-level data.tcConsumption / data.meterConstant)
const lossPercentage = effectiveTc > 0
? Math.round(((effectiveTc - installationConsumption) / effectiveTc) * 10000) / 100
: null;
7.5 Freeze payload
On freeze click, build overrides[] containing only installations where the user deviated from the AI's defaults:
const overrides = installations
.filter(i => {
const taggedByAI = (i.aiSuggestion === "TAG") ||
(i.aiSuggestion === "NO_CHANGE" && i.currentTaggingStatus === "TAGGED");
const tagDeviated = (i.tagged !== taggedByAI);
const avgDeviated = (i.useAvgOverride !== undefined &&
i.useAvgOverride !== i.aiSuggestsConsumption);
return tagDeviated || avgDeviated;
})
.map(i => ({
accountId: i.accountId,
action: i.tagged ? "TAG" : "UNTAG",
...(i.useAvgOverride !== undefined && { useAvgConsumption: i.useAvgOverride }),
}));
// POST /audit/transformers/:id/freeze?month=YYYY-MM
// body: { overrides }
Example payload:
{
"overrides": [
{ "accountId": "0001786227", "action": "UNTAG" },
{ "accountId": "0002504672", "action": "TAG", "useAvgConsumption": false }
]
}
Rules:
actionis required (always "TAG" or "UNTAG"; never include "NO_CHANGE")useAvgConsumptionis optional; include it only when the user toggled the avg checkbox away from AI's default- Skip installations where the user accepted everything as-is — backend uses AI's defaults for those
7.6 Worked walkthrough
TC has 3 installations:
| Installation | consumption | avgConsumption6Months | hasRemark | aiSuggestsConsumption | effectiveConsumption | aiSuggestion | currentTaggingStatus |
|---|---|---|---|---|---|---|---|
| A | 1000 | — | false | false | 1000 | NO_CHANGE | TAGGED |
| B | 200 | 500 | true | true | 500 | NO_CHANGE | TAGGED |
| C | 100 | 800 | true | true | 800 | NO_CHANGE | TAGGED |
tcConsumption = 3000, meterConstant = 1, so effectiveTc = 3000.
State 0 — initial load (after AI audit run, no user changes yet):
A.tagged=true, A.currentContribution=1000
B.tagged=true, B.currentContribution=500
C.tagged=true, C.currentContribution=800
installationConsumption = 1000 + 500 + 800 = 2300
loss% = (3000 - 2300) / 3000 * 100 = 23.33%
State 1 — user unchecks "Add consumption" on B:
B.currentContribution = 200 (actual)
B.useAvgOverride = false
installationConsumption = 1000 + 200 + 800 = 2000
loss% = (3000 - 2000) / 3000 * 100 = 33.33%
State 2 — user then untags C:
C.tagged = false
installationConsumption = 1000 + 200 = 1200
loss% = (3000 - 1200) / 3000 * 100 = 60%
State 3 — user clicks Freeze. Payload:
{
"overrides": [
{ "accountId": "B", "action": "TAG", "useAvgConsumption": false },
{ "accountId": "C", "action": "UNTAG" }
]
}
A is not in overrides because nothing deviated from AI's defaults for A.
Backend freeze does the equivalent sum:
- A: tagged, no override → use
effectiveConsumption(1000) - B: tagged, override
useAvgConsumption=false→ useconsumption(200) - C: untagged → excluded
- Sum = 1200, loss% = 60% — matches frontend exactly.
8. Implementation Order
Small, independently shippable PRs:
-
PR 1 — Schema + AI audit run populates
effectiveConsumption- Add field to
aiAuditInstallation.model.js - Compute and store in
aiAuditTC.js→buildInstallationDoc - No consumer changes yet → backward-compatible
- Verify: run AI audit on a test TC, confirm
effectiveConsumptionpopulated
- Add field to
-
PR 2 — Expose
effectiveConsumptionin fetch endpoints- Update projections and response serializers in
fetchAiAuditInstallations.jsandfetchAiAuditRemarks.js - Also additive — backend-only, frontend can start consuming when ready
- Update projections and response serializers in
-
Frontend PR (parallel to PR 2) — frontend team's work
- Switch to
effectiveConsumptionfor all local loss% math - Implement the state transitions in Section 7.2
- Send
useAvgConsumptionin overrides where applicable
- Switch to
-
PR 3 — Revert Option X in freeze, use
effectiveConsumption- Replace installationConsumption delta logic with the new reduce over
finalTaggedSet - Add useAvgConsumption handling
- Delete the dead
getEffectiveConsumptionhelper - Ship only AFTER frontend has switched (to avoid reintroducing the original mismatch)
- Replace installationConsumption delta logic with the new reduce over
-
Cleanup — delete
fix/freeze-match-frontend-deltasbranch, updateLLD-ai-audit-freeze.mdto reference this alignment doc
9. Migration Considerations
9.1 Existing ai-audit-installation docs
These are staging docs — every AI audit run deletes and recreates them (see aiAuditTC.js step 2, "Clean up existing unfrozen result"). Frozen ai-audit-results can't be re-frozen, so docs with missing effectiveConsumption are harmless.
Handling: in fetch endpoints, fall back for missing field:
effectiveConsumption: inst.effectiveConsumption ?? inst.consumption ?? 0
This gives pre-fix ai-audit-installation docs a sensible default without a backfill. Frontend sees a consistent field either way.
9.2 In-flight AI audits at deploy time
Possible race: AI audit ran just before deploy (no effectiveConsumption field), user freezes after deploy. Freeze handler with the effectiveConsumption reduce would see undefined → || 0 → under-count installationConsumption.
Mitigation: in the freeze reduce, same fallback:
return inst.effectiveConsumption ?? inst.consumption ?? 0;
10. Testing Matrix
Run against a TC with at least: 2 setA remark installations, 1 setA non-remark installation, 1 setB installation, 1 setC installation.
| # | User action | Expected frontend | Expected backend (freeze) | Match? |
|---|---|---|---|---|
| 1 | No overrides | computed.lossPercentage | Same as frontend | ✓ |
| 2 | UNTAG one non-remark setA | base − actual | Same | ✓ |
| 3 | UNTAG one remark setA | base − effectiveConsumption (avg) | Same | ✓ |
| 4 | TAG one setB (keep it) | base + effectiveConsumption of that inst | Same | ✓ |
| 5 | UNTAG one setC | base − effectiveConsumption | Same | ✓ |
| 6 | Uncheck avg on a remark setA (keep tagged) | base − avg + actual | Same | ✓ |
| 7 | Combo: UNTAG one + uncheck avg on another | sum of deltas in cases 2 + 6 | Same | ✓ |
| 8 | Pre-fix staging data (no effectiveConsumption) | fallback to consumption | Same (fallback) | ✓ |
Verify on Dev for all 8; spot-check on STG for #1 + #3 + #6 + #7.
11. Rollback Plan
Each PR is independently revertable:
- PR 1 (schema): revertable — the field just stops being populated on new AI audit runs. Fetch endpoints won't return it either (not selected in projection yet).
- PR 2 (fetch): revertable — frontend that expects the field falls back to
consumption(coordinate with frontend). - PR 3 (freeze): revertable — previous version with Option X is in git history on
fix/freeze-match-frontend-deltas.
No data migration to unwind. effectiveConsumption values on existing ai-audit-installation docs are harmless if unused.
12. Related Files
Modified:
common/src/schemas/aiAuditInstallation.model.jsapi/src/entities/audit/controller/aiAuditTC.jsapi/src/entities/audit/controller/fetchAiAuditInstallations.jsapi/src/entities/audit/controller/fetchAiAuditRemarks.jsapi/src/entities/audit/controller/freezeAiAudit.js
Read-only references:
api/src/entities/audit/helpers/deriveRemarkType.jscommon/src/schemas/aiAuditResult.model.js
Related LLDs:
docs/api/LLD-ai-audit-run.mddocs/api/LLD-ai-audit-fetch.mddocs/api/LLD-ai-audit-freeze.md(will be updated to point here after PR 3)