feat/peer-price
needs attentionviewing older commitffd77be · fullPR #345reviewed 2026-07-23 21:18 UTC2H · 5M · 6L · 5I- Purpose
- Completes the $-layer (dollar-impact pricing) for the three optimization gap-detectors: D1 capacity saving (punta_management), D2b energy indicator (overnight_load_factor), and peer_benchmark_consumption — the star detector that compares a contract's year-over-year daily kWh change vs a like-for-like regional/tariff cohort.
- Goal
- peer_benchmark_consumption now carries estimatedImpact = excess kWh/day × 365 × marginalEnergyRate (COALESCE(genCost,genBCost)). Introduces a new ConsumptionSeries entity (pure calendar-allocated monthly kWh series from CFE bills) that supersedes the naive BillFactRow projection for all consumption detectors.
- Sub-goals
- ConsumptionSeries entity: pure day-ownership allocation of CFE bills into calendar months (measured > adjustment > estimated priority, handles bimonthly/long-catch-up bills correctly)
- peer_benchmark detector @2: priced excess over cohort-median YoY at marginal energy rate; null when rate doesn't resolve (ranking-only unchanged)
- detectConsumptionYoySpike bumped to @2 consuming ConsumptionSeries instead of raw BillFactRow
- fetchContractEnergyRatesForOrg: org-scoped query resolving COALESCE(NULLIF(genCost,0), genBCost) from latest tariff_rates row per contract
- What
- New ConsumptionSeries FCIS module (type, decisions, errors, queries, tests). finding.decisions.ts: new detectPeerBenchmark with $-layer, groupSeriesByContractDesc, percentile helper. finding.queries.ts: new fetchContractEnergyRatesForOrg, fetchContractRegionsForOrg (pre-existing from prior commits). finding.shells.ts: 3 new detector calls, 2 new fetches (consumptionSeries + energyRates). Spec docs updated.
- Why
- peer_benchmark was ranking-only (estimatedImpact: null) since its initial landing. Using averageFee (bill÷kWh) was explicitly rejected in prod QA — it was 3-4× the real energy rate because it folds in demand+fixed charges. marginalEnergyRate (genCost/genBCost) is the correct price for marginal consumption.
- Areas
- domains/utility/src/consumption-series+862−0domains/utility/src/finding+2170−371docs/energia+313−0domains/utility/src/index.ts+1−0
- Blast
- 14 files, +3162/-374. Confined to domains/utility — no API contract changes, no DB schema migrations, no CDK/Lambda changes. Detector version bumps (consumption_yoy_spike@1→@2, peer_benchmark@1→@2) will re-evaluate existing findings on next sweep run.
Findings · 17
correctness1
Invalid-period contract suppression logged at info — should be warn
domains/utility/src/finding/finding.shells.ts:107
A contract silently excluded from all consumption-based detectors is a data gap worth surfacing above info level. The query pre-filter makes this path rare but not impossible (unparseable date string). Logging at warn would make it visible in alerting dashboards.
security1
JSONB ::numeric cast raises error on malformed data (not NULL)
domains/utility/src/finding/finding.queries.ts
In fetchContractEnergyRatesForOrg, `(rz.rate_components->>'genCost')::numeric` will raise a PostgreSQL error (not return NULL) if the JSONB value is a non-numeric string. Low risk since tariff_rates is Batu-controlled catalog data, but a malformed entry would abort the entire sweep for that org. A CASE WHEN ... ~ '^[0-9.]+$' guard or try/catch at the query level would be more resilient.
conventions3
`buildConsumptionSeries` uses `build` prefix instead of canonical `decide`
domains/utility/src/consumption-series/consumption-series.decisions.ts:128
canonical-form.md prescribes `decide{Op}` for all decision-entry-point functions. Every other entity in the domain follows this (decideCreateFinding, decideUpdateContract). `build` is used for adapter/utility helpers elsewhere. This is the primary decision function (fallible Result<T,E> return), so it should be `decideConsumptionMonths` or similar.
Shell skip-and-continue on buildConsumptionSeries error is undocumented policy
domains/utility/src/finding/finding.shells.ts:106
When buildConsumptionSeries returns Err, the shell logs at info and silently skips the contract. The SQL pre-filter makes inverted-period bills unreachable in normal state, but an unparseable date string still hits this path. A comment explaining WHY skip-and-continue is the right policy (partial-failure tolerance for org-level aggregation) and surfacing the skipped count in DetectFindingsResult would make this intentional.
DETECTOR_VERSIONS belongs in finding.decisions.ts (business rule), not finding.shells.ts
domains/utility/src/finding/finding.shells.ts:65
Which version string is canonical for each detector type is a business rule, not shell orchestration. Moving the map to finding.decisions.ts would make it testable in pure unit tests without shell setup and align with canonical-form.md.
tests6
Overlap hole 2 is unimplemented and untested
domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts
The spec and test file name three overlap holes (hole 1: partial adjustment coverage, hole 3: measured beats adjustment). Hole 2 is completely absent — no describe block, no test, no comment. The production allocator may have a blind spot for that scenario.
detectPeerBenchmark warning severity path (30–50 pp) is completely untested
domains/utility/src/finding/__tests__/finding.detectors.test.ts
The only positive-fire test uses +55 pp and asserts 'critical'. There is a no-fire test at ≤30 pp. No test covers the 30–50 pp 'warning' band. A regression that promotes warning→critical or demotes critical→warning would not be caught.
DETECTOR_VERSIONS version strings for @2 detectors are untested
domains/utility/src/finding/finding.shells.ts:69
consumption_yoy_spike@2 and peer_benchmark_consumption@2 are the idempotency keys for DB upserts. No test asserts these exact strings. A silent drift back to @1 (merge conflict, copy-paste) would cause old findings to be overwritten rather than versioned, with no test failure.
Mixed adjustment+measured days: coverage.source classification untested
domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts
When a month's days are owned by a mix of adjustment and measured bills, anyAdjustment=true causes source='adjustment' even if most days are measured. No test exercises this scenario. The hole-3 test covers only full-month dominance, not partial mixing.
Separate-cohort test only verifies no-fire; cohort isolation is not proven bidirectionally
domains/utility/src/finding/__tests__/finding.detectors.test.ts
The 'does not mix cohorts' test asserts toHaveLength(0) when two tariff groups each lack a full cohort. It does not verify that a valid outlier within a single-tariff cohort fires, nor that the two groups' YoY arrays are independent. It proves the false-positive is avoided, not that the isolation mechanism is correct.
No test for February 29 (leap year) period boundary in buildConsumptionSeries
domains/utility/src/consumption-series/__tests__/consumption-series.decisions.test.ts
All February tests use 2026-02-28. No test covers a period spanning Feb 29 (e.g. 2024-01-01 to 2024-02-29). The UTC arithmetic is likely correct but unguarded.
improvement5
toNumberOrNull duplicated verbatim in finding.queries.ts and consumption-series.queries.ts
domains/utility/src/consumption-series/consumption-series.queries.ts
Extract to domains/utility/src/lib/ (alongside ulid.ts) to avoid silent divergence on bug fixes.
groupSeriesByContractDesc and groupByRpuDesc are structurally identical — extract generic helper
domains/utility/src/finding/finding.decisions.ts
Both group items into a Map by a string key and sort each group descending by a date-string field. A single generic groupAndSortDesc<T>(items, getKey, getSort) would unify them.
org_scoped_sites / org_contract_ids CTE pair copy-pasted into every raw-SQL query
domains/utility/src/finding/finding.queries.ts
Four queries in finding.queries.ts and one in consumption-series.queries.ts share the identical two-CTE preamble. A Drizzle orgContractIdsSubquery(orgId) helper would centralise the security-critical org-scope logic. Low priority but worth a cleanup ticket.
ContractRate and ContractEnergyRate are structurally parallel — could unify into one query
domains/utility/src/finding/finding.decisions.ts
Both are per-contract rate bags fetched by structurally identical queries against tariff_rates. A single ContractRates { contractId, marginalEnergyRateMxnPerKwh?, capacityCostMxnPerKw?, baseCostMxnPerKwh? } and one merged query would save a round-trip. Deferrable.
percentile helper should name the interpolation method (R-7) and declare input contract
domains/utility/src/finding/finding.decisions.ts:563
The existing comment says 'linear-interpolation' but doesn't name the method (R-7 / NumPy default). A brief JSDoc '@param sorted must be sorted ascending, p in [0,1]' would prevent future misuse.
correctness/conventions/improvement1
Shell runs 6 independent DB fetches sequentially — all three lenses flagged this
domains/utility/src/finding/finding.shells.ts:164
fetchBillFactRowsForOrg, fetchOrgConsumptionSeries, fetchContractRegionsForOrg, fetchPerPeriodDemandForOrg, fetchContractRatesForOrg, fetchContractEnergyRatesForOrg are all independent reads (orgId only). Running them serially multiplies latency by ~6×. Promise.all([...]) would reduce the fetch phase to max(individual) with no correctness trade-off — the 'no transaction across reads' header comment is fully compatible with Promise.all.