Files
udyamlinc-api/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java
T
LincPayandClaude Opus 5 2cb7d3b14a 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>
2026-08-15 15:29:00 +05:30

310 lines
16 KiB
Java

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;
}
}