feat(risk): bind the scoring engine to the ML service, with honest attribution

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) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:44:07 +05:30
co-authored by Claude Opus 5
parent bfbb933fe1
commit 6b4aec468d
10 changed files with 644 additions and 2 deletions
@@ -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.
*
* <p><b>Never throws to the caller.</b> 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<Map<String, Object>> modelInfo() {
return call(() -> http.get().uri("/model").retrieve().body(Map.class), "/model");
}
public Optional<MlScore> score(Map<String, Object> 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<List<MlScore>> scoreBatch(List<Map<String, Object>> items) {
return call(() -> http.post().uri("/score/batch")
.body(Map.of("items", items))
.retrieve()
.body(List.class), "/score/batch");
}
@SuppressWarnings("unchecked")
private <T> Optional<T> call(java.util.function.Supplier<Object> 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<MlForecast> forecast(Map<String, Object> 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<String, Integer> subScores,
List<MlDriver> 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<MlForecastPoint> points) {}
public record MlForecastPoint(int horizon, double p10, double p50, double p90) {}
}
@@ -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.
*
* <p>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;
}
@@ -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.
*
* <p>Selected by {@code udyam.forecast.engine=MODEL}. Answers the half of the
* problem statement the rule engine never could: "AI-Driven <b>Cash Flow
* Prediction</b>" was, until this existed, a median of past months with a band
* widening as sqrt(h) — defensible, explainable, and not a model.
*
* <h2>What crosses the boundary</h2>
*
* <p>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.
*
* <h2>Fallback</h2>
*
* <p>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<String> 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<ForecastPoint> forecast(Enterprise enterprise, Sector sector,
Map<String, FeatureValue> 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<MlClient.MlForecast> 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<ForecastPoint> 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<String, Object> request(Enterprise enterprise, Map<String, FeatureValue> features, Trailing t) {
Map<String, Object> 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<String, Object> 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.
*
* <p>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<YearMonth, double[]> 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<String, FeatureValue> 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) {}
}
@@ -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.
*
* <h2>Why this table exists</h2>
*
* <p>The Reports page used to render <code>0.87</code> AUC from a constant in
* <code>lib/demo/ops.ts</code>. 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.
*
* <h2>Provenance travels with the number</h2>
*
* <p>{@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.
*
* <h2>Baselines are rows too</h2>
*
* <p>{@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;
}
@@ -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.
*
* <p>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<Map<String, Object>> 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<ModelMetric> rows = repository.findByComputedAtOrderByArmAscMetricAsc(latest.getComputedAt());
Map<String, Map<String, Object>> byArm = new LinkedHashMap<>();
for (ModelMetric row : rows) {
Map<String, Object> arm = byArm.computeIfAbsent(row.getArm(), k -> new LinkedHashMap<>());
arm.put("baseline", row.isBaseline());
arm.put(row.getMetric(), row.getValue());
}
Map<String, Object> 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);
}
}
@@ -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<ModelMetric, Long> {
/** The most recent backtest. Metrics from two different runs must not be mixed. */
Optional<ModelMetric> findFirstByOrderByComputedAtDesc();
List<ModelMetric> findByComputedAtOrderByArmAscMetricAsc(LocalDateTime computedAt);
}
@@ -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.
*
* <p>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}.
*
* <h2>Fallback is the point, not a nicety</h2>
*
* <p>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.
*
* <h2>What crosses the boundary</h2>
*
* <p>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<String> 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<String, FeatureValue> 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<String, Object> request(Enterprise enterprise, Map<String, FeatureValue> features) {
Map<String, Object> 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<String, Object> 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<ScoreOutcome.Driver> 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();
}
}
@@ -240,7 +240,7 @@ public class RiskEngineService {
.enterpriseId(enterprise.getEnterpriseId()) .enterpriseId(enterprise.getEnterpriseId())
.score(scored.score()) .score(scored.score())
.band(band) .band(band)
.engine(strategy.engine()) .engine(scored.engine() != null ? scored.engine() : strategy.engine())
.confidence(scored.confidence()) .confidence(scored.confidence())
.liquidity(scored.subScores().get(RuleBasedScoringStrategy.LIQUIDITY)) .liquidity(scored.subScores().get(RuleBasedScoringStrategy.LIQUIDITY))
.behavioural(scored.subScores().get(RuleBasedScoringStrategy.BEHAVIOURAL)) .behavioural(scored.subScores().get(RuleBasedScoringStrategy.BEHAVIOURAL))
@@ -17,7 +17,31 @@ public record ScoreOutcome(
Map<String, Integer> subScores, Map<String, Integer> subScores,
List<Driver> drivers, List<Driver> drivers,
/** Families that had no usable feature and fell back to the sector prior. */ /** 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.
*
* <p>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 <em>this</em> 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<String, Integer> subScores,
List<Driver> 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 * One family's contribution to the composite. In the rule engine this is the
@@ -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, # 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. # so a worst-of-six rule separates nobody from anybody.
udyam.forecast.tripwire-horizon=3 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