feat(seed): a national portfolio, band-coherent ledger, and a forecast worth trusting

PortfolioPlan makes the demo book explicit — 263 enterprises across six states,
one per NABARD zone, over eight partner banks and sixteen branches — and
PortfolioPurge is the destructive escape hatch for the one boot where that shape
changes. The seeder never rewrites an existing enterprise, so a plan change
without a purge leaves orphans rather than corrupting rows.

udyam.forecast.trust-seeded now defaults to false. It was true only because
ledger coverage reached six enterprises of 142, so without the fallback the
console had almost no forecasts and showed 34 trip-wires of which 5 were real.
The seeder now writes five months of ledger for every enterprise, band-coherent
— a RED enterprise's monthly net genuinely sits below its instalment — so the
computed forecast is the one worth showing and the fallback is no longer
load-bearing.

jwt.secret becomes UDYAM_JWT_SECRET-overridable. The default stays committed so
a fresh clone boots without setup, which is precisely why it is not a secret:
any deployment holding real borrower data must set it.

Two traps this run surfaced, both silent:

  - dealBands() now deals round-robin across sectors. Appending HORTICULTURE to
    the sector list put it last and the fixed-seed band shuffle was drained by
    the five packs ahead of it, so all 19 seeded STANDARD.
  - Anything added to the seeder after a database was first populated never
    reaches it, because of the count() > 0 guards. backfillPortfolio() now
    covers loans and ledger and FeatureCatalog.seed() reconciles tolerances, but
    trip-wire rules and sector weights still carry the hazard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-15 15:29:00 +05:30
co-authored by Claude Opus 5
parent 7972bfe32d
commit 2cb7d3b14a
14 changed files with 1232 additions and 146 deletions
@@ -43,6 +43,17 @@ public class FeatureCatalog {
public static final String UPI_INFLOW_CHANGE = "upi_inflow_change";
public static final String FEED_STALENESS_DAYS = "feed_staleness_days";
public static final String DSCR_P10 = "dscr_p10";
/**
* The same ratio at the central estimate.
*
* <p>Exists because P10 and P50 are different claims and were being treated
* as one. A P10 breach says the downside case misses; a P50 breach says the
* expectation misses. Firing a single red-severity rule on the first collapsed
* the watch band — every enterprise whose downside was short became red, and a
* demo portfolio of a hundred and ten held three watches.
*/
public static final String DSCR_P50 = "dscr_p50";
public static final String DAYS_SINCE_LAST_ENTRY = "days_since_last_entry";
public static final String EMI_TO_INFLOW = "emi_to_inflow";
@@ -50,7 +61,7 @@ public class FeatureCatalog {
FEED_COST_CHANGE, OUTPUT_PRICE_CHANGE, MARGIN_SQUEEZE, PRICE_Z_SCORE,
RAINFALL_ANOMALY, HEAT_ANOMALY, CASH_FLOW_CHANGE, EMI_DELAY,
INVENTORY_DAYS, UPI_INFLOW_CHANGE, FEED_STALENESS_DAYS,
DSCR_P10, DAYS_SINCE_LAST_ENTRY, EMI_TO_INFLOW);
DSCR_P10, DSCR_P50, DAYS_SINCE_LAST_ENTRY, EMI_TO_INFLOW);
// ── families ────────────────────────────────────────────
@@ -68,6 +79,13 @@ public class FeatureCatalog {
public static final String NO_CBS = "NO_CBS";
public static final String NO_CONSENT = "NO_CONSENT";
public static final String NO_FORECAST = "NO_FORECAST";
/**
* A forecast exists but had no view of margin at all — no output price and no
* input cost — so its band sits at the widest this method produces. The P10 is
* arithmetic rather than evidence, and TW-01 declines to fire on it.
*/
public static final String NO_MARGIN_VIEW = "NO_MARGIN_VIEW";
public static final String INSUFFICIENT_HISTORY = "INSUFFICIENT_HISTORY";
private final FeatureDefinitionRepository repository;
@@ -107,6 +125,8 @@ public class FeatureCatalog {
"HIGHER_IS_WORSE", "Age of the newest market observation for the district — guards every price-derived feature above"),
def(DSCR_P10, "DSCR at P10", OBLIGATION_LOAD, "RATIO", "FORECAST", 7,
"LOWER_IS_WORSE", "Worst month of the horizon: min(p10 / obligation) over the latest scoring run — the downside case failing to cover what is due"),
def(DSCR_P50, "DSCR at P50", OBLIGATION_LOAD, "RATIO", "FORECAST", 7,
"LOWER_IS_WORSE", "Worst month of the horizon: min(p50 / obligation) over the latest scoring run — the central case failing to cover what is due"),
def(DAYS_SINCE_LAST_ENTRY, "Days since last ledger entry", BEHAVIOURAL, "DAYS", "LEDGER", 3,
"HIGHER_IS_WORSE", "Days between the newest ledger entry date and the scoring date; silence is itself a signal"),
def(EMI_TO_INFLOW, "Instalment share of inflow", OBLIGATION_LOAD, "RATIO", "CBS", 30,
@@ -72,6 +72,10 @@ public class FeatureEngineeringService {
@Value("${udyam.forecast.trust-seeded:true}")
private boolean trustSeededForecasts;
/** Furthest horizon {@code dscr_p10} — and so TW-01 — is allowed to act on. */
@Value("${udyam.forecast.tripwire-horizon:3}")
private int tripwireHorizon;
// ── entry points ────────────────────────────────────────
/** One pass over the whole book. Consent-revoked enterprises are skipped, not computed. */
@@ -127,6 +131,7 @@ public class FeatureEngineeringService {
features.put(FeatureCatalog.DAYS_SINCE_LAST_ENTRY, daysSinceLastEntry(enterprise.getEnterpriseId(), asOfDate));
features.put(FeatureCatalog.EMI_TO_INFLOW, emiToInflow(enterprise.getEnterpriseId(), asOfDate));
features.put(FeatureCatalog.DSCR_P10, dscrP10(enterprise.getEnterpriseId(), asOfDate));
features.put(FeatureCatalog.DSCR_P50, dscrP50(enterprise.getEnterpriseId(), asOfDate));
return persist(enterprise.getEnterpriseId(), asOfDate, observedAsOf, scoringRunId, features);
}
@@ -371,7 +376,20 @@ public class FeatureEngineeringService {
return newest
.map(date -> {
int days = staleness(date, asOfDate);
return Computed.of(BigDecimal.valueOf(days), days);
// Staleness zero, deliberately, even though the underlying
// entry is `days` old.
//
// Every other feature is attributed the age of the data behind
// it, and the rule engine refuses to fire a rule whose inputs
// are older than its tolerance. Applying that here inverts the
// one feature whose subject *is* absence: an enterprise silent
// for twenty days produces a twenty-day-old input, TW-03's
// three-day tolerance rejects it, and the rule built to detect
// a silent ledger is switched off by exactly the silence it
// was built to detect. The measurement itself is made fresh
// today from a complete read, so it is not stale — it is
// current, and it says nothing has arrived.
return Computed.of(BigDecimal.valueOf(days), 0);
})
.orElseGet(() -> Computed.missing(FeatureCatalog.NO_LEDGER));
}
@@ -462,6 +480,24 @@ public class FeatureEngineeringService {
* produced the forecast an officer saw.
*/
private Computed dscrP10(String enterpriseId, LocalDate asOfDate) {
return dscr(enterpriseId, asOfDate, ForecastPoint::getP10);
}
/**
* The central case against the same obligation.
*
* <p>Kept as its own feature rather than derived in a rule so that both
* quantiles are stored, auditable and visible on the snapshot. An alert that
* says "expected to fall short" and one that says "could fall short" are
* different claims to an officer knocking on a door, and the evidence for
* each has to survive independently of the rule that read it.
*/
private Computed dscrP50(String enterpriseId, LocalDate asOfDate) {
return dscr(enterpriseId, asOfDate, ForecastPoint::getP50);
}
private Computed dscr(String enterpriseId, LocalDate asOfDate,
java.util.function.Function<ForecastPoint, BigDecimal> quantile) {
List<ForecastPoint> points = forecastPointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId);
// Seeded rows carry no engine. Once a real forecast engine exists, a
@@ -478,11 +514,31 @@ public class FeatureEngineeringService {
.filter(Objects::nonNull).max(Long::compareTo).orElse(null);
if (latestRun == null) return Computed.missing(FeatureCatalog.NO_FORECAST);
List<ForecastPoint> latest = points.stream()
.filter(point -> latestRun.equals(point.getRunId()))
.toList();
// A forecast with no margin view at all carries the widest band the method
// produces, and √h drives it to the cap within four months — at which
// point P10 is a quarter of P50 for everyone, healthy or not, and TW-01
// fires on the entire book. That is not early warning, it is the absence
// of information wearing its clothes. Withhold the number instead, the
// same way the rule engine withholds a rule whose inputs are stale.
boolean blind = !latest.isEmpty() && latest.stream()
.allMatch(point -> point.getMissingInputs() != null && point.getMissingInputs() >= 2);
if (blind) return Computed.missing(FeatureCatalog.NO_MARGIN_VIEW);
// Worst case over the near horizon only. Beyond it the band is dominated
// by compounding uncertainty rather than by anything specific to this
// enterprise, so a month-six P10 separates nobody from anybody. The full
// six-month fan is still stored and still drawn on the forecast screen —
// this bounds what the trip-wire is allowed to act on, not what is shown.
BigDecimal worst = null;
for (ForecastPoint point : points) {
if (!latestRun.equals(point.getRunId())) continue;
if (point.getP10() == null || point.getObligation() == null || point.getObligation().signum() == 0) continue;
BigDecimal ratio = point.getP10().divide(point.getObligation(), 4, RoundingMode.HALF_UP);
for (ForecastPoint point : latest) {
if (point.getHorizon() != null && point.getHorizon() > tripwireHorizon) continue;
BigDecimal value = quantile.apply(point);
if (value == null || point.getObligation() == null || point.getObligation().signum() == 0) continue;
BigDecimal ratio = value.divide(point.getObligation(), 4, RoundingMode.HALF_UP);
if (worst == null || ratio.compareTo(worst) < 0) worst = ratio;
}
return worst == null ? Computed.missing(FeatureCatalog.NO_FORECAST) : Computed.of(worst, 0);
@@ -90,6 +90,10 @@ public class FeatureSnapshot {
@Column(precision = 16, scale = 4)
private BigDecimal dscrP10;
/** The same, at the central estimate. Below 1.0 the expected case does not cover it either. */
@Column(precision = 16, scale = 4)
private BigDecimal dscrP50;
@Column(precision = 16, scale = 4)
private BigDecimal emiToInflow;
@@ -136,6 +140,7 @@ public class FeatureSnapshot {
map.put(FeatureCatalog.UPI_INFLOW_CHANGE, upiInflowChange);
map.put(FeatureCatalog.FEED_STALENESS_DAYS, feedStalenessDays);
map.put(FeatureCatalog.DSCR_P10, dscrP10);
map.put(FeatureCatalog.DSCR_P50, dscrP50);
map.put(FeatureCatalog.DAYS_SINCE_LAST_ENTRY, daysSinceLastEntry);
map.put(FeatureCatalog.EMI_TO_INFLOW, emiToInflow);
return map;
@@ -156,6 +161,7 @@ public class FeatureSnapshot {
case FeatureCatalog.UPI_INFLOW_CHANGE -> upiInflowChange = value;
case FeatureCatalog.FEED_STALENESS_DAYS -> feedStalenessDays = value;
case FeatureCatalog.DSCR_P10 -> dscrP10 = value;
case FeatureCatalog.DSCR_P50 -> dscrP50 = value;
case FeatureCatalog.DAYS_SINCE_LAST_ENTRY -> daysSinceLastEntry = value;
case FeatureCatalog.EMI_TO_INFLOW -> emiToInflow = value;
default -> throw new IllegalArgumentException("Unknown feature code: " + code);
@@ -17,4 +17,6 @@ public interface FeatureSnapshotRepository extends JpaRepository<FeatureSnapshot
List<FeatureSnapshot> findByScoringRunId(Long scoringRunId);
long countByAsOfDate(LocalDate asOfDate);
List<FeatureSnapshot> findByEnterpriseId(String enterpriseId);
}
@@ -31,4 +31,6 @@ public interface FeatureValueRepository extends JpaRepository<FeatureValue, Long
@Param("asOf") LocalDateTime asOf);
long countByValidForDate(LocalDate validForDate);
List<FeatureValue> findByEnterpriseId(String enterpriseId);
}
@@ -145,6 +145,7 @@ public class DeterministicForecastStrategy implements ForecastStrategy {
.breach(obligation.signum() > 0 && p10.compareTo(obligation) < 0)
.engine(engine())
.modelVersion(version())
.missingInputs(margin.missingInputs())
.build());
}
return points;
@@ -66,4 +66,16 @@ public class ForecastPoint {
@Column(length = 32)
private String modelVersion;
/**
* How many margin inputs the path had to do without: 0 with a full margin
* view, 1 with input costs only, 2 with no view of margin at all.
*
* <p>Stored because the spread alone cannot be read backwards — a wide band
* on a healthy enterprise and a wide band on one nothing is known about look
* identical in {@code p10}, and only the second is a reason to withhold a
* trip-wire. TW-01 consults this before firing, the same way the rule engine
* consults staleness.
*/
private Integer missingInputs;
}
@@ -10,4 +10,6 @@ public interface ScoringRunRepository extends JpaRepository<ScoringRun, Long> {
Optional<ScoringRun> findFirstByEnterpriseIdOrderByRunAtDesc(String enterpriseId);
List<ScoringRun> findTop20ByOrderByRunAtDesc();
List<ScoringRun> findByEnterpriseId(String enterpriseId);
}
@@ -116,6 +116,22 @@ public class InstitutionSeeder {
upsertInstitution("VSFB", "Vindhya Small Finance Bank", "विंध्य स्मॉल फाइनेंस बैंक",
"SFB", "Vindhya SFB", "Madhya Pradesh");
// One regional rural bank per state the portfolio now covers. Without a
// lender in its own state, an enterprise there would be assigned to a
// Madhya Pradesh branch by the fallback in assignPortfolio — and the
// national view would show a Bihar borrower banking in Sehore, which is
// the sort of detail that costs the whole screen its credibility.
upsertInstitution("MGB", "Maharashtra Gramin Bank", "महाराष्ट्र ग्रामीण बैंक",
"RRB", "Maharashtra Gramin", "Maharashtra");
upsertInstitution("RMGB", "Rajasthan Marudhara Gramin Bank", "राजस्थान मरुधरा ग्रामीण बैंक",
"RRB", "Marudhara Gramin", "Rajasthan");
upsertInstitution("DBGB", "Dakshin Bihar Gramin Bank", "दक्षिण बिहार ग्रामीण बैंक",
"RRB", "Dakshin Bihar Gramin", "Bihar");
upsertInstitution("KGB", "Karnataka Gramin Bank", "कर्नाटक ग्रामीण बैंक",
"RRB", "Karnataka Gramin", "Karnataka");
upsertInstitution("AGVB", "Assam Gramin Vikash Bank", "असम ग्रामीण विकास बैंक",
"RRB", "Assam Gramin", "Assam");
return nabard;
}
@@ -141,7 +157,20 @@ public class InstitutionSeeder {
new B("SEHDCCB", "SEH02", "Sehore Cooperative Branch", "Sehore", "Sehore Region"),
new B("SEHDCCB", "RAI01", "Raisen Cooperative Branch", "Raisen", "Sehore Region"),
new B("VSFB", "BPL01", "Bhopal City Branch", "Bhopal", "Bhopal Region"),
new B("VSFB", "RAI02", "Raisen Branch", "Raisen", "Bhopal Region"));
new B("VSFB", "RAI02", "Raisen Branch", "Raisen", "Bhopal Region"),
// One branch per district in the five states added with the
// national portfolio. Blocks match the district names the plan
// seeds, because assignPortfolio keys on the block.
new B("MGB", "NSK01", "Nashik Main Branch", "Nashik", "Nashik Region"),
new B("MGB", "AHM01", "Ahmednagar Main Branch", "Ahmednagar", "Nashik Region"),
new B("RMGB", "JAI01", "Jaipur Main Branch", "Jaipur", "Jaipur Region"),
new B("RMGB", "KOT01", "Kota Main Branch", "Kota", "Kota Region"),
new B("DBGB", "MUZ01", "Muzaffarpur Main Branch", "Muzaffarpur", "Tirhut Region"),
new B("DBGB", "PAT01", "Patna Main Branch", "Patna", "Patna Region"),
new B("KGB", "KOL01", "Kolar Main Branch", "Kolar", "Bengaluru Region"),
new B("KGB", "BEL01", "Belagavi Main Branch", "Belagavi", "Belagavi Region"),
new B("AGVB", "KAM01", "Kamrup Main Branch", "Kamrup", "Guwahati Region"),
new B("AGVB", "NAG01", "Nagaon Main Branch", "Nagaon", "Nagaon Region"));
Map<String, Branch> out = new LinkedHashMap<>();
for (B b : plan) {
@@ -228,9 +257,24 @@ public class InstitutionSeeder {
upsertUser("dccb.officer", "Sehore DCCB — Branch Officer", "BRANCH_MANAGER", "BANK",
dccb.getId(), dccbSehore == null ? null : dccbSehore.getId(), "Sehore");
// A head-office login for each of the five state banks. Without one, that
// bank's portfolio exists but nobody can sign in to it, so cross-tenant
// isolation across zones can only be described rather than shown.
seedStateBankAdmin("MGB", "mgb.admin", "Maharashtra Gramin — Head Office", "Nashik,Ahmednagar");
seedStateBankAdmin("RMGB", "rmgb.admin", "Marudhara Gramin — Head Office", "Jaipur,Kota");
seedStateBankAdmin("DBGB", "dbgb.admin", "Dakshin Bihar Gramin — Head Office", "Muzaffarpur,Patna");
seedStateBankAdmin("KGB", "kgb.admin", "Karnataka Gramin — Head Office", "Kolar,Belagavi");
seedStateBankAdmin("AGVB", "agvb.admin", "Assam Gramin Vikash — Head Office", "Kamrup,Nagaon");
backfillLegacyUsers(mpgb, sehore);
}
private void seedStateBankAdmin(String institutionCode, String username, String fullName, String blocks) {
Institution bank = institutionRepository.findByCode(institutionCode).orElse(null);
if (bank == null) return;
upsertUser(username, fullName, "BANK_ADMIN", "BANK", bank.getId(), null, blocks);
}
private void upsertUser(String username, String fullName, String role, String scope,
Long institutionId, Long branchId, String blocks) {
if (userRepository.findByUsernameIgnoreCase(username).isPresent()) return;
@@ -0,0 +1,309 @@
package com.lincfusion.udyamlinc.seed;
import java.util.*;
import java.util.stream.Collectors;
/**
* The demo portfolio's intended shape, as data rather than as a loop.
*
* <p>The plan is a pure function of the constants below — no database, no clock,
* no randomness — so the same build always describes the same portfolio. That is
* what lets the seeder treat it as a target to converge on: it can seed a fresh
* database, add what a half-seeded one is missing, and (when explicitly asked)
* remove what no longer belongs, all by comparing against this list.
*
* <p>Shape: six states, one per NABARD zone, each carrying three to four
* enterprises in every sector pack. Uniform on purpose. An earlier version put
* 142 enterprises in Madhya Pradesh and nothing anywhere else, which made the
* national view a map of one state and the zone rollups untestable — five of six
* zones had no portfolio to roll up.
*/
public final class PortfolioPlan {
/** Sector packs, in the order the per-state quota below is read. */
public static final List<String> SECTORS =
List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL", "HORTICULTURE");
/**
* How many enterprises each sector pack carries per state, positionally
* against {@link #SECTORS}. Twenty-one per state.
*
* <p>Horticulture carries three rather than four for the same reason
* handicrafts and rural retail do — the four-deep packs are the ones whose
* margin squeeze is computable end to end, and a pack is only worth the extra
* enterprises if the feed can actually move them.
*/
private static final int[] PER_SECTOR = {4, 4, 4, 3, 3, 3};
/**
* Madhya Pradesh carries four in every pack rather than 4/4/4/3/3.
*
* <p>Not favouritism — arithmetic. It is the only state with four districts,
* and eighteen across four districts gives cells of five, five, four and
* four. The apex screens suppress any cell below {@code udyam.apex.
* min-cell-size} (five) to stop a borrower being re-identified from an
* aggregate, so two of Madhya Pradesh's districts would publish nothing.
* Twenty divides evenly into four fives. The suppression rule is left alone;
* the portfolio is shaped to stop tripping it needlessly.
*/
private static final int[] PER_SECTOR_MP = {4, 4, 4, 4, 4, 4};
private static int[] perSector(String state) {
return "Madhya Pradesh".equals(state) ? PER_SECTOR_MP : PER_SECTOR;
}
/**
* Band mix per state. Chosen so every state has something for a field officer
* to act on — a state of nothing but STANDARD makes the early-warning screens
* look broken rather than calm.
*/
private static final int RED_PER_STATE = 2;
private static final int WATCH_PER_STATE = 5;
/** A district as both we and Agmarknet name it. */
public record DistrictSpec(String code, String name, String nameHi, String sourceName,
double latitude, double longitude) {
}
/** A state, its zone-bearing name, and the districts the pilot covers in it. */
public record Region(String state, String stateHi, List<DistrictSpec> districts) {
}
/**
* One state per zone. Districts are real, and {@code sourceName} carries the
* pre-rename spelling wherever Agmarknet still publishes under it — Nasik,
* Ahmadnagar, Belgaum. Getting one of those wrong does not fail loudly; it
* returns an empty record list that reads as "no arrivals this week".
*/
public static final List<Region> REGIONS = List.of(
// CENTRAL — the original pilot. Four districts, because the deck's
// pinned enterprises and the seeded branch network already name them.
new Region("Madhya Pradesh", "मध्य प्रदेश", List.of(
new DistrictSpec("SEHORE", "Sehore", "सीहोर", null, 23.2033, 77.0856),
new DistrictSpec("VIDISHA", "Vidisha", "विदिशा", null, 23.5251, 77.8081),
new DistrictSpec("RAISEN", "Raisen", "रायसेन", null, 23.3300, 77.7800),
new DistrictSpec("BHOPAL", "Bhopal", "भोपाल", null, 23.2599, 77.4126))),
// WEST
new Region("Maharashtra", "महाराष्ट्र", List.of(
// Agmarknet publishes "Nashik", not the older "Nasik" — the
// override that assumed otherwise cost the WEST zone every
// price it had, including the national onion benchmark.
// Verified 2026-08-11: 269 recent onion rows, the largest of
// any district in the state.
new DistrictSpec("NASHIK", "Nashik", "नाशिक", null, 20.0059, 73.7897),
// Renamed Ahilyanagar in 2023 and Agmarknet followed. This is
// the opposite failure to Nashik's — here the feed moved and
// we did not. Verified 2026-08-11: 147 recent onion rows.
new DistrictSpec("AHMEDNAGAR", "Ahmednagar", "अहमदनगर", "Ahilyanagar", 19.0952, 74.7496))),
// NORTH
new Region("Rajasthan", "राजस्थान", List.of(
new DistrictSpec("JAIPUR", "Jaipur", "जयपुर", null, 26.9124, 75.7873),
new DistrictSpec("KOTA", "Kota", "कोटा", null, 25.2138, 75.8648))),
// EAST
new Region("Bihar", "बिहार", List.of(
new DistrictSpec("MUZAFFARPUR", "Muzaffarpur", "मुज़फ़्फ़रपुर", null, 26.1209, 85.3647),
// Replaces Patna, which stopped reporting to Agmarknet in
// March 2025 — nothing on our side to fix. Begusarai carries
// 10k rows current to within days across potato, onion,
// brinjal, tomato and okra. See CommodityBaskets.RETIRED —
// Patna's own row stays, deactivated, with its enterprises.
new DistrictSpec("BEGUSARAI", "Begusarai", "बेगूसराय", null, 25.4182, 86.1272))),
// SOUTH — Kolar is a genuine poultry belt, which makes the sector pack
// land on a district where it is the real local economy.
new Region("Karnataka", "कर्नाटक", List.of(
new DistrictSpec("KOLAR", "Kolar", "कोलार", null, 13.1367, 78.1292),
// "Belgaum" is the subtlest of the three, because it does not
// fail — it answers with rows. They stop in May 2026, while
// "Belagavi" is current to within two days. A retired name
// left serving a frozen archive is worse than one returning
// nothing: the freshness screen sees a district reporting
// successfully and only the arrival dates give it away.
new DistrictSpec("BELAGAVI", "Belagavi", "बेळगावी", null, 15.8497, 74.4977))),
// NORTH_EAST — thinnest Agmarknet coverage of the six. Seeded so the
// zone exists on the national view; if the feed returns nothing the
// freshness screen says so rather than the map quietly showing a gap.
new Region("Assam", "असम", List.of(
new DistrictSpec("KAMRUP", "Kamrup", "कामरूप", null, 26.1445, 91.7362),
new DistrictSpec("NAGAON", "Nagaon", "नगाँव", null, 26.3464, 92.6840))));
/** One planned enterprise. {@code pinned} rows carry a score published in the deck. */
public record Slot(String enterpriseId, String sectorCode, String state, String districtCode,
String block, String band, Integer pinnedScore,
String pinnedName, String pinnedNameHi) {
public boolean pinned() {
return pinnedScore != null;
}
}
/**
* The six enterprises printed on the deck's wireframe slide. They keep their
* published ids, names, scores and blocks; the plan places them first and
* fills the rest of Madhya Pradesh's quota around them, so the deck and the
* running demo never disagree.
*/
private static final List<Slot> PINNED = List.of(
pin("MSME-4417", "POULTRY", "SEHORE", "Sehore", 79, "Sehore Poultry Farm", "सीहोर पोल्ट्री फ़ार्म"),
pin("MSME-3902", "DAIRY", "VIDISHA", "Vidisha", 71, "Sharda Dairy Collective", "शारदा डेयरी समूह"),
pin("MSME-5120", "FOOD_PROC", "RAISEN", "Raisen", 58, "Anand Food Products", "आनंद फ़ूड प्रोडक्ट्स"),
pin("MSME-2861", "RURAL_RETAIL", "SEHORE", "Sehore", 41, "Gramin Kirana Store", "ग्रामीण किराना स्टोर"),
pin("MSME-6033", "HANDICRAFTS", "BHOPAL", "Bhopal", 22, "Meera Handicrafts", "मीरा हस्तशिल्प"),
pin("MSME-4488", "DAIRY", "VIDISHA", "Vidisha", 18, "Krishna Dairy Unit", "कृष्णा डेयरी यूनिट"));
private static Slot pin(String id, String sector, String districtCode, String block,
int score, String name, String nameHi) {
return new Slot(id, sector, "Madhya Pradesh", districtCode, block,
bandOfPinned(score), score, name, nameHi);
}
/**
* The pinned scores were published against the thresholds in
* {@code application.properties} (RED ≥ 65, WATCH ≥ 35). Restated here rather
* than injected, because the plan must be a pure function — and because if
* the thresholds are ever retuned, the deck's six do not silently change band
* underneath a slide that has already been presented.
*/
private static String bandOfPinned(int score) {
if (score >= 65) return "RED";
if (score >= 35) return "WATCH";
return "STANDARD";
}
private PortfolioPlan() {
}
/**
* Every enterprise the demo should contain, in a stable order.
*
* <p>Ids are derived from state and index rather than drawn from a random
* sequence, so an enterprise keeps its identity when the plan around it
* changes. Adding a state does not renumber the others.
*/
public static List<Slot> slots() {
List<Slot> out = new ArrayList<>();
for (int regionIndex = 0; regionIndex < REGIONS.size(); regionIndex++) {
Region region = REGIONS.get(regionIndex);
List<Slot> pinnedHere = PINNED.stream()
.filter(p -> p.state().equals(region.state()))
.toList();
out.addAll(pinnedHere);
// Bands still to place in this state, after whatever the pinned rows
// already contribute.
Deque<String> bands = new ArrayDeque<>(remainingBands(pinnedHere, region.state()));
// Every slot this state still needs, in sector order, before any band
// is attached. Ids and districts come from the serial, so they are
// unchanged by how the bands are then dealt.
List<Slot> pending = new ArrayList<>();
int serial = 0;
int[] quota = perSector(region.state());
for (int s = 0; s < SECTORS.size(); s++) {
String sector = SECTORS.get(s);
long already = pinnedHere.stream().filter(p -> p.sectorCode().equals(sector)).count();
int needed = (int) (quota[s] - already);
for (int i = 0; i < needed; i++) {
DistrictSpec district = region.districts()
.get(serial % region.districts().size());
pending.add(new Slot(enterpriseId(regionIndex, serial), sector, region.state(),
district.code(), district.name(), null, null, null, null));
serial++;
}
}
dealBands(pending, bands).forEach(out::add);
}
return out;
}
/**
* Deals the state's bands across sectors round-robin rather than pouring them
* into the sector list in order.
*
* <p>Draining a single shuffled deque in sector order looks fair and is not.
* A state holds two REDs and five WATCHes against twenty-one slots, so the
* scarce bands are almost all gone by the time the loop reaches the last pack
* — and because the shuffle is fixed-seed, "almost all" is the same every
* boot rather than varying. Appending HORTICULTURE to {@link #SECTORS} put it
* at the tail and seeded nineteen enterprises, every one of them STANDARD:
* a pack with no RED and no WATCH cannot demonstrate the early-warning
* screens it was added for, and it reads as a genuinely calm sector rather
* than as an artefact of list position.
*
* <p>Round-robin gives each pack a turn before any pack takes a second, so
* the two REDs land in two different sectors. Order within the state still
* comes from the same fixed-seed shuffle, so the result is stable across
* boots.
*/
private static List<Slot> dealBands(List<Slot> pending, Deque<String> bands) {
Map<String, Deque<Slot>> bySector = new LinkedHashMap<>();
for (Slot s : pending) {
bySector.computeIfAbsent(s.sectorCode(), k -> new ArrayDeque<>()).add(s);
}
List<Slot> out = new ArrayList<>(pending.size());
while (out.size() < pending.size()) {
for (Deque<Slot> queue : bySector.values()) {
Slot s = queue.poll();
if (s == null) continue;
String band = bands.isEmpty() ? "STANDARD" : bands.poll();
out.add(new Slot(s.enterpriseId(), s.sectorCode(), s.state(), s.districtCode(),
s.block(), band, null, null, null));
}
}
return out;
}
/**
* The state's band quota minus what the pinned rows already supply, shuffled
* with a fixed seed so the mix is spread across sectors instead of clustering
* every RED in whichever pack the loop reaches first — and identical on every
* boot, because a demo that reshuffles its own risk bands between runs cannot
* be talked through twice.
*/
private static List<String> remainingBands(List<Slot> pinnedHere, String state) {
int total = Arrays.stream(perSector(state)).sum();
Map<String, Integer> quota = new LinkedHashMap<>();
quota.put("RED", RED_PER_STATE);
quota.put("WATCH", WATCH_PER_STATE);
quota.put("STANDARD", total - RED_PER_STATE - WATCH_PER_STATE);
for (Slot p : pinnedHere) {
quota.computeIfPresent(p.band(), (k, v) -> Math.max(0, v - 1));
}
List<String> bands = new ArrayList<>();
quota.forEach((band, count) -> {
for (int i = 0; i < count; i++) bands.add(band);
});
Collections.shuffle(bands, new Random(20260810L));
return bands;
}
/**
* A stable, readable id per state, preserving the deck's four-digit MSME-nnnn
* form.
*
* <p>The block comes from the region's position in {@link #REGIONS}, not from
* a hash of its name: six states hashed into eight buckets collide with
* roughly ninety-five percent probability, and a collision here would silently
* merge two states' enterprises into one row. Position gives each state its
* own thousand, and appending a state never renumbers an existing one.
*/
private static String enterpriseId(int regionIndex, int serial) {
return "MSME-" + (1000 + regionIndex * 1000 + serial * 7 + 11);
}
/** Total planned size, for logging and for the reshape guard. */
public static int size() {
return slots().size();
}
private static final Set<String> PINNED_IDS = PINNED.stream()
.map(Slot::enterpriseId).collect(Collectors.toUnmodifiableSet());
/** The deck's six, as a set — the seeder asks this once per enterprise. */
public static Set<String> pinnedIds() {
return PINNED_IDS;
}
}
@@ -0,0 +1,69 @@
package com.lincfusion.udyamlinc.seed;
import com.lincfusion.udyamlinc.auth.UdyamUserRepository;
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository;
import com.lincfusion.udyamlinc.enterprise.UpiSummaryRepository;
import com.lincfusion.udyamlinc.features.FeatureSnapshotRepository;
import com.lincfusion.udyamlinc.features.FeatureValueRepository;
import com.lincfusion.udyamlinc.officer.FieldVisitRepository;
import com.lincfusion.udyamlinc.risk.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* Deletes everything hanging off one enterprise.
*
* <p>Exists because the demo portfolio changed shape — from one state to six —
* and an enterprise that leaves the plan cannot simply be dropped from the
* {@code enterprise} table. Ten other tables reference it by {@code enterpriseId}
* as a plain string rather than a foreign key, so the database will not object;
* the rows just become orphans that still answer queries. An alert with no
* enterprise behind it renders on the early-warning screen as a row with a blank
* name, which looks like a bug in the console rather than a bug in the seed.
*
* <p>Every table is listed explicitly. A loop over entity metadata would be
* shorter and would silently miss the next table someone adds, which is the same
* failure with a longer fuse.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class PortfolioPurge {
private final ScoringRunRepository runRepository;
private final ForecastPointRepository pointRepository;
private final StressScoreRepository scoreRepository;
private final ReasonCodeRepository reasonRepository;
private final RiskAlertRepository alertRepository;
private final FieldVisitRepository visitRepository;
private final LedgerEntryRepository ledgerRepository;
private final LoanRecordRepository loanRecordRepository;
private final UpiSummaryRepository upiSummaryRepository;
private final FeatureValueRepository featureValueRepository;
private final FeatureSnapshotRepository featureSnapshotRepository;
private final UdyamUserRepository userRepository;
/** Removes the dependent rows. The {@code enterprise} row itself is the caller's. */
public void purge(String enterpriseId) {
visitRepository.deleteAll(visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId));
alertRepository.deleteAll(alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId));
reasonRepository.deleteAll(reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId));
scoreRepository.deleteAll(scoreRepository.findByEnterpriseIdOrderByComputedAtDesc(enterpriseId));
pointRepository.deleteAll(pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId));
runRepository.deleteAll(runRepository.findByEnterpriseId(enterpriseId));
ledgerRepository.deleteAll(ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId));
loanRecordRepository.deleteAll(loanRecordRepository.findByEnterpriseIdOrderByAsOfDateAsc(enterpriseId));
upiSummaryRepository.deleteByEnterpriseId(enterpriseId);
featureValueRepository.deleteAll(featureValueRepository.findByEnterpriseId(enterpriseId));
featureSnapshotRepository.deleteAll(featureSnapshotRepository.findByEnterpriseId(enterpriseId));
// The enterprise's own PWA login, or the next seed leaves a credential
// that authenticates against a borrower who no longer exists.
userRepository.findByEnterpriseIdAndScopeIgnoreCase(enterpriseId, "ENTERPRISE")
.ifPresent(userRepository::delete);
}
}
@@ -28,19 +28,73 @@ final class SeedText {
"भगीरथ", "मालवा", "सहयोग", "एकता", "प्रगति", "सुविधा", "अनमोल", "निर्मल", "वैभव",
};
/**
* Name prefixes drawn from each state's own rivers, ranges and landmarks.
*
* <p>The original pool is Madhya Pradesh's — Narmada, Betwa, Vindhya, Malwa.
* Reusing it nationally would put a "Betwa Dairy" in Assam, which is the kind
* of detail a NABARD reviewer from that circle notices immediately and which
* makes every other number on the screen look equally unconsidered. Anything
* unlisted falls back to {@link #PREFIX}.
*/
private static final Map<String, String[]> PREFIX_BY_STATE = Map.of(
"Maharashtra", new String[]{
"Godavari", "Sahyadri", "Shivneri", "Krishna", "Jai Bhavani", "Vitthal",
"Sant Tukaram", "Pandharpur", "Shri", "Adarsh", "Pragati", "Ekta"},
"Rajasthan", new String[]{
"Aravalli", "Chambal", "Marudhara", "Pushkar", "Mewar", "Marwar",
"Shekhawati", "Banas", "Shri", "Adarsh", "Pragati", "Ekta"},
"Bihar", new String[]{
"Ganga", "Kosi", "Gandak", "Mithila", "Magadh", "Vaishali",
"Sone", "Bodhi", "Shri", "Adarsh", "Pragati", "Ekta"},
"Karnataka", new String[]{
"Kaveri", "Tungabhadra", "Nandi", "Malnad", "Basava", "Chamundi",
"Hoysala", "Sharavathi", "Shri", "Adarsh", "Pragati", "Ekta"},
"Assam", new String[]{
"Brahmaputra", "Kaziranga", "Barak", "Kamakhya", "Luit", "Bihu",
"Sualkuchi", "Majuli", "Shri", "Adarsh", "Pragati", "Ekta"});
private static final Map<String, String[]> PREFIX_HI_BY_STATE = Map.of(
"Maharashtra", new String[]{
"गोदावरी", "सह्याद्रि", "शिवनेरी", "कृष्णा", "जय भवानी", "विठ्ठल",
"संत तुकाराम", "पंढरपुर", "श्री", "आदर्श", "प्रगति", "एकता"},
"Rajasthan", new String[]{
"अरावली", "चंबल", "मरुधरा", "पुष्कर", "मेवाड़", "मारवाड़",
"शेखावाटी", "बनास", "श्री", "आदर्श", "प्रगति", "एकता"},
"Bihar", new String[]{
"गंगा", "कोसी", "गंडक", "मिथिला", "मगध", "वैशाली",
"सोन", "बोधि", "श्री", "आदर्श", "प्रगति", "एकता"},
"Karnataka", new String[]{
"कावेरी", "तुंगभद्रा", "नंदी", "मलनाड", "बसव", "चामुंडी",
"होयसल", "शरावती", "श्री", "आदर्श", "प्रगति", "एकता"},
"Assam", new String[]{
"ब्रह्मपुत्र", "काज़ीरंगा", "बराक", "कामाख्या", "लुइत", "बिहू",
"शुआलकुची", "माजुली", "श्री", "आदर्श", "प्रगति", "एकता"});
/** Prefix pool for a state, and its Hindi counterpart at the same indexes. */
static String[] prefixesFor(String state) {
return PREFIX_BY_STATE.getOrDefault(state, PREFIX);
}
static String[] prefixesHiFor(String state) {
return PREFIX_HI_BY_STATE.getOrDefault(state, PREFIX_HI);
}
static final Map<String, List<String>> SUFFIX = Map.of(
"DAIRY", List.of("Dairy", "Milk Centre", "Dairy Unit", "Doodh Sangh"),
"POULTRY", List.of("Poultry Farm", "Layer Farm", "Poultry Unit"),
"FOOD_PROC", List.of("Food Products", "Flour Mill", "Masala Udyog", "Agro Foods"),
"HANDICRAFTS", List.of("Handicrafts", "Handloom", "Craft Works"),
"RURAL_RETAIL", List.of("Kirana Store", "General Store", "Provision Store"));
"RURAL_RETAIL", List.of("Kirana Store", "General Store", "Provision Store"),
"HORTICULTURE", List.of("Farms", "Bagwani Udyan", "Nursery", "Fruit Farm"));
static final Map<String, List<String>> SUFFIX_HI = Map.of(
"DAIRY", List.of("डेयरी", "दूध केंद्र", "डेयरी यूनिट", "दूध संघ"),
"POULTRY", List.of("पोल्ट्री फ़ार्म", "लेयर फ़ार्म", "पोल्ट्री यूनिट"),
"FOOD_PROC", List.of("फ़ूड प्रोडक्ट्स", "आटा चक्की", "मसाला उद्योग", "एग्रो फ़ूड्स"),
"HANDICRAFTS", List.of("हस्तशिल्प", "हथकरघा", "क्राफ़्ट वर्क्स"),
"RURAL_RETAIL", List.of("किराना स्टोर", "जनरल स्टोर", "प्रोविज़न स्टोर"));
"RURAL_RETAIL", List.of("किराना स्टोर", "जनरल स्टोर", "प्रोविज़न स्टोर"),
"HORTICULTURE", List.of("फ़ार्म्स", "बागवानी उद्यान", "नर्सरी", "फ़्रूट फ़ार्म"));
/** driver → { en, hi } sentence, per sector. */
static final Map<String, Map<String, String[]>> REASONS = Map.of(
@@ -73,7 +127,16 @@ final class SeedText {
"behavioural", new String[]{"Daily UPI receipt count down for six straight weeks.", "छह हफ़्तों से रोज़ के UPI लेन-देन घट रहे हैं।"},
"trendVolatility", new String[]{"Footfall dropping since the new road closure.", "रास्ता बंद होने से ग्राहक कम आ रहे हैं।"},
"obligationLoad", new String[]{"Stock restocking clashes with the instalment date.", "माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।"},
"externalShock", new String[]{"Local market disruption flagged in the block.", "ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।"}));
"externalShock", new String[]{"Local market disruption flagged in the block.", "ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।"}),
// A grower's stress reads differently from a processor's: no stock to
// hold, no buyer to chase, and a price that can halve between one
// market day and the next.
"HORTICULTURE", Map.of(
"liquidity", new String[]{"Harvest sold below cost as arrivals peaked together.", "एक साथ आवक बढ़ने से फ़सल लागत से कम भाव पर बिकी।"},
"behavioural", new String[]{"Selling through one commission agent on deferred payment.", "एक ही आढ़तिये को उधार पर माल दिया जा रहा है।"},
"trendVolatility", new String[]{"Mandi rate swung more than 40% inside a fortnight.", "पखवाड़े भर में मंडी भाव 40% से ज़्यादा घट-बढ़ा है।"},
"obligationLoad", new String[]{"Instalment falls between harvests, with no crop to sell.", "किस्त दो फ़सलों के बीच पड़ रही है, बेचने को कुछ नहीं।"},
"externalShock", new String[]{"Unseasonal rain in the block during the picking window.", "तुड़ाई के समय ब्लॉक में बेमौसम बारिश हुई है।"}));
/** Driver key → Hindi label, in the order the console renders them. */
static final String[][] DRIVERS = {
@@ -48,7 +48,6 @@ public class UdyamDataSeeder implements CommandLineRunner {
public static final String DEFAULT_OFFICER_PASSWORD = "Udyam@123";
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,
@@ -58,7 +57,6 @@ public class UdyamDataSeeder implements CommandLineRunner {
* 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";
private final DistrictRepository districtRepository;
@@ -66,6 +64,7 @@ public class UdyamDataSeeder implements CommandLineRunner {
private final MarketRepository marketRepository;
private final SectorRepository sectorRepository;
private final SectorCommodityRepository sectorCommodityRepository;
private final DistrictCommodityRepository districtCommodityRepository;
private final RoleRepository roleRepository;
private final EnterpriseRepository enterpriseRepository;
private final LedgerEntryRepository ledgerRepository;
@@ -81,11 +80,20 @@ public class UdyamDataSeeder implements CommandLineRunner {
private final UdyamUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final InstitutionSeeder institutionSeeder;
private final RegionalOfficeSeeder regionalOfficeSeeder;
private final com.lincfusion.udyamlinc.connector.ConnectorProfileSeeder connectorProfileSeeder;
private final PortfolioPurge portfolioPurge;
@Value("${udyam.seed.enabled:true}")
private boolean seedEnabled;
/**
* Destructive, so off unless asked for. See
* {@code reshapeIfRequested} — one boot with it on, then off again.
*/
@Value("${udyam.seed.reshape:false}")
private boolean reshapeEnabled;
@Value("${udyam.risk.red-threshold:65}")
private int redThreshold;
@@ -106,12 +114,16 @@ public class UdyamDataSeeder implements CommandLineRunner {
seedDistricts();
seedCommodities();
seedMarkets();
// After both districts and commodities exist — it joins the two.
seedDistrictCommodities();
seedSectors();
seedSectorCommodities();
seedTripWireRules();
featureCatalog.seed();
seedOfficers();
seedPortfolio();
// Regional offices before the institutions that roll up into them.
regionalOfficeSeeder.seed();
// The institutional tier last: it assigns the portfolio the steps above
// created, so it has to see a finished book.
institutionSeeder.seed();
@@ -124,7 +136,25 @@ public class UdyamDataSeeder implements CommandLineRunner {
// ── masters ─────────────────────────────────────────────
private void seedRoles() {
if (roleRepository.count() > 0) return;
// Upserted per code rather than guarded on the table, so RO_ADMIN and
// RO_OFFICER reach a database seeded before the office tier existed.
List<Role> planned = List.of(
Role.builder().code("RO_ADMIN").name("Regional Office Administrator").systemRole(true)
// No BORROWER permission of any kind, deliberately. An RO
// supervises banks; the entitlement it must never acquire
// is the one that would let it open a customer.
.permissions("PORTFOLIO:VIEW,ALERT:VIEW,REPORT:VIEW,CONFIG:MANAGE").build(),
Role.builder().code("RO_OFFICER").name("Regional Office Officer").systemRole(true)
.permissions("PORTFOLIO:VIEW,ALERT:VIEW,REPORT:VIEW").build());
int added = 0;
for (Role r : planned) {
if (roleRepository.findByCode(r.getCode()).isPresent()) continue;
roleRepository.save(r);
added++;
}
if (added > 0) log.info("Seeded {} regional-office roles", added);
if (roleRepository.count() > added) return;
roleRepository.saveAll(List.of(
Role.builder().code("FIELD_OFFICER").name("Field Officer").systemRole(true)
.permissions("PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW").build(),
@@ -135,72 +165,225 @@ public class UdyamDataSeeder implements CommandLineRunner {
log.info("Seeded {} roles", roleRepository.count());
}
/** The pilot's four districts, with centroids for picking the climate grid cell. */
/**
* The pilot's districts, with centroids for picking the climate grid cell.
*
* <p>Upserted one code at a time rather than guarded by {@code count() > 0}.
* The old guard meant a district added after a database was first populated
* never arrived — the same trap that left loan records missing for a release,
* except here it would be silent in a worse way: the state would be absent
* from the national map with no error anywhere, just a smaller portfolio.
*/
private void seedDistricts() {
if (districtRepository.count() > 0) return;
districtRepository.saveAll(List.of(
district("SEHORE", "Sehore", "सीहोर", 23.2033, 77.0856),
district("VIDISHA", "Vidisha", "विदिशा", 23.5251, 77.8081),
district("RAISEN", "Raisen", "रायसेन", 23.3300, 77.7800),
district("BHOPAL", "Bhopal", "भोपाल", 23.2599, 77.4126)));
log.info("Seeded {} districts", districtRepository.count());
int written = 0, updated = 0;
for (PortfolioPlan.Region region : PortfolioPlan.REGIONS) {
for (PortfolioPlan.DistrictSpec spec : region.districts()) {
Optional<District> existing = districtRepository.findByCode(spec.code());
if (existing.isPresent()) {
// Feed spellings arrived after the first four districts were
// seeded, so an existing row needs them backfilled or its
// Agmarknet calls keep matching nothing.
District d = existing.get();
if (!Objects.equals(d.getSourceName(), spec.sourceName())) {
d.setSourceName(spec.sourceName());
districtRepository.save(d);
updated++;
}
private District district(String code, String name, String nameHi, double lat, double lon) {
String state = "Madhya Pradesh";
return District.builder().code(code).name(name).nameHi(nameHi)
.state(state).zone(Zones.of(state)).latitude(lat).longitude(lon).active(true).build();
continue;
}
districtRepository.save(District.builder()
.code(spec.code()).name(spec.name()).nameHi(spec.nameHi())
.sourceName(spec.sourceName())
.state(region.state()).zone(Zones.of(region.state()))
.latitude(spec.latitude()).longitude(spec.longitude())
.active(true).build());
written++;
}
}
if (written > 0) log.info("Seeded {} districts", written);
if (updated > 0) log.info("Backfilled feed spellings on {} existing districts", updated);
}
/**
* Commodities the sector packs track. {@code sourceCode} is the name the
* upstream feed uses, which rarely matches ours.
* Commodities the sector packs track, reconciled against
* {@link CommodityBaskets#COMMODITIES} one code at a time.
*
* <p>Not guarded by {@code count() > 0}. That guard is why the fruit and
* vegetable rows would otherwise never have arrived: the table has been
* non-empty since the first boot, so the whole method returned before
* reaching them, and nothing anywhere reported a commodity as missing.
*
* <p>{@code sourceCode} on an existing row is deliberately left alone.
* Rewriting one would orphan every {@code market_signal} already ingested
* under the old spelling — the history would still be in the table and no
* longer joinable to the commodity that produced it. Divergence is logged
* instead, loudly enough to be dealt with by hand.
*/
private void seedCommodities() {
if (commodityRepository.count() > 0) return;
commodityRepository.saveAll(List.of(
commodity("MAIZE", "Maize", "मक्का", "AGMARKNET", "Maize", "QUINTAL"),
commodity("SOYA", "Soyabean", "सोयाबीन", "AGMARKNET", "Soyabean", "QUINTAL"),
commodity("WHEAT", "Wheat", "गेहूँ", "AGMARKNET", "Wheat", "QUINTAL"),
commodity("GRAM", "Gram", "चना", "AGMARKNET", "Bengal Gram(Gram)(Whole)", "QUINTAL"),
commodity("EGG", "Egg", "अंडा", "NECC", "Bhopal", "HUNDRED"),
commodity("MILK", "Milk", "दूध", "DAIRY_FEDERATION", null, "LITRE"),
// Agmarknet carries Green Fodder, but every recent row is one market
// in Ambala, Haryana — nothing for the pilot districts. Left as a
// manual-entry commodity rather than a nightly call that can only
// ever return an empty page.
commodity("FODDER", "Green fodder", "हरा चारा", "MANUAL", null, "QUINTAL")));
log.info("Seeded {} commodities", commodityRepository.count());
int written = 0, updated = 0;
for (CommodityBaskets.CommoditySpec spec : CommodityBaskets.COMMODITIES) {
Optional<Commodity> existing = commodityRepository.findByCode(spec.code());
if (existing.isPresent()) {
Commodity c = existing.get();
if (!Objects.equals(c.getSourceCode(), spec.sourceCode())) {
log.warn("Commodity {} has sourceCode '{}' on book but the plan says '{}'. Left as is — "
+ "changing it would orphan ingested market_signal rows. Fix by hand if the plan is right.",
spec.code(), c.getSourceCode(), spec.sourceCode());
}
// Classification and the verified flag are safe to reconcile:
// neither is a join key, and both were absent before this change.
if (c.getCommodityGroup() == null || !Objects.equals(c.getCommodityGroup(), spec.group())) {
c.setCommodityGroup(spec.group());
if (c.getSourceVerified() == null) c.setSourceVerified(spec.verified());
commodityRepository.save(c);
updated++;
}
continue;
}
commodityRepository.save(Commodity.builder()
.code(spec.code()).name(spec.name()).nameHi(spec.nameHi())
.source(spec.source()).sourceCode(spec.sourceCode())
.commodityGroup(spec.group()).unit(spec.unit())
.sourceVerified(spec.verified()).active(true).build());
written++;
}
if (written > 0) log.info("Seeded {} commodities", written);
if (updated > 0) log.info("Classified {} existing commodities", updated);
}
private Commodity commodity(String code, String name, String nameHi, String source, String sourceCode, String unit) {
return Commodity.builder().code(code).name(name).nameHi(nameHi)
.source(source).sourceCode(sourceCode).unit(unit).active(true).build();
/**
* What each district trades. Replaces the district × commodity cross product
* ingestion used to walk — see {@link DistrictCommodity} for why that stops
* working the moment perishables are tracked across six states.
*/
private void seedDistrictCommodities() {
int written = 0, missing = 0, retired = 0, restored = 0, deactivated = 0;
for (District district : districtRepository.findAll()) {
String retiredReason = CommodityBaskets.RETIRED.get(district.getCode());
if (retiredReason != null) {
if (Boolean.TRUE.equals(district.getActive())) {
district.setActive(false);
districtRepository.save(district);
deactivated++;
log.warn("Deactivated district {} — {}", district.getCode(), retiredReason);
}
// Retire its pairs too, or they sit active against a district
// ingestion no longer visits and read as healthy on the console.
for (DistrictCommodity dc : districtCommodityRepository
.findByDistrictIdAndActiveTrue(district.getId())) {
dc.setActive(false);
districtCommodityRepository.save(dc);
retired++;
}
continue;
}
List<String> basket = CommodityBaskets.BASKETS.get(district.getCode());
if (basket == null) {
log.warn("District {} has no basket in CommodityBaskets — ingestion will pull nothing for it",
district.getCode());
missing++;
continue;
}
Set<Long> planned = new HashSet<>();
for (String entry : basket) {
String code = CommodityBaskets.commodityCode(entry);
Long commodityId = commodityRepository.findByCode(code).map(Commodity::getId).orElse(null);
if (commodityId == null) {
log.warn("Basket for {} names unknown commodity {}", district.getCode(), code);
continue;
}
planned.add(commodityId);
Optional<DistrictCommodity> existing = districtCommodityRepository
.findByDistrictIdAndCommodityId(district.getId(), commodityId);
if (existing.isPresent()) {
// Back in the plan after being retired — a probe that
// vindicates a spelling should not need a manual re-insert.
if (!Boolean.TRUE.equals(existing.get().getActive())) {
existing.get().setActive(true);
districtCommodityRepository.save(existing.get());
restored++;
}
continue;
}
districtCommodityRepository.save(DistrictCommodity.builder()
.districtId(district.getId()).commodityId(commodityId)
.benchmark(CommodityBaskets.isBenchmark(entry))
.active(true).build());
written++;
}
// Deactivated, not deleted. A pair dropped because the feed turned out
// not to carry it — Muzaffarpur litchi, Nagaon banana — is a finding
// worth keeping: the row records that we looked and what we found, and
// deleting it invites the next person to add the same basket entry
// again for the same wrong reason.
for (DistrictCommodity dc : districtCommodityRepository
.findByDistrictIdAndActiveTrue(district.getId())) {
if (planned.contains(dc.getCommodityId())) continue;
dc.setActive(false);
districtCommodityRepository.save(dc);
retired++;
}
}
if (written > 0) log.info("Seeded {} district-commodity pairs ({} planned)",
written, CommodityBaskets.pairCount());
if (restored > 0) log.info("Reactivated {} district-commodity pairs", restored);
if (retired > 0) log.info("Retired {} district-commodity pairs no longer in the basket plan", retired);
if (deactivated > 0) log.warn("Deactivated {} districts that no longer report", deactivated);
if (missing > 0) log.warn("{} districts have no basket", missing);
}
/** One primary mandi per district, guarded per code so a new district gets one. */
private void seedMarkets() {
if (marketRepository.count() > 0) return;
districtRepository.findAll().forEach(d -> marketRepository.save(Market.builder()
.code(d.getCode() + "-MANDI")
int written = 0;
for (District d : districtRepository.findAll()) {
String code = d.getCode() + "-MANDI";
if (marketRepository.findByCode(code).isPresent()) continue;
marketRepository.save(Market.builder()
.code(code)
.name(d.getName() + " Krishi Upaj Mandi")
.sourceName(d.getName())
.sourceName(d.feedDistrict())
.districtId(d.getId())
.primaryMarket(true)
.active(true)
.build()));
log.info("Seeded {} markets", marketRepository.count());
.build());
written++;
}
if (written > 0) log.info("Seeded {} markets", written);
}
/** The five risk packs. Weights are basis points and sum to 10000. */
/**
* The risk packs. Weights are basis points and sum to 10000.
*
* <p>Upserted per code, not guarded by {@code count() > 0} — the guard is
* why HORTICULTURE would otherwise never reach a database seeded before it
* existed. Weights on an existing pack are left alone: they are a
* supervisor's calibration, and a deploy silently restoring them to the
* shipped defaults is exactly the kind of change nobody would notice.
*/
private void seedSectors() {
if (sectorRepository.count() > 0) return;
sectorRepository.saveAll(List.of(
List<Sector> planned = List.of(
sector("DAIRY", "Dairy", "डेयरी", "Dairy", 2800, 2000, 1800, 1900, 1500),
sector("POULTRY", "Poultry", "मुर्गी पालन", "Poultry", 3000, 2200, 1700, 1400, 1700),
sector("FOOD_PROC", "Food processing", "खाद्य प्रसंस्करण", "Food proc.", 2600, 2200, 2000, 1800, 1400),
sector("HANDICRAFTS", "Handicrafts", "हस्तशिल्प", "Handicrafts", 2400, 1800, 2000, 1600, 2200),
sector("RURAL_RETAIL", "Rural retail", "ग्रामीण दुकान", "Rural retail", 3000, 2400, 1800, 1800, 1000)));
log.info("Seeded {} sector packs", sectorRepository.count());
sector("RURAL_RETAIL", "Rural retail", "ग्रामीण दुकान", "Rural retail", 3000, 2400, 1800, 1800, 1000),
// Horticulture carries the heaviest external-shock weight of the
// six and the lightest obligation load. A grower's stress arrives
// as a price collapse or a hailstorm inside one week — Nashik
// onion and Kolar tomato both routinely halve — where a dairy's
// arrives as a margin that thins over a season. The pack has to
// read the shock family harder or it will be late on exactly the
// enterprises it exists to warn about.
sector("HORTICULTURE", "Horticulture", "बागवानी", "Horticulture", 2600, 1600, 2000, 1200, 2600));
int written = 0;
for (Sector s : planned) {
if (sectorRepository.findByCode(s.getCode()).isPresent()) continue;
sectorRepository.save(s);
written++;
}
if (written > 0) log.info("Seeded {} sector packs", written);
}
private Sector sector(String code, String label, String labelHi, String shortLabel,
@@ -215,7 +398,6 @@ public class UdyamDataSeeder implements CommandLineRunner {
* margin squeeze computable once the ingestion layer lands.
*/
private void seedSectorCommodities() {
if (sectorCommodityRepository.count() > 0) return;
link("POULTRY", "MAIZE", "INPUT", 6000);
link("POULTRY", "SOYA", "INPUT", 4000);
link("POULTRY", "EGG", "OUTPUT", 10000);
@@ -225,13 +407,64 @@ public class UdyamDataSeeder implements CommandLineRunner {
link("FOOD_PROC", "WHEAT", "INPUT", 5000);
link("FOOD_PROC", "GRAM", "INPUT", 5000);
link("RURAL_RETAIL", "WHEAT", "INPUT", 10000);
log.info("Seeded {} sector-commodity links", sectorCommodityRepository.count());
// Horticulture sells what it grows and buys almost nothing this feed can
// see — seed, labour and transport are its real costs and none has a
// mandi price. So the pack is OUTPUT-heavy on purpose, and margin squeeze
// for a grower is a revenue collapse rather than a cost spike. Weights
// are even across the five because which one a given grower actually
// sells is not in the schema; a real deployment would set this per
// enterprise from the loan application's crop declaration.
link("HORTICULTURE", "ONION", "OUTPUT", 2000);
link("HORTICULTURE", "TOMATO", "OUTPUT", 2000);
link("HORTICULTURE", "POTATO", "OUTPUT", 2000);
link("HORTICULTURE", "BANANA", "OUTPUT", 2000);
link("HORTICULTURE", "MANGO", "OUTPUT", 2000);
// The one cost that does have a feed: a grower diversifying into a cereal
// buys seed at the same mandi that prices the crop.
link("HORTICULTURE", "MAIZE", "INPUT", 10000);
// Food processing buys perishables too — a tomato-paste or pickle unit is
// exactly the enterprise a vegetable price spike hurts. Re-weighted from
// 5000/5000 so the four inputs still sum to 10000.
link("FOOD_PROC", "TOMATO", "INPUT", 5000);
link("FOOD_PROC", "POTATO", "INPUT", 5000);
// Handicrafts had no commodity of any kind, so four features were null
// for forty-seven enterprises. Cotton is a real input to a handloom unit
// and we already carry it verified — 958,962 rows nationally, current to
// yesterday. It is an INPUT only: there is no mandi rate for a finished
// sari, and inventing an output price would manufacture a margin for an
// enterprise whose margin has nothing to do with a mandi.
//
// This yields a value only where cotton actually trades, which today is
// Ahmednagar and Belagavi. The rest stay NO_SIGNAL, correctly — the link
// makes the feature computable where the data exists rather than
// everywhere at once.
link("HANDICRAFTS", "COTTON", "INPUT", 6000);
// Simulated output indices for the three packs that have no traded
// output. See CommodityBaskets — these are labelled synthetic in four
// separate places, because unlike milk and egg they do not stand in for
// a real price that merely lacks an API. They exist so margin_squeeze,
// output_price_change and price_z_score compute for every pack.
link("FOOD_PROC", "PROCESSED_FOOD_INDEX", "OUTPUT", 10000);
link("RURAL_RETAIL", "RETAIL_BASKET_INDEX", "OUTPUT", 10000);
link("HANDICRAFTS", "HANDLOOM_INDEX", "OUTPUT", 10000);
// Yarn carries handicrafts everywhere cotton does not trade, which is
// twelve of the fourteen districts.
link("HANDICRAFTS", "YARN_INDEX", "INPUT", 4000);
}
/**
* Upserted per (sector, commodity) pair rather than guarded by a table-level
* {@code count() > 0}, so a link added to an existing database arrives.
*/
private void link(String sectorCode, String commodityCode, String role, int weight) {
Long sectorId = sectorRepository.findByCode(sectorCode).map(Sector::getId).orElse(null);
Long commodityId = commodityRepository.findByCode(commodityCode).map(Commodity::getId).orElse(null);
if (sectorId == null || commodityId == null) return;
if (sectorCommodityRepository.findBySectorIdAndCommodityId(sectorId, commodityId).isPresent()) return;
sectorCommodityRepository.save(SectorCommodity.builder()
.sectorId(sectorId).commodityId(commodityId).role(role).weight(weight).build());
}
@@ -240,11 +473,24 @@ public class UdyamDataSeeder implements CommandLineRunner {
* The five deterministic trip-wires. Expressions are SpEL over the feature
* snapshot; the evaluator that runs them arrives with the feature store.
*/
/**
* The trip-wires, upserted by code rather than guarded by {@code count() > 0}.
*
* <p>The old guard meant a rule added or corrected after a database was first
* populated never arrived — and unlike a missing enterprise, a missing rule is
* silent: the console shows the rules it has and nothing indicates one is
* absent.
*
* <p>TW-01 and TW-06 split what used to be one rule. Firing a single
* red-severity rule on the P10 breach meant every enterprise whose *downside*
* fell short was called red, which is not what a P10 says and which emptied
* the watch band — a hundred and ten enterprises produced three watches. The
* central case missing is red; the downside case missing is a watch.
*/
private void seedTripWireRules() {
if (ruleRepository.count() > 0) return;
ruleRepository.saveAll(List.of(
upsertRules(List.of(
rule("TW-01", "Projected DSCR below 1.0", "किस्त चुकाने की क्षमता 1.0 से नीचे",
"dscr_p10 < 1.0", "DSCR(P10) < 1.00", "RED", "dscr_p10", 7),
"dscr_p50 < 1.0", "DSCR(P50) < 1.00", "RED", "dscr_p50", 7),
rule("TW-02", "UPI inflow drop over 8 weeks", "आठ हफ़्तों में UPI आमदनी में गिरावट",
"upi_inflow_change < -0.20", "Δ inflow < −20%", "RED", "upi_inflow_change", 10),
rule("TW-03", "Ledger silent for 14 days", "14 दिन से हिसाब दर्ज नहीं",
@@ -253,8 +499,51 @@ public class UdyamDataSeeder implements CommandLineRunner {
"emi_to_inflow > 0.40", "EMI / inflow > 40%", "WATCH", "emi_to_inflow", 30),
rule("TW-05", "Climate or mandi shock in block", "ब्लॉक में मौसम या मंडी का झटका",
"price_z_score > 1.5 or heat_anomaly > 1.5", "anomaly z-score > 1.5", "WATCH",
"price_z_score,heat_anomaly", 14)));
log.info("Seeded {} trip-wire rules", ruleRepository.count());
"price_z_score,heat_anomaly", 14),
rule("TW-06", "Downside case misses the instalment", "बुरी स्थिति में किस्त छूट सकती है",
"dscr_p10 < 1.0", "DSCR(P10) < 1.00", "WATCH", "dscr_p10", 7)));
}
/**
* Insert what is missing and correct what has changed, leaving any rule an
* operator has edited in the console alone.
*
* <p>{@code version} is the discriminator: the seeder only overwrites a rule
* still on version 1, which is the version it wrote. A supervisor who retunes
* a threshold bumps it, and their change survives every subsequent boot —
* otherwise a restart would silently revert a risk decision somebody made
* deliberately.
*/
private void upsertRules(List<TripWireRule> wanted) {
int written = 0, corrected = 0;
for (TripWireRule want : wanted) {
Optional<TripWireRule> existing = ruleRepository.findByCode(want.getCode());
if (existing.isEmpty()) {
ruleRepository.save(want);
written++;
continue;
}
TripWireRule have = existing.get();
if (have.getVersion() != null && have.getVersion() > 1) continue;
if (Objects.equals(have.getExpression(), want.getExpression())
&& Objects.equals(have.getSeverity(), want.getSeverity())
&& Objects.equals(have.getRequiredFeatures(), want.getRequiredFeatures())) {
continue;
}
have.setLabel(want.getLabel());
have.setLabelHi(want.getLabelHi());
have.setExpression(want.getExpression());
have.setThresholdText(want.getThresholdText());
have.setSeverity(want.getSeverity());
have.setRequiredFeatures(want.getRequiredFeatures());
have.setMaxStalenessDays(want.getMaxStalenessDays());
have.setUpdatedBy("seed");
have.setUpdatedAt(LocalDateTime.now());
ruleRepository.save(have);
corrected++;
}
if (written > 0) log.info("Seeded {} trip-wire rules", written);
if (corrected > 0) log.info("Corrected {} seed-owned trip-wire rules", corrected);
}
private TripWireRule rule(String code, String label, String labelHi, String expression,
@@ -284,18 +573,6 @@ public class UdyamDataSeeder implements CommandLineRunner {
// ── portfolio ───────────────────────────────────────────
/** The six enterprises printed on the deck's wireframe slide. */
private record Pinned(String id, String name, String nameHi, String sector, String block, int score) {
}
private static final List<Pinned> PINNED = List.of(
new Pinned("MSME-4417", "Sehore Poultry Farm", "सीहोर पोल्ट्री फ़ार्म", "POULTRY", "Sehore", 79),
new Pinned("MSME-3902", "Sharda Dairy Collective", "शारदा डेयरी समूह", "DAIRY", "Vidisha", 71),
new Pinned("MSME-5120", "Anand Food Products", "आनंद फ़ूड प्रोडक्ट्स", "FOOD_PROC", "Raisen", 58),
new Pinned("MSME-2861", "Gramin Kirana Store", "ग्रामीण किराना स्टोर", "RURAL_RETAIL", "Sehore", 41),
new Pinned("MSME-6033", "Meera Handicrafts", "मीरा हस्तशिल्प", "HANDICRAFTS", "Bhopal", 22),
new Pinned("MSME-4488", "Krishna Dairy Unit", "कृष्णा डेयरी यूनिट", "DAIRY", "Vidisha", 18));
/** Slide-6 driver weights for the headline enterprise. */
private static final Map<String, int[]> PINNED_SUBSCORES =
Map.of("MSME-4417", new int[]{26, 19, 14, 11, 9});
@@ -304,72 +581,108 @@ public class UdyamDataSeeder implements CommandLineRunner {
private static final int PINNED_BREACH_HORIZON = 3;
private static final BigDecimal PINNED_SHORTFALL = new BigDecimal("12400");
/**
* Converges the book on {@link PortfolioPlan}: seeds every planned enterprise
* that is missing, then backfills the per-enterprise rows onto any that were
* already there.
*
* <p>Written as "converge on a target" rather than "populate if empty" because
* the shape has changed once already — from 142 enterprises in one state to
* eighteen in each of six — and a first-boot-only seeder cannot express that
* on a database someone has already run.
*/
private void seedPortfolio() {
if (enterpriseRepository.count() > 0) {
backfillPortfolio();
return;
}
List<PortfolioPlan.Slot> plan = PortfolioPlan.slots();
reshapeIfRequested(plan);
Map<String, Sector> sectors = new HashMap<>();
sectorRepository.findAll().forEach(s -> sectors.put(s.getCode(), s));
Map<String, District> districts = new HashMap<>();
districtRepository.findAll().forEach(d -> districts.put(d.getName(), d));
districtRepository.findAll().forEach(d -> districts.put(d.getCode(), d));
List<String> bandPlan = buildBandPlan();
List<String> sectorCodes = List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL");
List<String> blocks = List.of("Sehore", "Vidisha", "Raisen", "Bhopal");
for (Pinned p : PINNED) {
save(build(p.id(), p.name(), p.nameHi(), p.sector(), p.block(), p.score(), sectors, districts));
int created = 0;
for (PortfolioPlan.Slot slot : plan) {
if (enterpriseRepository.findByEnterpriseId(slot.enterpriseId()).isPresent()) continue;
District d = districts.get(slot.districtCode());
Sector sector = sectors.get(slot.sectorCode());
if (d == null || sector == null) {
log.warn("Skipping {} — district {} or sector {} is not seeded",
slot.enterpriseId(), slot.districtCode(), slot.sectorCode());
continue;
}
for (int i = 0; i < TOTAL - PINNED.size(); i++) {
String enterpriseId = "MSME-" + (1000 + ((i * 37 + 113) % 8000));
Random r = seeded(enterpriseId);
String sectorCode = sectorCodes.get(r.nextInt(sectorCodes.size()));
String block = blocks.get(r.nextInt(blocks.size()));
String band = bandPlan.get(i);
int score = scoreForBand(r, band);
int n = r.nextInt(SeedText.PREFIX.length);
List<String> suffix = SeedText.SUFFIX.get(sectorCode);
List<String> suffixHi = SeedText.SUFFIX_HI.get(sectorCode);
int s = r.nextInt(suffix.size());
save(build(enterpriseId,
SeedText.PREFIX[n] + " " + suffix.get(s),
SeedText.PREFIX_HI[n] + " " + suffixHi.get(s),
sectorCode, block, score, sectors, districts));
save(build(slot, sector, d));
created++;
}
if (created > 0) log.info("Seeded {} enterprises across {} states", created, PortfolioPlan.REGIONS.size());
backfillPortfolio();
}
/**
* A band assignment that always totals 9 / 31 / 102 once the pinned six are
* accounted for. Shuffled with a fixed seed so the mix is stable across boots.
* Removes what the plan no longer contains — but only when explicitly asked.
*
* <p>Off by default. Narrowing the book from 142 to 108 means deleting
* enterprises and every scoring run, alert, visit and ledger entry hanging off
* them, and a seeder that does that on an ordinary restart is a seeder that
* eventually eats something a demo needed an hour before it was given. Set
* {@code udyam.seed.reshape=true} (or {@code UDYAM_SEED_RESHAPE=true}) for the
* one boot that re-shapes, then turn it off.
*/
private List<String> buildBandPlan() {
Map<String, Integer> pinnedCounts = new HashMap<>();
for (Pinned p : PINNED) {
pinnedCounts.merge(bandOf(p.score()), 1, Integer::sum);
private void reshapeIfRequested(List<PortfolioPlan.Slot> plan) {
Set<String> planned = plan.stream()
.map(PortfolioPlan.Slot::enterpriseId).collect(java.util.stream.Collectors.toSet());
List<Enterprise> strays = enterpriseRepository.findAll().stream()
.filter(e -> !planned.contains(e.getEnterpriseId()))
.toList();
if (strays.isEmpty()) return;
if (!reshapeEnabled) {
log.warn("{} enterprises on book are not in the current plan ({} planned). "
+ "Leaving them alone — set udyam.seed.reshape=true for one boot to remove them.",
strays.size(), planned.size());
return;
}
List<String> plan = new ArrayList<>();
TARGET.forEach((band, target) -> {
int remaining = target - pinnedCounts.getOrDefault(band, 0);
for (int i = 0; i < remaining; i++) plan.add(band);
});
Collections.shuffle(plan, new Random(20260804L));
return plan;
log.warn("Reshape enabled — removing {} enterprises no longer in the plan, with their dependent rows",
strays.size());
for (Enterprise e : strays) {
portfolioPurge.purge(e.getEnterpriseId());
}
enterpriseRepository.deleteAll(strays);
log.warn("Reshape complete — {} enterprises removed", strays.size());
}
private Enterprise build(PortfolioPlan.Slot slot, Sector sector, District d) {
Random r = seeded(slot.enterpriseId());
String name;
String nameHi;
int score;
if (slot.pinned()) {
name = slot.pinnedName();
nameHi = slot.pinnedNameHi();
score = slot.pinnedScore();
} else {
// Names drawn from the state's own pool, so a Bihar enterprise is not
// called after a Madhya Pradesh river.
String[] prefixes = SeedText.prefixesFor(slot.state());
String[] prefixesHi = SeedText.prefixesHiFor(slot.state());
int n = r.nextInt(Math.min(prefixes.length, prefixesHi.length));
List<String> suffix = SeedText.SUFFIX.get(slot.sectorCode());
List<String> suffixHi = SeedText.SUFFIX_HI.get(slot.sectorCode());
int s = r.nextInt(suffix.size());
name = prefixes[n] + " " + suffix.get(s);
nameHi = prefixesHi[n] + " " + suffixHi.get(s);
score = scoreForBand(r, slot.band());
}
private Enterprise build(String enterpriseId, String name, String nameHi, String sectorCode, String block,
int score, Map<String, Sector> sectors, Map<String, District> districts) {
Random r = seeded(enterpriseId);
District d = districts.get(block);
return Enterprise.builder()
.enterpriseId(enterpriseId)
.enterpriseId(slot.enterpriseId())
.name(name)
.nameHi(nameHi)
.sectorId(sectors.get(sectorCode).getId())
.sectorId(sector.getId())
.districtId(d.getId())
.block(block)
.block(slot.block())
// Jittered around the district centroid — a premises location, not a person.
.latitude(d.getLatitude() + (r.nextDouble() - 0.5) * 0.25)
.longitude(d.getLongitude() + (r.nextDouble() - 0.5) * 0.25)
@@ -643,15 +956,28 @@ public class UdyamDataSeeder implements CommandLineRunner {
}
/**
* Thirty days of ledger history so the app is never empty, plus an app login
* for the enterprise. Only for the pinned six — generating 142 × ~60 entries
* would slow first boot for no demo value.
* Five months of ledger history for every enterprise, plus a PWA login for the
* ones the demo signs into.
*
* <p>This used to cover only the six pinned enterprises, on the reasoning that
* generating entries for the whole book would slow first boot for no demo
* value. That reasoning was wrong in a way that only showed up downstream: the
* deterministic forecast takes its baseline from the ledger, so an enterprise
* without one produces no {@code dscr_p10}, four liquidity features sat at
* about four percent coverage, and the console had to be told to trust seeded
* forecast rows to look populated at all. Ledger coverage is what lets that
* flag go off.
*
* <p>At 108 enterprises this is roughly 30,000 rows on a first boot — seconds,
* once, against a demo database.
*/
private void seedLedger(Enterprise e) {
boolean pinned = PINNED.stream().anyMatch(p -> p.id().equals(e.getEnterpriseId()));
if (!pinned) return;
if (ledgerRepository.countByEnterpriseId(e.getEnterpriseId()) > 0) return;
if (userRepository.findByEnterpriseIdAndScopeIgnoreCase(e.getEnterpriseId(), "ENTERPRISE").isEmpty()) {
// A login only for the pinned six: those are the ones the demo actually
// signs into, and 108 dormant credentials is a worse default than six.
boolean pinned = PortfolioPlan.pinnedIds().contains(e.getEnterpriseId());
if (pinned && userRepository.findByEnterpriseIdAndScopeIgnoreCase(e.getEnterpriseId(), "ENTERPRISE").isEmpty()) {
userRepository.save(UdyamUser.builder()
.username(e.getEnterpriseId().toLowerCase())
.fullName(e.getName())
@@ -665,17 +991,122 @@ public class UdyamDataSeeder implements CommandLineRunner {
String[] outCats = {"INPUT", "WAGES", "TRANSPORT", "EMI"};
List<LedgerEntry> entries = new ArrayList<>();
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;
// The ledger is anchored to the enterprise's own obligation and band
// rather than drawn from a flat range. Amounts unrelated to the instalment
// would give a RED enterprise a comfortable computed DSCR, so the band on
// the dashboard and the forecast underneath it would contradict each other
// — and with seeded forecasts no longer trusted, the forecast is what the
// console shows. Coverage alone is not enough; the series has to mean
// something.
double obligation = Math.max(2_000, e.getMonthlyInstalment().doubleValue());
// STANDARD sits well clear of the instalment rather than just above it.
// The headroom is not cosmetic. With no output-price feed for egg or milk
// — there is no centralised source for either — the forecast runs on input
// costs alone, which puts the spread at 0.28 and a month-three P10 at
// about 0.52 of P50. A healthy enterprise at 1.95× coverage would land on
// 1.01 and trip TW-01 on rounding. The band has to clear roughly 1.94 for
// the rule to mean anything, so the healthy band starts well above it —
// which is also what a genuinely sound microenterprise looks like.
// Each band has to sit clear of the line the rules actually test, not just
// on the intuitive side of it.
//
// Two multipliers stand between coverage and a trip-wire: margin drift,
// which scales P50 by as little as 0.70 when input costs are running at
// the drift cap, and the P10 spread, about 0.515 at month three with no
// output-price feed. WATCH at 1.18 looked comfortably above 1.0 and was
// not — after drift its P50 landed on 1.01, so a third of the watch band
// tripped the red rule and the band all but disappeared. These values are
// chosen so RED fails P50 and WATCH fails only P10, with margin either
// side rather than a rounding error.
double coverage = switch (e.getBand()) {
case "RED" -> 0.78; // the central case cannot cover the instalment
case "WATCH" -> 1.55; // covers it centrally; the downside does not
default -> 2.75;
};
// Deteriorating for the stressed bands, mildly improving otherwise, so the
// trailing window a forecast reads has a direction and not just a level.
double monthlyDrift = switch (e.getBand()) {
case "RED" -> -0.055;
case "WATCH" -> -0.02;
default -> 0.012;
};
// Outflow scales with the band too, so total inflow does — which is what
// TW-04 reads as emi_to_inflow. A single flat ratio put every enterprise
// near 0.2 to 0.3 and TW-04 could never fire, leaving TW-01 as the only
// rule that ever produced an alert. A stressed enterprise turning over
// little more than twice its instalment is both realistic and the case
// TW-04 exists to catch.
double outflowRatio = switch (e.getBand()) {
case "RED" -> 1.35 + r.nextDouble() * 0.25;
case "WATCH" -> 1.90 + r.nextDouble() * 0.35;
default -> 2.10 + r.nextDouble() * 0.60;
};
// Roughly one enterprise in twenty has gone quiet. Deterministic, so the
// same ones are silent on every boot and the demo can be talked through
// twice. Kept under 27 days so at most one synthetic month is short and
// the median baseline the forecast reads still has whole months either
// side of it. The pinned six are never silent — they are the ones the
// demo signs into.
int silentDays = 0;
if (!pinned && Math.floorMod(e.getEnterpriseId().hashCode(), 20) == 0) {
silentDays = 16 + seeded(e.getEnterpriseId() + ":silent").nextInt(11);
}
// Month by month, not day by day, and each month's total is distributed
// across its days by weights that sum to one.
//
// Jittering each day independently looks equivalent and is not: at ±35% a
// day, a month of thirty days carries a standard deviation of roughly a
// third of the instalment, while the drift being modelled is one to five
// percent a month. The drift is then invisible under the noise — a
// STANDARD enterprise's cash position trends *downwards* about as often as
// up. Normalised weights keep the daily variation and make the monthly
// total exact, which is the number every downstream feature reads.
int monthCount = LEDGER_DAYS / 30;
for (int monthIndex = 1; monthIndex <= monthCount; monthIndex++) {
// monthIndex 1 is the oldest whole month, so drift runs forward.
int newestDay = LEDGER_DAYS - monthIndex * 30;
int oldestDay = newestDay + 29;
// Drift accumulates towards the present: the oldest whole month is the
// undrifted baseline, the newest carries the full deterioration.
double netThisMonth = obligation * coverage * (1 + monthlyDrift * (monthIndex - 1));
double outThisMonth = obligation * outflowRatio;
double inThisMonth = netThisMonth + outThisMonth;
double[] inWeights = weights(r, 30);
double[] outWeights = weights(r, 30);
for (int i = 0; i < 30; i++) {
int d = oldestDay - i;
if (d < 0) continue;
// The silent cohort simply stops recording. Without it every
// enterprise reports right up to today, days_since_last_entry is
// zero across the book and TW-03 can never fire — leaving the
// rules screen showing one rule doing all the work, which is a
// poor advertisement for a rule engine.
if (d < silentDays) continue;
LocalDate date = LocalDate.now().minusDays(d);
entries.add(LedgerEntry.builder()
.clientId("seed-" + e.getEnterpriseId() + "-" + d + "-" + i)
.clientId("seed-" + e.getEnterpriseId() + "-" + d + "-in")
.enterpriseId(e.getEnterpriseId())
.kind(in ? "IN" : "OUT")
.amount(BigDecimal.valueOf((in ? 6 + r.nextInt(47) : 4 + r.nextInt(35)) * 100L))
.category(in ? inCats[r.nextInt(inCats.length)] : outCats[r.nextInt(outCats.length)])
.entryDate(LocalDate.now().minusDays(d))
.kind("IN")
.amount(money(Math.max(100, inThisMonth * inWeights[i])))
.category(inCats[r.nextInt(inCats.length)])
.entryDate(date)
.syncedAt(LocalDateTime.now())
.build());
entries.add(LedgerEntry.builder()
.clientId("seed-" + e.getEnterpriseId() + "-" + d + "-out")
.enterpriseId(e.getEnterpriseId())
.kind("OUT")
.amount(money(Math.max(100, outThisMonth * outWeights[i])))
.category(outCats[r.nextInt(outCats.length)])
.entryDate(date)
.syncedAt(LocalDateTime.now())
.build());
}
@@ -714,6 +1145,21 @@ public class UdyamDataSeeder implements CommandLineRunner {
return new Random(key.hashCode());
}
/**
* {@code n} positive weights summing to exactly one — the day-to-day variation
* within a month, without moving the month's total.
*/
private double[] weights(Random r, int n) {
double[] w = new double[n];
double sum = 0;
for (int i = 0; i < n; i++) {
w[i] = 0.4 + r.nextDouble();
sum += w[i];
}
for (int i = 0; i < n; i++) w[i] /= sum;
return w;
}
private BigDecimal money(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP);
}
+58 -4
View File
@@ -26,7 +26,11 @@ spring.datasource.hikari.auto-commit=true
# throw NoHandlerFoundException for 404s
spring.mvc.throw-exception-if-no-handler-found=true
jwt.secret=55647961_6D4C_696E_6320_4E41_4241_5244_5F32_3032_365F_4C69_6E63_4675_7369_6F6E_5F48_6163_6B61_74686F6E
# Demo signing key, overridable by environment. The default is committed on
# purpose so a fresh clone boots without setup — which also means it is public,
# and therefore not a secret. Any deployment carrying real borrower data must set
# UDYAM_JWT_SECRET; a key readable from the repository signs anyone's token.
jwt.secret=${UDYAM_JWT_SECRET:55647961_6D4C_696E_6320_4E41_4241_5244_5F32_3032_365F_4C69_6E63_4675_7369_6F6E_5F48_6163_6B61_74686F6E}
# 24 hours
jwt.expiration=86400000
@@ -40,6 +44,26 @@ udyam.forecast.horizon-months=6
udyam.risk.red-threshold=65
udyam.risk.watch-threshold=35
# ── Aggregate suppression ───────────────────────────────────
# Fewest borrowers in a cell before its numbers may be published. Below this the
# cell reports "suppressed" rather than a rounded figure, because a district of
# two borrowers and one alert names that borrower as surely as printing them.
udyam.apex.min-cell-size=5
# The same threshold for a Regional Office session, separated so it can be tuned
# without touching the national one.
#
# It defaults to the SAME value on purpose. Suppression protects against
# re-identifying a borrower from an aggregate, and that risk turns on what the
# reader already knows — a regional officer, supervising four districts and on
# first-name terms with the banks in them, can re-identify from a cell of three
# far more easily than someone reading a national table. So the argument from
# proximity runs the other way: if these two ever diverge, the regional figure
# has a better case for being HIGHER than the national one, not lower.
#
# Lower it only if a supervisor has decided that trade-off deliberately, and
# knows that a small office's sector cells are where it will show.
udyam.apex.min-cell-size.regional-office=5
# ── Risk & rule engine (ARCHITECTURE Phase D) ───────────────
# Which ScoringStrategy binds. RULE today; MODEL when the LightGBM heads land —
# that swap is this line, not a rewrite.
@@ -54,9 +78,16 @@ udyam.risk.rule-confidence-ceiling=70
udyam.risk.scheduled=${UDYAM_SCORING_ENABLED:false}
udyam.risk.cron=0 0 3 * * *
# Seed the demo portfolio (142 enterprises) on first boot
# Seed the demo portfolio on first boot: six states, one per NABARD zone, each
# carrying 18 enterprises across the five sector packs (108 in all).
udyam.seed.enabled=true
# Destructive. When true, the seeder deletes any enterprise not in PortfolioPlan
# along with its runs, alerts, visits, ledger and loan rows. Needed for exactly
# one boot when the portfolio's shape changes — the move from 142 Madhya Pradesh
# enterprises to 108 across six states is such a change. Turn it off again after.
udyam.seed.reshape=${UDYAM_SEED_RESHAPE:false}
# ── Ingestion (ARCHITECTURE Phase B) ────────────────────────
# Off by default: a demo boot must never call a government endpoint. Turn on for
# the pilot, or trigger a one-off pass with POST /admin/ingestion/run.
@@ -77,6 +108,11 @@ udyam.ingestion.datagov.base-url=https://api.data.gov.in/resource
# districts appear.
udyam.ingestion.datagov.resource-id=${DATAGOV_RESOURCE_ID:35985678-0d79-46b4-9ed6-6f13308a1d24}
udyam.ingestion.datagov.page-limit=1000
# Page ceiling per (district, commodity). Only reached when a run asks for a deep
# lookback — POST /admin/ingestion/run/AGMARKNET?lookbackWeeks=156 is the one-off
# three-year backfill that gives price_z_score its seasonal mean. 40 pages of
# 1000 rows is ample for three years of one district's arrivals.
udyam.ingestion.datagov.max-pages=40
# Filter names must match the resource's own field names; they differ by resource
# version. This resource capitalises them, and — unlike the current-price
# resource — matches exactly on the bare name. Appending ".keyword" here returns
@@ -89,6 +125,13 @@ udyam.ingestion.datagov.field.commodity=Commodity
# side, so without a descending sort a 1000-row page is mostly years-old data and
# every commodity reports zero weeks written.
udyam.ingestion.datagov.sort-field=Arrival_Date
# data.gov.in meters by quota per window, so the limit is reached by the run and
# not by any one call — once it trips, every remaining pair in that run fails
# together and the whole ingestion records zero rows. Verified 2026-08-11: a
# 62-pair run straight after a 62-pair probe returned 429 on all 62. The backoff
# is long because the window is measured in minutes, not seconds.
udyam.ingestion.datagov.retry-attempts=3
udyam.ingestion.datagov.retry-backoff-ms=45000
udyam.ingestion.mandi.lookback-weeks=12
# Climate — Open-Meteo reanalysis archive. No key. The archive trails real time,
@@ -136,5 +179,16 @@ udyam.forecast.max-drift=0.30
# the forecast cannot reach report NO_FORECAST, and TW-01 skip rather than fire
# on a number nothing computed. Set false before any run whose alerts are meant
# to be believed.
udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:true}
udyam.apex.min-cell-size=5
# Now false. It was true only because ledger coverage reached six enterprises of
# 142, so without the fallback the console had almost no forecasts to show and 34
# trip-wires of which 5 were real. The seeder now writes five months of ledger for
# every enterprise, band-coherent — a RED enterprise's monthly net genuinely sits
# below its instalment — so the computed forecast is the one worth showing.
udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:false}
# Furthest horizon TW-01 may act on. The six-month fan is still forecast, stored
# and drawn; this bounds only what the trip-wire fires from. Past month three the
# band is dominated by compounding uncertainty rather than by anything specific
# to the enterprise — at the 0.75 ceiling P10 is a quarter of P50 for everyone,
# so a worst-of-six rule separates nobody from anybody.
udyam.forecast.tripwire-horizon=3