Skip to main content

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 month
  • avgConsumption6Months — 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

  1. Frontend and backend produce the same loss% for every combination of (tagging change × remark consumption toggle).
  2. Backend remains the source of truth for the formula; frontend just sums pre-computed per-installation values.
  3. Remove the Option X workaround from the freeze handler.
  4. Keep avgConsumption6Months as 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 / computedRemarks shape (those stay).
  • Backward compatibility for old ai-audit-installation docs — 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 → add effectiveConsumption to the response
  • GET /audit/transformers/:id/ai-audit/remarks → add effectiveConsumption to the response
  • POST /audit/transformers/:id/freeze → freeze handler uses effectiveConsumption when 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 decision
  • useAvgConsumption?: boolean — optional. If omitted, backend uses aiSuggestsConsumption's default. If false, backend uses consumption (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 effectiveConsumption from the negative projection (or keep the current projection and add effectiveConsumption: 1 on 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

  • finalTaggedSet construction (unchanged)
  • billingCount, installationCount, revenueParams, totalInstallationSanctionedLoadInKVA, overloaded — unchanged
  • Source TC reaudits — unchanged (they use computeAuditFields.js which 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 set
  • i.currentContribution — number, the value i adds to the displayed installationConsumption

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:

  • action is required (always "TAG" or "UNTAG"; never include "NO_CHANGE")
  • useAvgConsumption is 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:

InstallationconsumptionavgConsumption6MonthshasRemarkaiSuggestsConsumptioneffectiveConsumptionaiSuggestioncurrentTaggingStatus
A1000falsefalse1000NO_CHANGETAGGED
B200500truetrue500NO_CHANGETAGGED
C100800truetrue800NO_CHANGETAGGED

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 → use consumption (200)
  • C: untagged → excluded
  • Sum = 1200, loss% = 60% — matches frontend exactly.

8. Implementation Order

Small, independently shippable PRs:

  1. PR 1 — Schema + AI audit run populates effectiveConsumption

    • Add field to aiAuditInstallation.model.js
    • Compute and store in aiAuditTC.jsbuildInstallationDoc
    • No consumer changes yet → backward-compatible
    • Verify: run AI audit on a test TC, confirm effectiveConsumption populated
  2. PR 2 — Expose effectiveConsumption in fetch endpoints

    • Update projections and response serializers in fetchAiAuditInstallations.js and fetchAiAuditRemarks.js
    • Also additive — backend-only, frontend can start consuming when ready
  3. Frontend PR (parallel to PR 2) — frontend team's work

    • Switch to effectiveConsumption for all local loss% math
    • Implement the state transitions in Section 7.2
    • Send useAvgConsumption in overrides where applicable
  4. 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 getEffectiveConsumption helper
    • Ship only AFTER frontend has switched (to avoid reintroducing the original mismatch)
  5. Cleanup — delete fix/freeze-match-frontend-deltas branch, update LLD-ai-audit-freeze.md to 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 actionExpected frontendExpected backend (freeze)Match?
1No overridescomputed.lossPercentageSame as frontend
2UNTAG one non-remark setAbase − actualSame
3UNTAG one remark setAbase − effectiveConsumption (avg)Same
4TAG one setB (keep it)base + effectiveConsumption of that instSame
5UNTAG one setCbase − effectiveConsumptionSame
6Uncheck avg on a remark setA (keep tagged)base − avg + actualSame
7Combo: UNTAG one + uncheck avg on anothersum of deltas in cases 2 + 6Same
8Pre-fix staging data (no effectiveConsumption)fallback to consumptionSame (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.


Modified:

  • common/src/schemas/aiAuditInstallation.model.js
  • api/src/entities/audit/controller/aiAuditTC.js
  • api/src/entities/audit/controller/fetchAiAuditInstallations.js
  • api/src/entities/audit/controller/fetchAiAuditRemarks.js
  • api/src/entities/audit/controller/freezeAiAudit.js

Read-only references:

  • api/src/entities/audit/helpers/deriveRemarkType.js
  • common/src/schemas/aiAuditResult.model.js

Related LLDs:

  • docs/api/LLD-ai-audit-run.md
  • docs/api/LLD-ai-audit-fetch.md
  • docs/api/LLD-ai-audit-freeze.md (will be updated to point here after PR 3)