diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java
index 3de118f..4f6c044 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java
@@ -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.
+ *
+ *
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,
diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java
index 9ee1854..b8625ec 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java
@@ -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.
+ *
+ *
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 quantile) {
List 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 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);
diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java
index 305b51b..7e8705a 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java
@@ -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);
diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java
index b6b00aa..bb375e7 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java
@@ -17,4 +17,6 @@ public interface FeatureSnapshotRepository extends JpaRepository findByScoringRunId(Long scoringRunId);
long countByAsOfDate(LocalDate asOfDate);
+
+ List findByEnterpriseId(String enterpriseId);
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java
index 4eec6ec..732bbb6 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java
@@ -31,4 +31,6 @@ public interface FeatureValueRepository extends JpaRepository findByEnterpriseId(String enterpriseId);
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java b/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java
index 57a115a..820ed17 100644
--- a/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java
@@ -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;
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java
index ae493d4..d6cff7c 100644
--- a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java
@@ -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.
+ *
+ * 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;
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java b/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java
index 9f3c38f..bc47df0 100644
--- a/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java
@@ -10,4 +10,6 @@ public interface ScoringRunRepository extends JpaRepository {
Optional findFirstByEnterpriseIdOrderByRunAtDesc(String enterpriseId);
List findTop20ByOrderByRunAtDesc();
+
+ List findByEnterpriseId(String enterpriseId);
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java
index 1956b80..b3f6330 100644
--- a/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java
+++ b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java
@@ -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 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;
diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java
new file mode 100644
index 0000000..492beda
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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 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.
+ *
+ * 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.
+ *
+ *
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 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 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 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.
+ *
+ * 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 slots() {
+ List out = new ArrayList<>();
+
+ for (int regionIndex = 0; regionIndex < REGIONS.size(); regionIndex++) {
+ Region region = REGIONS.get(regionIndex);
+ List 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 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 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.
+ *
+ * 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.
+ *
+ *
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 dealBands(List pending, Deque bands) {
+ Map> bySector = new LinkedHashMap<>();
+ for (Slot s : pending) {
+ bySector.computeIfAbsent(s.sectorCode(), k -> new ArrayDeque<>()).add(s);
+ }
+ List out = new ArrayList<>(pending.size());
+ while (out.size() < pending.size()) {
+ for (Deque 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 remainingBands(List pinnedHere, String state) {
+ int total = Arrays.stream(perSector(state)).sum();
+ Map 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 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.
+ *
+ * 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 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 pinnedIds() {
+ return PINNED_IDS;
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java
new file mode 100644
index 0000000..6da9cd6
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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);
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java b/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java
index fb2985f..e93a276 100644
--- a/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java
+++ b/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java
@@ -28,19 +28,73 @@ final class SeedText {
"भगीरथ", "मालवा", "सहयोग", "एकता", "प्रगति", "सुविधा", "अनमोल", "निर्मल", "वैभव",
};
+ /**
+ * Name prefixes drawn from each state's own rivers, ranges and landmarks.
+ *
+ *
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 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 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> 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> 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> 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 = {
diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java
index 96aa9e5..5deb0d3 100644
--- a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java
+++ b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java
@@ -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 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 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.
+ *
+ * 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());
- }
-
- 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();
+ int written = 0, updated = 0;
+ for (PortfolioPlan.Region region : PortfolioPlan.REGIONS) {
+ for (PortfolioPlan.DistrictSpec spec : region.districts()) {
+ Optional 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++;
+ }
+ 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.
+ *
+ * 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.
+ *
+ *
{@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 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 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 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 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")
- .name(d.getName() + " Krishi Upaj Mandi")
- .sourceName(d.getName())
- .districtId(d.getId())
- .primaryMarket(true)
- .active(true)
- .build()));
- log.info("Seeded {} markets", marketRepository.count());
+ 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.feedDistrict())
+ .districtId(d.getId())
+ .primaryMarket(true)
+ .active(true)
+ .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.
+ *
+ * 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 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}.
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
{@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 wanted) {
+ int written = 0, corrected = 0;
+ for (TripWireRule want : wanted) {
+ Optional 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 = 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 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.
+ *
+ * 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 plan = PortfolioPlan.slots();
+ reshapeIfRequested(plan);
Map sectors = new HashMap<>();
sectorRepository.findAll().forEach(s -> sectors.put(s.getCode(), s));
Map districts = new HashMap<>();
- districtRepository.findAll().forEach(d -> districts.put(d.getName(), d));
+ districtRepository.findAll().forEach(d -> districts.put(d.getCode(), d));
- List bandPlan = buildBandPlan();
- List sectorCodes = List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL");
- List 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;
+ }
+ save(build(slot, sector, d));
+ created++;
}
+ if (created > 0) log.info("Seeded {} enterprises across {} states", created, PortfolioPlan.REGIONS.size());
- 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 suffix = SeedText.SUFFIX.get(sectorCode);
- List 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));
- }
+ 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.
+ *
+ * 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 buildBandPlan() {
- Map pinnedCounts = new HashMap<>();
- for (Pinned p : PINNED) {
- pinnedCounts.merge(bandOf(p.score()), 1, Integer::sum);
+ private void reshapeIfRequested(List plan) {
+ Set planned = plan.stream()
+ .map(PortfolioPlan.Slot::enterpriseId).collect(java.util.stream.Collectors.toSet());
+ List 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 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(String enterpriseId, String name, String nameHi, String sectorCode, String block,
- int score, Map sectors, Map districts) {
- Random r = seeded(enterpriseId);
- District d = districts.get(block);
+ 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 suffix = SeedText.SUFFIX.get(slot.sectorCode());
+ List 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());
+ }
+
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.
+ *
+ * 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.
+ *
+ *
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 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);
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 0d59724..e244f50 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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