diff --git a/src/main/java/com/lincfusion/udyamlinc/ml/MlClient.java b/src/main/java/com/lincfusion/udyamlinc/ml/MlClient.java
new file mode 100644
index 0000000..fa0a1c4
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/ml/MlClient.java
@@ -0,0 +1,99 @@
+package com.lincfusion.udyamlinc.ml;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * The one call out to the Python inference service.
+ *
+ *
Never throws to the caller. Every method returns an {@link Optional},
+ * empty on any failure — connection refused, timeout, a 500, a malformed body.
+ * That is deliberate: the caller is a scoring run, and a run that fails because
+ * an inference service is unreachable is worse than a run that scores on rules
+ * and says which engine it used. The failure is logged once per call, not per
+ * enterprise, so a service being down does not produce 263 identical stack traces.
+ */
+@Component
+@Slf4j
+public class MlClient {
+
+ private final MlProperties properties;
+ private final RestClient http;
+
+ public MlClient(MlProperties properties) {
+ this.properties = properties;
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(Duration.ofMillis(properties.getTimeoutMs()));
+ factory.setReadTimeout(Duration.ofMillis(properties.getBatchTimeoutMs()));
+ this.http = RestClient.builder()
+ .baseUrl(properties.getBaseUrl())
+ .requestFactory(factory)
+ .build();
+ }
+
+ public boolean enabled() {
+ return properties.isEnabled();
+ }
+
+ /** What the service is serving — becomes {@code scoring_run.model_version}. */
+ public Optional> modelInfo() {
+ return call(() -> http.get().uri("/model").retrieve().body(Map.class), "/model");
+ }
+
+ public Optional score(Map request) {
+ return call(() -> http.post().uri("/score").body(request).retrieve().body(MlScore.class), "/score");
+ }
+
+ /** One call for the nightly run, rather than one per enterprise. */
+ @SuppressWarnings("unchecked")
+ public Optional> scoreBatch(List> items) {
+ return call(() -> http.post().uri("/score/batch")
+ .body(Map.of("items", items))
+ .retrieve()
+ .body(List.class), "/score/batch");
+ }
+
+ @SuppressWarnings("unchecked")
+ private Optional call(java.util.function.Supplier supplier, String what) {
+ if (!properties.isEnabled()) return Optional.empty();
+ try {
+ return Optional.ofNullable((T) supplier.get());
+ } catch (Exception e) {
+ log.warn("ML service {} unavailable ({}) — falling back to the rule engine",
+ what, e.getClass().getSimpleName());
+ return Optional.empty();
+ }
+ }
+
+ public Optional forecast(Map request) {
+ return call(() -> http.post().uri("/forecast").body(request).retrieve().body(MlForecast.class),
+ "/forecast");
+ }
+
+ /** Mirrors the FastAPI response, field for field. */
+ public record MlScore(
+ String enterpriseId,
+ int score,
+ String band,
+ int confidence,
+ double probability,
+ Map subScores,
+ List drivers,
+ int familiesOnPrior,
+ String engine,
+ String modelVersion) {}
+
+ public record MlDriver(String family, int weight, String featureCode, Double value, boolean onPrior) {}
+
+ public record MlForecast(String enterpriseId, String engine, String modelVersion,
+ List points) {}
+
+ public record MlForecastPoint(int horizon, double p10, double p50, double p90) {}
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/ml/MlProperties.java b/src/main/java/com/lincfusion/udyamlinc/ml/MlProperties.java
new file mode 100644
index 0000000..920c686
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/ml/MlProperties.java
@@ -0,0 +1,32 @@
+package com.lincfusion.udyamlinc.ml;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * Where the inference service is, and how long we are willing to wait for it.
+ *
+ * Timeouts are short on purpose. A scoring run over the whole book must finish
+ * inside its window, and an inference service that has become slow is
+ * operationally the same as one that is down — both mean "score this enterprise
+ * on rules and move on".
+ */
+@Component
+@ConfigurationProperties(prefix = "udyam.ml")
+@Getter
+@Setter
+public class MlProperties {
+
+ /** Off by default: a checkout with no Python running must still boot and score. */
+ private boolean enabled = false;
+
+ private String baseUrl = "http://127.0.0.1:8000";
+
+ /** Per-enterprise call. */
+ private int timeoutMs = 2000;
+
+ /** Batch call over the book — larger, because it is one call for 263 rows. */
+ private int batchTimeoutMs = 30000;
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ModelForecastStrategy.java b/src/main/java/com/lincfusion/udyamlinc/risk/ModelForecastStrategy.java
new file mode 100644
index 0000000..48d9d1b
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ModelForecastStrategy.java
@@ -0,0 +1,194 @@
+package com.lincfusion.udyamlinc.risk;
+
+import com.lincfusion.udyamlinc.enterprise.Enterprise;
+import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
+import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
+import com.lincfusion.udyamlinc.features.FeatureCatalog;
+import com.lincfusion.udyamlinc.features.FeatureValue;
+import com.lincfusion.udyamlinc.master.Sector;
+import com.lincfusion.udyamlinc.ml.MlClient;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.util.*;
+
+/**
+ * The quantile heads, arriving through the seam built for them.
+ *
+ *
Selected by {@code udyam.forecast.engine=MODEL}. Answers the half of the
+ * problem statement the rule engine never could: "AI-Driven Cash Flow
+ * Prediction " was, until this existed, a median of past months with a band
+ * widening as sqrt(h) — defensible, explainable, and not a model.
+ *
+ *
What crosses the boundary
+ *
+ * The feature map, and five trailing statistics computed here from the
+ * ledger. The inference service holds no database credentials and cannot read a
+ * borrower's ledger even if asked — this class already reads it for the
+ * deterministic path, so the query is one that was being made anyway.
+ *
+ *
Fallback
+ *
+ * Unreachable service, or fewer than three months of ledger, and this returns
+ * an empty list — which {@code RiskEngineService} already treats as "not enough
+ * to forecast from", leaving {@code dscr_p10} null with {@code NO_FORECAST}. That
+ * is the honest answer and is visibly different from a forecast saying the
+ * enterprise is fine.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class ModelForecastStrategy implements ForecastStrategy {
+
+ /** Trailing window the trainer used. Changing it here silently degrades the model. */
+ private static final int TRAIL_MONTHS = 6;
+ private static final int MIN_MONTHS = 3;
+
+ private static final List SENT = List.of(
+ FeatureCatalog.FEED_COST_CHANGE,
+ FeatureCatalog.OUTPUT_PRICE_CHANGE,
+ FeatureCatalog.MARGIN_SQUEEZE,
+ FeatureCatalog.PRICE_Z_SCORE,
+ FeatureCatalog.RAINFALL_ANOMALY,
+ FeatureCatalog.HEAT_ANOMALY,
+ FeatureCatalog.CASH_FLOW_CHANGE,
+ FeatureCatalog.EMI_DELAY,
+ FeatureCatalog.INVENTORY_DAYS,
+ FeatureCatalog.FEED_STALENESS_DAYS,
+ FeatureCatalog.DAYS_SINCE_LAST_ENTRY,
+ FeatureCatalog.EMI_TO_INFLOW);
+
+ private final MlClient client;
+ private final LedgerEntryRepository ledgerRepository;
+ private final DeterministicForecastStrategy fallback;
+
+ @Override
+ public String engine() {
+ return "MODEL";
+ }
+
+ @Override
+ public String version() {
+ return "lightgbm-forecast-v1";
+ }
+
+ @Override
+ public List forecast(Enterprise enterprise, Sector sector,
+ Map features, LocalDate asOfDate) {
+
+ Trailing trail = trailing(enterprise.getEnterpriseId(), asOfDate);
+ if (trail.months() < MIN_MONTHS) {
+ // Not the model's decision to make — the same floor the deterministic
+ // strategy uses, so switching engines does not change who gets a forecast.
+ return List.of();
+ }
+
+ Optional answer = client.forecast(request(enterprise, features, trail));
+ if (answer.isEmpty()) {
+ log.debug("Forecasting {} deterministically — model unavailable", enterprise.getEnterpriseId());
+ return fallback.forecast(enterprise, sector, features, asOfDate);
+ }
+
+ BigDecimal obligation = enterprise.getMonthlyInstalment() == null
+ ? BigDecimal.ZERO
+ : enterprise.getMonthlyInstalment();
+
+ List points = new ArrayList<>();
+ for (MlClient.MlForecastPoint p : answer.get().points()) {
+ YearMonth month = YearMonth.from(asOfDate).plusMonths(p.horizon());
+ BigDecimal p10 = money(p.p10());
+ points.add(ForecastPoint.builder()
+ .enterpriseId(enterprise.getEnterpriseId())
+ .horizon(p.horizon())
+ .month(month.toString())
+ .p10(p10)
+ .p50(money(p.p50()))
+ .p90(money(p.p90()))
+ .obligation(obligation)
+ .breach(p10.compareTo(obligation) < 0)
+ .engine(engine())
+ .modelVersion(version())
+ .missingInputs(missingCount(features))
+ .build());
+ }
+ return points;
+ }
+
+ private Map request(Enterprise enterprise, Map features, Trailing t) {
+ Map values = new HashMap<>();
+ for (String code : SENT) {
+ FeatureValue row = features.get(code);
+ values.put(code, row == null || row.getValue() == null ? null : row.getValue().doubleValue());
+ }
+
+ Map body = new HashMap<>();
+ body.put("enterpriseId", enterprise.getEnterpriseId());
+ body.put("sectorId", enterprise.getSectorId());
+ body.put("districtId", enterprise.getDistrictId());
+ body.put("features", values);
+ body.put("trailMean", t.mean());
+ body.put("trailSd", t.sd());
+ body.put("trailMin", t.min());
+ body.put("trailInflow", t.inflow());
+ body.put("trailMonths", (double) t.months());
+ body.put("emi", enterprise.getMonthlyInstalment() == null
+ ? null : enterprise.getMonthlyInstalment().doubleValue());
+ return body;
+ }
+
+ /**
+ * Monthly net position over the trailing window, point-in-time.
+ *
+ * Whole months only, and never the month {@code asOfDate} falls in — a
+ * partial month reads as a collapse in income and would teach the model that
+ * every enterprise fails on the first of the month.
+ */
+ private Trailing trailing(String enterpriseId, LocalDate asOfDate) {
+ YearMonth current = YearMonth.from(asOfDate);
+ YearMonth from = current.minusMonths(TRAIL_MONTHS);
+
+ Map byMonth = new TreeMap<>();
+ for (LedgerEntry e : ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId)) {
+ if (e.getEntryDate() == null || e.getAmount() == null) continue;
+ YearMonth m = YearMonth.from(e.getEntryDate());
+ if (!m.isBefore(current) || m.isBefore(from)) continue;
+ double[] cell = byMonth.computeIfAbsent(m, k -> new double[2]);
+ double amount = e.getAmount().doubleValue();
+ if ("IN".equals(e.getKind())) {
+ cell[0] += amount;
+ cell[1] += amount;
+ } else if ("OUT".equals(e.getKind())) {
+ cell[0] -= amount;
+ }
+ }
+ if (byMonth.isEmpty()) return new Trailing(0, null, null, null, null);
+
+ double[] nets = byMonth.values().stream().mapToDouble(c -> c[0]).toArray();
+ double mean = Arrays.stream(nets).average().orElse(0);
+ double variance = Arrays.stream(nets).map(n -> (n - mean) * (n - mean)).average().orElse(0);
+ double inflow = byMonth.values().stream().mapToDouble(c -> c[1]).average().orElse(0);
+
+ return new Trailing(nets.length, mean, Math.sqrt(variance),
+ Arrays.stream(nets).min().orElse(0), inflow);
+ }
+
+ private int missingCount(Map features) {
+ int missing = 0;
+ for (String code : SENT) {
+ FeatureValue row = features.get(code);
+ if (row == null || row.getValue() == null) missing++;
+ }
+ return missing;
+ }
+
+ private BigDecimal money(double value) {
+ return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
+ }
+
+ private record Trailing(int months, Double mean, Double sd, Double min, Double inflow) {}
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetric.java b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetric.java
new file mode 100644
index 0000000..61556a7
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetric.java
@@ -0,0 +1,85 @@
+package com.lincfusion.udyamlinc.risk;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * One measured number about a model, and everything needed to read it honestly.
+ *
+ * Why this table exists
+ *
+ * The Reports page used to render 0.87 AUC from a constant in
+ * lib/demo/ops.ts. Nothing computed it. A juror asking "how did you
+ * arrive at that?" would have been shown a literal. Every figure the console
+ * publishes about model quality now comes from a row here, written by the
+ * backtest that produced it.
+ *
+ *
Provenance travels with the number
+ *
+ * {@code syntheticLabelShare} is not decoration. Every DPD label on this book
+ * is generated, so a metric computed against it measures the generator as much
+ * as the borrowers. Storing the share on the same row makes it impossible to
+ * quote the accuracy without also quoting what it was measured on — the console
+ * reads both from one record.
+ *
+ *
Baselines are rows too
+ *
+ * {@code arm} names what was measured: an algorithm, or a single-feature
+ * baseline. AUC 0.76 is either a real result or a restatement of "a borrower who
+ * is late today is late in three months", and only the comparison says which.
+ * The baselines sit beside the model rather than in a separate table, so a chart
+ * cannot show one without the other being available.
+ */
+@Entity
+@Table(name = "model_metric", indexes = {
+ @Index(name = "ix_model_metric_run", columnList = "computedAt"),
+ @Index(name = "ix_model_metric_arm", columnList = "arm")
+})
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ModelMetric {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /** The artifact this describes — matches {@code scoring_run.model_version}. */
+ @Column(nullable = false, length = 64)
+ private String modelVersion;
+
+ /** lightgbm | xgboost | repayment_history_only | obligation_ratio_only */
+ @Column(nullable = false, length = 64)
+ private String arm;
+
+ /** True for a single-feature comparison arm rather than a trained model. */
+ @Column(nullable = false)
+ private boolean baseline;
+
+ /** auc_roc | pr_auc | precision_red | recall_red | brier */
+ @Column(nullable = false, length = 40)
+ private String metric;
+
+ @Column(nullable = false, precision = 10, scale = 5)
+ private BigDecimal value;
+
+ // ── what it was measured on ──────────────────────────────
+
+ private Integer datasetRows;
+ private Integer datasetPositives;
+
+ /** 1.0 = every label generated. Read this before reading the value above. */
+ @Column(precision = 5, scale = 4)
+ private BigDecimal syntheticLabelShare;
+
+ @Column(length = 120)
+ private String validation;
+
+ @Column(nullable = false)
+ private LocalDateTime computedAt;
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricController.java b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricController.java
new file mode 100644
index 0000000..ec7b04b
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricController.java
@@ -0,0 +1,56 @@
+package com.lincfusion.udyamlinc.risk;
+
+import com.lincfusion.udyamlinc.common.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * What the model actually scored, for the Reports page.
+ *
+ *
Serves the latest backtest only. Metrics from two runs are not comparable
+ * on one screen — a chart mixing them would show a model beating itself.
+ */
+@RestController
+@RequestMapping("/admin/model")
+@RequiredArgsConstructor
+public class ModelMetricController {
+
+ private final ModelMetricRepository repository;
+
+ @GetMapping("/metrics")
+ public ApiResponse> metrics() {
+ var latest = repository.findFirstByOrderByComputedAtDesc().orElse(null);
+ if (latest == null) {
+ // No backtest has been run. The console shows that in words rather
+ // than falling back to a number, which is how 0.87 survived so long.
+ return ApiResponse.ok(Map.of("available", false,
+ "message", "No backtest has been run against this database yet."));
+ }
+
+ List rows = repository.findByComputedAtOrderByArmAscMetricAsc(latest.getComputedAt());
+
+ Map> byArm = new LinkedHashMap<>();
+ for (ModelMetric row : rows) {
+ Map arm = byArm.computeIfAbsent(row.getArm(), k -> new LinkedHashMap<>());
+ arm.put("baseline", row.isBaseline());
+ arm.put(row.getMetric(), row.getValue());
+ }
+
+ Map body = new LinkedHashMap<>();
+ body.put("available", true);
+ body.put("modelVersion", latest.getModelVersion());
+ body.put("computedAt", latest.getComputedAt());
+ body.put("validation", latest.getValidation());
+ body.put("datasetRows", latest.getDatasetRows());
+ body.put("datasetPositives", latest.getDatasetPositives());
+ body.put("syntheticLabelShare", latest.getSyntheticLabelShare());
+ body.put("arms", byArm);
+ return ApiResponse.ok(body);
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricRepository.java b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricRepository.java
new file mode 100644
index 0000000..f19462e
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricRepository.java
@@ -0,0 +1,15 @@
+package com.lincfusion.udyamlinc.risk;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+public interface ModelMetricRepository extends JpaRepository {
+
+ /** The most recent backtest. Metrics from two different runs must not be mixed. */
+ Optional findFirstByOrderByComputedAtDesc();
+
+ List findByComputedAtOrderByArmAscMetricAsc(LocalDateTime computedAt);
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ModelScoringStrategy.java b/src/main/java/com/lincfusion/udyamlinc/risk/ModelScoringStrategy.java
new file mode 100644
index 0000000..f0ea6ae
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ModelScoringStrategy.java
@@ -0,0 +1,128 @@
+package com.lincfusion.udyamlinc.risk;
+
+import com.lincfusion.udyamlinc.enterprise.Enterprise;
+import com.lincfusion.udyamlinc.features.FeatureCatalog;
+import com.lincfusion.udyamlinc.features.FeatureValue;
+import com.lincfusion.udyamlinc.master.Sector;
+import com.lincfusion.udyamlinc.ml.MlClient;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The gradient-boosted engine, arriving through the seam that was built for it.
+ *
+ * Registered as another {@link ScoringStrategy} bean; {@code RiskEngineService}
+ * resolves it by name from {@code udyam.risk.engine=MODEL}. Nothing downstream
+ * changes — the score, the sub-scores, the reason codes and the alerts are the
+ * same records the rule engine produces, because both write {@link ScoreOutcome}.
+ *
+ *
Fallback is the point, not a nicety
+ *
+ * If the inference service is unreachable, slow or wrong, this delegates to
+ * {@link RuleBasedScoringStrategy} for that enterprise and marks the outcome so a
+ * reader can tell which engine actually produced it. A nightly run must complete;
+ * a demo must survive the Python process dying mid-presentation. The alternative
+ * — a run that aborts because a sidecar is down — trades a slightly worse score
+ * for no score at all.
+ *
+ *
What crosses the boundary
+ *
+ * The feature map, the sector and district ids, and three loan-book figures.
+ * No name, no phone, no account reference — the schema does not hold them, and
+ * this service must never become the reason someone adds them.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class ModelScoringStrategy implements ScoringStrategy {
+
+ /** The features the model was trained on. Order is the service's business, not ours. */
+ private static final List SENT = List.of(
+ FeatureCatalog.FEED_COST_CHANGE,
+ FeatureCatalog.OUTPUT_PRICE_CHANGE,
+ FeatureCatalog.MARGIN_SQUEEZE,
+ FeatureCatalog.PRICE_Z_SCORE,
+ FeatureCatalog.RAINFALL_ANOMALY,
+ FeatureCatalog.HEAT_ANOMALY,
+ FeatureCatalog.CASH_FLOW_CHANGE,
+ FeatureCatalog.EMI_DELAY,
+ FeatureCatalog.INVENTORY_DAYS,
+ FeatureCatalog.UPI_INFLOW_CHANGE,
+ FeatureCatalog.FEED_STALENESS_DAYS,
+ FeatureCatalog.DAYS_SINCE_LAST_ENTRY,
+ FeatureCatalog.EMI_TO_INFLOW);
+
+ private final MlClient client;
+ private final RuleBasedScoringStrategy fallback;
+
+ @Override
+ public String engine() {
+ return "MODEL";
+ }
+
+ @Override
+ public String modelVersion() {
+ return client.modelInfo()
+ .map(info -> String.valueOf(info.getOrDefault("modelVersion", "model-unknown")))
+ .orElse("model-unavailable");
+ }
+
+ @Override
+ public ScoreOutcome score(Enterprise enterprise, Sector sector, Map features) {
+ return client.score(request(enterprise, features))
+ .map(this::toOutcome)
+ .orElseGet(() -> {
+ log.debug("Scoring {} on rules — model unavailable", enterprise.getEnterpriseId());
+ // Attributed to RULE_FALLBACK, not MODEL. The run is a model run;
+ // this score is not, and the difference has to survive to the table.
+ return fallback.score(enterprise, sector, features).attributedTo("RULE_FALLBACK");
+ });
+ }
+
+ private Map request(Enterprise enterprise, Map features) {
+ Map values = new HashMap<>();
+ for (String code : SENT) {
+ FeatureValue row = features.get(code);
+ // A null goes across as a null. The model was trained with an explicit
+ // missing-indicator per column, so "we did not have this" is a state it
+ // has seen — dropping the key would silently make it a zero.
+ values.put(code, row == null || row.getValue() == null ? null : row.getValue().doubleValue());
+ }
+
+ Map body = new HashMap<>();
+ body.put("enterpriseId", enterprise.getEnterpriseId());
+ body.put("sectorId", enterprise.getSectorId());
+ body.put("districtId", enterprise.getDistrictId());
+ body.put("features", values);
+ // DPD at the as-of date is already in the feature store as emi_delay; reading
+ // it from there keeps the request to one source of truth.
+ body.put("dpdAtT", doubleOf(features.get(FeatureCatalog.EMI_DELAY)));
+ body.put("outstandingAtT", asDouble(enterprise.getLoanOutstanding()));
+ body.put("emiAtT", asDouble(enterprise.getMonthlyInstalment()));
+ return body;
+ }
+
+ private ScoreOutcome toOutcome(MlClient.MlScore ml) {
+ List drivers = new ArrayList<>();
+ for (MlClient.MlDriver d : ml.drivers()) {
+ drivers.add(new ScoreOutcome.Driver(d.family(), d.weight(), d.featureCode(), d.value(), d.onPrior()));
+ }
+ return new ScoreOutcome(
+ ml.score(), ml.band(), ml.confidence(), ml.subScores(), drivers, ml.familiesOnPrior());
+ }
+
+ private Double doubleOf(FeatureValue row) {
+ return row == null || row.getValue() == null ? null : row.getValue().doubleValue();
+ }
+
+ private Double asDouble(BigDecimal value) {
+ return value == null ? null : value.doubleValue();
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/RiskEngineService.java b/src/main/java/com/lincfusion/udyamlinc/risk/RiskEngineService.java
index 814103d..3dffd6f 100644
--- a/src/main/java/com/lincfusion/udyamlinc/risk/RiskEngineService.java
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/RiskEngineService.java
@@ -240,7 +240,7 @@ public class RiskEngineService {
.enterpriseId(enterprise.getEnterpriseId())
.score(scored.score())
.band(band)
- .engine(strategy.engine())
+ .engine(scored.engine() != null ? scored.engine() : strategy.engine())
.confidence(scored.confidence())
.liquidity(scored.subScores().get(RuleBasedScoringStrategy.LIQUIDITY))
.behavioural(scored.subScores().get(RuleBasedScoringStrategy.BEHAVIOURAL))
diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ScoreOutcome.java b/src/main/java/com/lincfusion/udyamlinc/risk/ScoreOutcome.java
index 1117707..d80c40a 100644
--- a/src/main/java/com/lincfusion/udyamlinc/risk/ScoreOutcome.java
+++ b/src/main/java/com/lincfusion/udyamlinc/risk/ScoreOutcome.java
@@ -17,7 +17,31 @@ public record ScoreOutcome(
Map subScores,
List drivers,
/** Families that had no usable feature and fell back to the sector prior. */
- int familiesOnPrior) {
+ int familiesOnPrior,
+
+ /**
+ * What actually produced this score, when that differs from the engine the
+ * run was configured with.
+ *
+ * Null means "the configured engine", which is the normal case. It is
+ * set when {@link ModelScoringStrategy} cannot reach the inference service
+ * and answers from the rule engine instead: the run is still a MODEL run,
+ * but this score is not, and stamping the whole run MODEL would
+ * be the platform telling itself a provenance lie of exactly the kind the
+ * simulated/real distinction exists to prevent.
+ */
+ String engine) {
+
+ /** Engine unset — the score came from the strategy the run is configured with. */
+ public ScoreOutcome(int score, String band, int confidence, Map subScores,
+ List drivers, int familiesOnPrior) {
+ this(score, band, confidence, subScores, drivers, familiesOnPrior, null);
+ }
+
+ /** The same outcome, attributed to a different engine. */
+ public ScoreOutcome attributedTo(String actualEngine) {
+ return new ScoreOutcome(score, band, confidence, subScores, drivers, familiesOnPrior, actualEngine);
+ }
/**
* One family's contribution to the composite. In the rule engine this is the
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index d4451c1..7180e69 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -195,3 +195,12 @@ udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:false}
# 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
+
+# ── ML inference service ───────────────────────────────────────────
+# Off by default: a checkout with no Python running must still boot and score.
+# Turn both on together — udyam.risk.engine=MODEL without udyam.ml.enabled=true
+# would resolve the model strategy and then fall back on every enterprise.
+udyam.ml.enabled=${UDYAM_ML_ENABLED:false}
+udyam.ml.base-url=${UDYAM_ML_BASE_URL:http://127.0.0.1:8000}
+udyam.ml.timeout-ms=2000
+udyam.ml.batch-timeout-ms=30000