From 6b4aec468d39efd225a590c83dfee010da1f890f Mon Sep 17 00:00:00 2001 From: LincPay Date: Tue, 25 Aug 2026 13:44:07 +0530 Subject: [PATCH] feat(risk): bind the scoring engine to the ML service, with honest attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MlClient and the two model-backed strategies behind the existing ScoringStrategy / ForecastStrategy seams, so switching engines stays a config value rather than a code change. MlClient never throws. Every call returns Optional.empty() on any failure — timeout, connection refused, malformed body — and ModelScoringStrategy falls back to the rule engine for that enterprise. A nightly run must complete, and a demo must survive the Python process dying mid-presentation. The important part is what the fallback records. A run configured MODEL used to stamp every score MODEL even when the ML service was down and rules had actually produced the number, which made the provenance column a lie exactly when it mattered. ScoreOutcome now carries an `engine` field and the fallback returns .attributedTo("RULE_FALLBACK"). Verified by killing the service mid-run: run 285 MODEL, run 286 RULE_FALLBACK, 260 of 263 scores differ. ModelMetric and its read-only controller expose the measured backtest at GET /admin/model/metrics. syntheticLabelShare is stored on the same row as every metric so accuracy cannot be quoted without the caveat travelling with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/lincfusion/udyamlinc/ml/MlClient.java | 99 +++++++++ .../lincfusion/udyamlinc/ml/MlProperties.java | 32 +++ .../udyamlinc/risk/ModelForecastStrategy.java | 194 ++++++++++++++++++ .../udyamlinc/risk/ModelMetric.java | 85 ++++++++ .../udyamlinc/risk/ModelMetricController.java | 56 +++++ .../udyamlinc/risk/ModelMetricRepository.java | 15 ++ .../udyamlinc/risk/ModelScoringStrategy.java | 128 ++++++++++++ .../udyamlinc/risk/RiskEngineService.java | 2 +- .../udyamlinc/risk/ScoreOutcome.java | 26 ++- src/main/resources/application.properties | 9 + 10 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/lincfusion/udyamlinc/ml/MlClient.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/ml/MlProperties.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/risk/ModelForecastStrategy.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/risk/ModelMetric.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricController.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/risk/ModelMetricRepository.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/risk/ModelScoringStrategy.java 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