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>