feat(forecast): deterministic quantile path so dscr_p10 stops reading seeded rows

dscr_p10 is the feature TW-01 fires on, and so the origin of nearly every
alert an officer sees — but nothing had written forecast_point since the
seeder. A headline number computed from demo data is worse than no
headline number, because it looks identical to a real one.

ForecastStrategy is a seam shaped like ScoringStrategy: the LightGBM
quantile heads implement it later and the swap is one property. The
forecast and the score answer different questions and fail differently,
so they get separate seams.

The method is fully explainable. Baseline is the median whole-month net
position from the ledger, median so one festival month does not become
the expectation, and the partial current month is discarded. Drift comes
from margin_squeeze, else feed_cost_change, capped so one sharp month
does not extrapolate into a cliff. Spread widens with the square root of
the horizon and again for every input we did not have.

Missing inputs widen the band, never lower the centre. A pessimistic P50
built on ignorance would manufacture breaches.

An enterprise with too little ledger gets no path rather than a fabricated
one, so dscr_p10 resolves NO_FORECAST visibly.

forecast_point gains engine/model_version. Seeded rows carry neither, and
udyam.forecast.trust-seeded decides whether dscr_p10 may fall back to
them. Left true so the demo portfolio stays populated; with it false the
picture is honest — 6 enterprises forecast, 136 NO_FORECAST, and
trip-wires firing drops from 34 to 5.

The forecast reads margin features and dscr_p10 reads the forecast, so
features are computed twice per enterprise rather than letting either half
read a previous run's answer.

Seeder now writes 150 days of ledger, not 31: the baseline needs whole
months and one month leaves nothing to take a median of. Ledger joins
loans in the backfill pass, keyed on clientId.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-06 14:53:32 +05:30
co-authored by Claude Opus 5
parent 5401496a64
commit d710176f36
7 changed files with 399 additions and 13 deletions
@@ -49,6 +49,15 @@ public class UdyamDataSeeder implements CommandLineRunner {
public static final String DEFAULT_ENTERPRISE_PIN = "1234";
private static final int TOTAL = 142;
/**
* Days of ledger history per demo enterprise. Five months rather than one,
* because the deterministic forecast takes its baseline from the median of
* whole calendar months and discards the partial current one — a single month
* of entries leaves nothing to take a median of, and every enterprise reports
* NO_FORECAST.
*/
private static final int LEDGER_DAYS = 150;
private static final Map<String, Integer> TARGET = Map.of("RED", 9, "WATCH", 31, "STANDARD", 102);
private static final String MODEL_VERSION = "rule-v0.9.3";
@@ -420,11 +429,20 @@ public class UdyamDataSeeder implements CommandLineRunner {
* per-enterprise guard, so this is safe on every boot.
*/
private void backfillPortfolio() {
long before = loanRecordRepository.count();
enterpriseRepository.findAll().forEach(this::seedLoans);
long added = loanRecordRepository.count() - before;
if (added > 0) {
log.info("Backfilled {} loan records onto an already-seeded portfolio", added);
long loansBefore = loanRecordRepository.count();
long ledgerBefore = ledgerRepository.count();
List<Enterprise> all = enterpriseRepository.findAll();
all.forEach(this::seedLoans);
all.forEach(this::seedLedger);
long loansAdded = loanRecordRepository.count() - loansBefore;
long ledgerAdded = ledgerRepository.count() - ledgerBefore;
if (loansAdded > 0) {
log.info("Backfilled {} loan records onto an already-seeded portfolio", loansAdded);
}
if (ledgerAdded > 0) {
log.info("Backfilled {} ledger entries onto an already-seeded portfolio", ledgerAdded);
}
}
@@ -637,7 +655,7 @@ public class UdyamDataSeeder implements CommandLineRunner {
String[] outCats = {"INPUT", "WAGES", "TRANSPORT", "EMI"};
List<LedgerEntry> entries = new ArrayList<>();
for (int d = 30; d >= 0; d--) {
for (int d = LEDGER_DAYS; d >= 0; d--) {
int count = 1 + r.nextInt(3);
for (int i = 0; i < count; i++) {
boolean in = r.nextDouble() > 0.45;
@@ -652,7 +670,13 @@ public class UdyamDataSeeder implements CommandLineRunner {
.build());
}
}
ledgerRepository.saveAll(entries);
// clientId is generated on the phone and is the identity an offline entry
// keeps through sync, so it is also the right idempotence key here: a
// re-run extends the history backwards without duplicating a single day.
List<LedgerEntry> fresh = entries.stream()
.filter(entry -> ledgerRepository.findByClientId(entry.getClientId()).isEmpty())
.toList();
if (!fresh.isEmpty()) ledgerRepository.saveAll(fresh);
}
// ── helpers ─────────────────────────────────────────────