← all branches

feat/peer-price

needs attentionviewing older commit
ffd77be · fullPR #345reviewed 2026-07-23 21:18 UTC2H · 5M · 6L · 5I
The branch
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
The changes (whole branch)
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+8620domains/utility/src/finding+2170371docs/energia+3130domains/utility/src/index.ts+10
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.
stacked on #344 — retarget to main once stack lands no merge ahead of stack
CI checks· No CI checks found for PR 345 — self-hosted runner not yet wired (vars.LOOP_REVIEW_ENABLED gate)CodeRabbit· No .coderabbit.yaml present

Findings · 17

correctness1

low

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

low

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

medium

`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.

low

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.

low

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

high

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.

high

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.

medium

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.

medium

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.

medium

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.

low

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

info

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.

info

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.

info

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.

info

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.

info

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

medium

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.

History · 2 commits

  1. d44d0c5safeincremental0H · 0M · 1L2026-07-23 21:28
  2. ffd77beneeds attentionfull2H · 5M · 6L2026-07-23 21:18current