From 88b882a2c72ecef990b55d02238b6ce5e57181c8 Mon Sep 17 00:00:00 2001 From: "DevOps (Aivo agent)" Date: Tue, 4 Aug 2026 14:13:12 +0000 Subject: [PATCH] feat: score feature rows with HH/CG LightGBM boosters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load two boosters (hh_beeline_bezzalog_2026_05_04_model, cg_beeline_bezzalog_2026_05_04_model) once at startup from KZ_SCORING_HH_MODEL_PATH / KZ_SCORING_CG_MODEL_PATH and enrich each row returned by /single and /multi with HH_score and CG_score. Feature vectors are projected onto booster.feature_name() in order; missing keys are passed to LightGBM as NaN so tree splits treat them as native missing values. Predictions are returned as raw PD (0..1) per AGG-131. Leaving a path empty disables that model — the response schema is unchanged, so the enrichment is fully backward-compatible. The chart gets an optional models.volume that mounts any k8s volume source (PVC, ConfigMap, Secret, ...) at a configurable mountPath, so operators can update the model files without rebuilding the image. Closes AGG-131. Co-Authored-By: Claude Opus 4.7 --- README.md | 35 ++++++ chart/templates/configmap.yaml | 2 + chart/templates/deployment.yaml | 12 +++ chart/values.yaml | 35 ++++++ pyproject.toml | 2 + requirements.txt | 2 + src/kz_scoring_api/app.py | 8 +- src/kz_scoring_api/config.py | 3 + src/kz_scoring_api/lookup.py | 8 +- src/kz_scoring_api/scoring.py | 155 +++++++++++++++++++++++++++ tests/conftest.py | 54 ++++++++++ tests/test_endpoints.py | 45 ++++++++ tests/test_lookup.py | 51 +++++++++ tests/test_scoring.py | 182 ++++++++++++++++++++++++++++++++ 14 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 src/kz_scoring_api/scoring.py create mode 100644 tests/test_scoring.py diff --git a/README.md b/README.md index 237a6a3..68b9d6c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,16 @@ Response: JSON array of feature objects. - Without `phone`: 0..N rows (one per SIM/SUBS_KEY on file for that IIN). - With `phone`: 0 or 1 row. +When HH/CG LightGBM boosters are configured (see [Scoring](#scoring) below), +each row is enriched in place with two extra float fields: + +| field | meaning | +| ---------- | --------------------------------------------------------------- | +| `HH_score` | raw `booster.predict()` output of the HH model (PD, `0..1`) | +| `CG_score` | raw `booster.predict()` output of the CG model (PD, `0..1`) | + +If a model is not configured, its `*_score` field is omitted from the row. + Status codes: | code | meaning | @@ -97,6 +107,8 @@ All settings are env vars with the `KZ_SCORING_` prefix; defaults are in | `KZ_SCORING_POLL_INTERVAL_MS` | `100` | | `KZ_SCORING_MAX_CONCURRENT_LOOKUPS` | `10` | | `KZ_SCORING_SALT_CACHE_TTL_SECONDS` | `300` | +| `KZ_SCORING_HH_MODEL_PATH` | `""` (empty → HH scoring disabled; file path to a LightGBM booster txt otherwise) | +| `KZ_SCORING_CG_MODEL_PATH` | `""` (empty → CG scoring disabled; file path to a LightGBM booster txt otherwise) | | `KZ_SCORING_LOG_LEVEL` | `INFO` | | `KZ_SCORING_HOST` / `KZ_SCORING_PORT` | `0.0.0.0` / `8000` | @@ -143,6 +155,29 @@ The Argo Application that wires the chart into the Beeline cluster lives in `argo/overlays/vaultee/` and is set up separately by DevOps once the chart is green. +## Scoring + +The service can score each feature row with two LightGBM boosters (HH and CG, +`hh_beeline_bezzalog_2026_05_04_model` / `cg_beeline_bezzalog_2026_05_04_model` +delivered by the modeling team on `aggregion/kz-scoring` issue AGG-131). + +- Boosters are loaded once at process startup from + `KZ_SCORING_HH_MODEL_PATH` and `KZ_SCORING_CG_MODEL_PATH`. +- Feature vectors are projected onto `booster.feature_name()` in order; row keys + missing from the response become `NaN` (LightGBM handles them as native + missing values at tree splits). +- `booster.predict(...)` output — raw PD in `[0, 1]` — is written back on the + row as `HH_score` / `CG_score`. +- Leaving a path empty disables that model; scoring never blocks the lookup — + if the model file is missing at startup, the process fails fast with a + `FileNotFoundError`. +- Startup logs record `scoring: loaded hh model from … (N features)` so a bad + model file is easy to catch in the deployment logs. + +In-cluster, mount the model files via the chart's `models.volume` (any k8s +volume type — PVC, ConfigMap, Secret) and point +`config.hh_model_path` / `config.cg_model_path` at files under its `mountPath`. + ## Not in scope - AuthN / TLS — handled by external ingress. diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index ef2592d..ff09e9c 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -17,4 +17,6 @@ data: KZ_SCORING_POLL_INTERVAL_MS: {{ .Values.config.poll_interval_ms | quote }} KZ_SCORING_MAX_CONCURRENT_LOOKUPS: {{ .Values.config.max_concurrent_lookups | quote }} KZ_SCORING_SALT_CACHE_TTL_SECONDS: {{ .Values.config.salt_cache_ttl_seconds | quote }} + KZ_SCORING_HH_MODEL_PATH: {{ .Values.config.hh_model_path | quote }} + KZ_SCORING_CG_MODEL_PATH: {{ .Values.config.cg_model_path | quote }} KZ_SCORING_LOG_LEVEL: {{ .Values.config.log_level | quote }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 445f0b7..8d249d2 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -56,6 +56,18 @@ spec: {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.models.volume.enabled }} + volumeMounts: + - name: models + mountPath: {{ .Values.models.volume.mountPath | quote }} + readOnly: {{ .Values.models.volume.readOnly }} + {{- end }} + {{- if .Values.models.volume.enabled }} + volumes: + - name: models + {{- $vol := omit .Values.models.volume "enabled" "mountPath" "readOnly" }} + {{- toYaml $vol | nindent 10 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/chart/values.yaml b/chart/values.yaml index 3faf3cd..790df03 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -82,5 +82,40 @@ config: max_concurrent_lookups: 10 salt_cache_ttl_seconds: 300 log_level: "INFO" + # Paths inside the container where LightGBM booster txt files are mounted. + # Leave empty to disable scoring (endpoints return raw feature rows only). + # If you enable ``models.volume`` below, defaults line up with its mountPath. + hh_model_path: "" + cg_model_path: "" + +# Optional volume that materialises HH/CG LightGBM model files inside the pod. +# The volume is mounted at ``mountPath`` on the app container; put your +# ``config.hh_model_path`` / ``config.cg_model_path`` values under that path. +# +# Example — mount from an existing PVC that DevOps populates out of band: +# models: +# volume: +# enabled: true +# mountPath: /models +# persistentVolumeClaim: +# claimName: kz-scoring-models +# config: +# hh_model_path: /models/hh_beeline_bezzalog_2026_05_04_model.txt +# cg_model_path: /models/cg_beeline_bezzalog_2026_05_04_model.txt +# +# Example — mount from a ConfigMap or Secret; use the same keys the k8s +# volume type accepts (``configMap``, ``secret``, ``persistentVolumeClaim``, +# ``hostPath``, ``emptyDir``, …). Exactly one source key should be set. +models: + volume: + enabled: false + mountPath: /models + readOnly: true + # persistentVolumeClaim: + # claimName: kz-scoring-models + # configMap: + # name: kz-scoring-models + # secret: + # secretName: kz-scoring-models extraEnv: [] diff --git a/pyproject.toml b/pyproject.toml index 22507e8..df0926d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "httpx==0.27.2", "pydantic==2.9.2", "pydantic-settings==2.6.1", + "lightgbm==4.5.0", + "numpy==2.1.3", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 06ae59c..b9bc7b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]==0.32.1 httpx==0.27.2 pydantic==2.9.2 pydantic-settings==2.6.1 +lightgbm==4.5.0 +numpy==2.1.3 diff --git a/src/kz_scoring_api/app.py b/src/kz_scoring_api/app.py index 7bc440a..bb429d0 100644 --- a/src/kz_scoring_api/app.py +++ b/src/kz_scoring_api/app.py @@ -16,6 +16,7 @@ PipelineUnavailableError, VaulteePipelinesClient, ) +from .scoring import ScoringService from .secrets import VaulteeSecretsClient logger = logging.getLogger(__name__) @@ -40,7 +41,12 @@ async def lifespan(app: FastAPI): ttl_seconds=settings.salt_cache_ttl_seconds, http=http, ) - app.state.lookup = LookupService(settings, pipelines, secrets) + scoring = ScoringService.from_paths( + hh_model_path=settings.hh_model_path, + cg_model_path=settings.cg_model_path, + ) + app.state.lookup = LookupService(settings, pipelines, secrets, scoring=scoring) + app.state.scoring = scoring app.state.http = http try: yield diff --git a/src/kz_scoring_api/config.py b/src/kz_scoring_api/config.py index 81c527e..c3d8e56 100644 --- a/src/kz_scoring_api/config.py +++ b/src/kz_scoring_api/config.py @@ -35,6 +35,9 @@ class Settings(BaseSettings): salt_cache_ttl_seconds: float = Field(default=300.0) + hh_model_path: str = Field(default="") + cg_model_path: str = Field(default="") + log_level: str = Field(default="INFO") diff --git a/src/kz_scoring_api/lookup.py b/src/kz_scoring_api/lookup.py index 4cb5c07..716cd18 100644 --- a/src/kz_scoring_api/lookup.py +++ b/src/kz_scoring_api/lookup.py @@ -11,6 +11,7 @@ PipelineUnavailableError, VaulteePipelinesClient, ) +from .scoring import ScoringService from .secrets import VaulteeSecretsClient from .tsv import parse_tsv @@ -23,10 +24,12 @@ def __init__( settings: Settings, pipelines: VaulteePipelinesClient, secrets: VaulteeSecretsClient, + scoring: ScoringService | None = None, ) -> None: self._settings = settings self._pipelines = pipelines self._secrets = secrets + self._scoring = scoring self._sem = asyncio.Semaphore(max(1, settings.max_concurrent_lookups)) async def _salt_pkb(self) -> bytes: @@ -77,7 +80,10 @@ async def _run_one( run_id, deadline_s=self._settings.timeout_seconds ) payload = await self._pipelines.fetch_result(run_id) - return parse_tsv(payload) + rows = parse_tsv(payload) + if self._scoring is not None: + self._scoring.score_rows(rows) + return rows async def lookup( self, iin: str, phone: str | None diff --git a/src/kz_scoring_api/scoring.py b/src/kz_scoring_api/scoring.py new file mode 100644 index 0000000..d9491a9 --- /dev/null +++ b/src/kz_scoring_api/scoring.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import logging +import math +from pathlib import Path +from typing import Any, Protocol + +import numpy as np + +logger = logging.getLogger(__name__) + +_MISSING = float("nan") + +_MISSING_TOKENS = frozenset({"", "null", "none", "nan", "na", "n/a"}) + + +def _to_float(value: Any) -> float: + """Coerce a heterogeneous cell value to float, mapping missing/garbage to NaN. + + LightGBM handles NaN as an explicit missing value at tree splits, so + unparseable / blank cells propagate as NaN into ``predict`` rather than + getting silently zeroed. + """ + if value is None: + return _MISSING + if isinstance(value, bool): + return float(value) + if isinstance(value, int | float): + f = float(value) + return f if not math.isnan(f) else _MISSING + if isinstance(value, str): + s = value.strip() + if s.lower() in _MISSING_TOKENS: + return _MISSING + try: + return float(s) + except ValueError: + return _MISSING + return _MISSING + + +class SupportsPredict(Protocol): + def predict(self, data: Any) -> Any: ... + def feature_name(self) -> list[str]: ... + + +class BoosterScorer: + """Wraps a LightGBM booster and scores a single feature-row dict. + + The row dict is projected onto ``booster.feature_name()`` in-order; missing + keys become NaN. Extra keys in the row are ignored. + """ + + def __init__(self, booster: SupportsPredict, name: str) -> None: + self._booster = booster + self._name = name + self._feature_names: list[str] = list(booster.feature_name()) + + @property + def name(self) -> str: + return self._name + + @property + def feature_names(self) -> list[str]: + return self._feature_names + + def score(self, row: dict[str, Any]) -> float: + vec = [_to_float(row.get(feat)) for feat in self._feature_names] + arr = np.asarray([vec], dtype=np.float64) + preds = self._booster.predict(arr) + return float(np.asarray(preds).ravel()[0]) + + +class ScoringService: + """Attaches HH_score / CG_score to each feature-row. + + The two LightGBM boosters are loaded once at process start (see + :meth:`from_paths`). Scoring is a plain in-place mutation of the row + dicts so it composes with the existing TSV → JSON pipeline without + changing the response schema shape. + + Either model can be omitted by leaving its path empty; the corresponding + ``*_score`` field is then omitted from the response (the service falls + back to a pass-through). + """ + + HH_KEY = "HH_score" + CG_KEY = "CG_score" + + def __init__( + self, + hh_scorer: BoosterScorer | None = None, + cg_scorer: BoosterScorer | None = None, + ) -> None: + self._hh = hh_scorer + self._cg = cg_scorer + + @classmethod + def from_paths( + cls, + hh_model_path: str | None, + cg_model_path: str | None, + ) -> ScoringService: + return cls( + hh_scorer=cls._load(hh_model_path, "hh"), + cg_scorer=cls._load(cg_model_path, "cg"), + ) + + @staticmethod + def _load(model_path: str | None, name: str) -> BoosterScorer | None: + if not model_path: + logger.info( + "scoring: %s model path is not configured; %s_score will be omitted", + name, + name, + ) + return None + path = Path(model_path) + if not path.exists(): + raise FileNotFoundError( + f"scoring: {name} model file not found at {model_path}" + ) + import lightgbm as lgb + + booster = lgb.Booster(model_file=str(path)) + scorer = BoosterScorer(booster, name) + logger.info( + "scoring: loaded %s model from %s (%d features)", + name, + model_path, + len(scorer.feature_names), + ) + return scorer + + @property + def enabled(self) -> bool: + return self._hh is not None or self._cg is not None + + @property + def hh(self) -> BoosterScorer | None: + return self._hh + + @property + def cg(self) -> BoosterScorer | None: + return self._cg + + def score_rows(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not rows or not self.enabled: + return rows + for row in rows: + if self._hh is not None: + row[self.HH_KEY] = self._hh.score(row) + if self._cg is not None: + row[self.CG_KEY] = self._cg.score(row) + return rows diff --git a/tests/conftest.py b/tests/conftest.py index ba856f2..26260d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from kz_scoring_api.app import build_app from kz_scoring_api.config import Settings from kz_scoring_api.lookup import LookupService +from kz_scoring_api.scoring import BoosterScorer, ScoringService @pytest.fixture @@ -94,6 +95,33 @@ def fake_pipelines() -> FakePipelines: return FakePipelines() +class FakeBooster: + """Deterministic booster used by conftest — sums non-nan features per row.""" + + def __init__(self, feature_names: list[str]) -> None: + self._feature_names = list(feature_names) + + def feature_name(self) -> list[str]: + return list(self._feature_names) + + def predict(self, data): + import numpy as np + + arr = np.asarray(data, dtype=np.float64) + nan_mask = np.isnan(arr) + return np.where(nan_mask, 0.0, arr).sum(axis=1) + + +@pytest.fixture +def scoring_service() -> ScoringService: + """Scoring service wired with two fake boosters — HH sums a/b, CG sums c.""" + + return ScoringService( + hh_scorer=BoosterScorer(FakeBooster(["a", "b"]), "hh"), + cg_scorer=BoosterScorer(FakeBooster(["c"]), "cg"), + ) + + @pytest.fixture def lookup_service( settings: Settings, fake_pipelines: FakePipelines, fake_secrets: FakeSecrets @@ -101,6 +129,18 @@ def lookup_service( return LookupService(settings, fake_pipelines, fake_secrets) +@pytest.fixture +def lookup_service_with_scoring( + settings: Settings, + fake_pipelines: FakePipelines, + fake_secrets: FakeSecrets, + scoring_service: ScoringService, +) -> LookupService: + return LookupService( + settings, fake_pipelines, fake_secrets, scoring=scoring_service + ) + + @pytest.fixture def test_client( settings: Settings, fake_pipelines: FakePipelines, fake_secrets: FakeSecrets @@ -108,3 +148,17 @@ def test_client( app = build_app(settings) app.state.lookup = LookupService(settings, fake_pipelines, fake_secrets) return TestClient(app) + + +@pytest.fixture +def test_client_with_scoring( + settings: Settings, + fake_pipelines: FakePipelines, + fake_secrets: FakeSecrets, + scoring_service: ScoringService, +) -> TestClient: + app = build_app(settings) + app.state.lookup = LookupService( + settings, fake_pipelines, fake_secrets, scoring=scoring_service + ) + return TestClient(app) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 45a4ed8..e0d1632 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -112,3 +112,48 @@ def test_multi_partial_failure_is_207( def test_multi_invalid_item_422(test_client): r = test_client.post("/multi", json=[{"iin": "short"}]) assert r.status_code == 422 + + +def test_single_with_scoring_returns_scores( + test_client_with_scoring, settings, fake_pipelines, fake_secrets +): + row_id = compute_row_id_iin( + fake_secrets.value, "801217301434", settings.iin_salt + ) + fake_pipelines.set(row_id, "a\tb\tc\n1\t2\t10\n") + + r = test_client_with_scoring.get("/single", params={"iin": "801217301434"}) + assert r.status_code == 200 + body = r.json() + assert len(body) == 1 + row = body[0] + assert row["a"] == "1" + assert row["HH_score"] == 3.0 + assert row["CG_score"] == 10.0 + + +def test_multi_with_scoring_applies_per_row( + test_client_with_scoring, settings, fake_pipelines, fake_secrets +): + salt = fake_secrets.value + iin_a = "111111111111" + iin_b = "222222222222" + + a_row = compute_row_id_iin(salt, iin_a, settings.iin_salt) + b_row = compute_row_id_iin(salt, iin_b, settings.iin_salt) + fake_pipelines.set(a_row, "a\tb\tc\n1\t2\t10\n1\t3\t20\n") + fake_pipelines.set(b_row, "a\tb\tc\n5\t5\t0\n") + + r = test_client_with_scoring.post( + "/multi", json=[{"iin": iin_a}, {"iin": iin_b}] + ) + assert r.status_code == 200 + payload = r.json() + # First IIN — two rows + assert payload[0][0]["HH_score"] == 3.0 + assert payload[0][0]["CG_score"] == 10.0 + assert payload[0][1]["HH_score"] == 4.0 + assert payload[0][1]["CG_score"] == 20.0 + # Second IIN — one row + assert payload[1][0]["HH_score"] == 10.0 + assert payload[1][0]["CG_score"] == 0.0 diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 42dd716..79c6970 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -86,3 +86,54 @@ async def test_lookup_many_collects_per_item_errors( assert out[0] == [{"z": "9"}] assert isinstance(out[1], PipelineUnavailableError) + + +@pytest.mark.asyncio +async def test_lookup_with_scoring_adds_hh_cg_scores( + settings, fake_pipelines, fake_secrets, lookup_service_with_scoring +): + salt_pkb = fake_secrets.value + row_id = compute_row_id_iin(salt_pkb, "801217301434", settings.iin_salt) + # HH scorer sums a+b (=3), CG scorer takes c (=10) + fake_pipelines.set(row_id, "a\tb\tc\n1\t2\t10\n") + + result = await lookup_service_with_scoring.lookup("801217301434", None) + + assert len(result) == 1 + row = result[0] + assert row["a"] == "1" + assert row["b"] == "2" + assert row["c"] == "10" + assert row["HH_score"] == 3.0 + assert row["CG_score"] == 10.0 + + +@pytest.mark.asyncio +async def test_lookup_with_scoring_handles_missing_features( + settings, fake_pipelines, fake_secrets, lookup_service_with_scoring +): + salt_pkb = fake_secrets.value + row_id = compute_row_id_iin(salt_pkb, "801217301434", settings.iin_salt) + # 'b' absent from the row → falls back to NaN in the feature vector; + # FakeBooster sums non-nan cells so HH_score = 1.0, CG_score = 5.0 + fake_pipelines.set(row_id, "a\tc\n1\t5\n") + + result = await lookup_service_with_scoring.lookup("801217301434", None) + + assert result[0]["HH_score"] == 1.0 + assert result[0]["CG_score"] == 5.0 + + +@pytest.mark.asyncio +async def test_lookup_without_scoring_leaves_rows_untouched( + settings, fake_pipelines, fake_secrets, lookup_service +): + salt_pkb = fake_secrets.value + row_id = compute_row_id_iin(salt_pkb, "801217301434", settings.iin_salt) + fake_pipelines.set(row_id, "a\tb\n1\t2\n") + + result = await lookup_service.lookup("801217301434", None) + + assert result == [{"a": "1", "b": "2"}] + assert "HH_score" not in result[0] + assert "CG_score" not in result[0] diff --git a/tests/test_scoring.py b/tests/test_scoring.py new file mode 100644 index 0000000..f673747 --- /dev/null +++ b/tests/test_scoring.py @@ -0,0 +1,182 @@ +import math +from typing import Any + +import numpy as np +import pytest + +from kz_scoring_api.scoring import ( + BoosterScorer, + ScoringService, + _to_float, +) + + +class FakeBooster: + """Deterministic stand-in for ``lightgbm.Booster``. + + Its prediction is ``sum(non-nan features)`` — enough to distinguish rows, + react to missing values (NaN), and confirm feature-order projection. + """ + + def __init__(self, feature_names: list[str]) -> None: + self._feature_names = list(feature_names) + self.calls: list[np.ndarray] = [] + + def feature_name(self) -> list[str]: + return list(self._feature_names) + + def predict(self, data: Any) -> np.ndarray: + arr = np.asarray(data, dtype=np.float64) + self.calls.append(arr) + # sum of non-nan features per row + nan_mask = np.isnan(arr) + summed = np.where(nan_mask, 0.0, arr).sum(axis=1) + return summed + + +class TestToFloat: + def test_int_and_float_pass_through(self): + assert _to_float(3) == 3.0 + assert _to_float(3.14) == 3.14 + + def test_none_and_blank_are_nan(self): + assert math.isnan(_to_float(None)) + assert math.isnan(_to_float("")) + assert math.isnan(_to_float(" ")) + + def test_missing_tokens_are_nan(self): + for tok in ("null", "NULL", "None", "nan", "NA", "n/a"): + assert math.isnan(_to_float(tok)), tok + + def test_numeric_strings_parsed(self): + assert _to_float("1.5") == 1.5 + assert _to_float(" -2 ") == -2.0 + assert _to_float("1e3") == 1000.0 + + def test_unparseable_string_is_nan(self): + assert math.isnan(_to_float("abc")) + assert math.isnan(_to_float("1.2.3")) + + def test_bool_is_zero_or_one(self): + assert _to_float(True) == 1.0 + assert _to_float(False) == 0.0 + + +class TestBoosterScorer: + def test_projects_row_in_feature_order(self): + booster = FakeBooster(["a", "b", "c"]) + scorer = BoosterScorer(booster, "test") + result = scorer.score({"b": "2", "a": 1, "c": 3.0, "extra": 99}) + assert result == 6.0 + assert booster.calls[0].tolist() == [[1.0, 2.0, 3.0]] + + def test_missing_row_key_becomes_nan(self): + booster = FakeBooster(["a", "b"]) + scorer = BoosterScorer(booster, "test") + result = scorer.score({"a": 5}) + assert result == 5.0 + row = booster.calls[0][0] + assert row[0] == 5.0 + assert math.isnan(row[1]) + + def test_feature_names_exposed(self): + booster = FakeBooster(["x", "y"]) + scorer = BoosterScorer(booster, "hh") + assert scorer.feature_names == ["x", "y"] + assert scorer.name == "hh" + + +class TestScoringService: + def test_score_rows_adds_both_scores(self): + hh = BoosterScorer(FakeBooster(["f1", "f2"]), "hh") + cg = BoosterScorer(FakeBooster(["f3"]), "cg") + svc = ScoringService(hh_scorer=hh, cg_scorer=cg) + rows = [{"f1": "1", "f2": "2", "f3": "10"}] + svc.score_rows(rows) + assert rows[0]["HH_score"] == 3.0 + assert rows[0]["CG_score"] == 10.0 + + def test_pass_through_when_disabled(self): + svc = ScoringService() + assert not svc.enabled + rows = [{"a": "1"}] + out = svc.score_rows(rows) + assert out is rows + assert "HH_score" not in rows[0] + assert "CG_score" not in rows[0] + + def test_partial_enabled_only_hh(self): + hh = BoosterScorer(FakeBooster(["a"]), "hh") + svc = ScoringService(hh_scorer=hh, cg_scorer=None) + rows = [{"a": "7"}] + svc.score_rows(rows) + assert rows[0]["HH_score"] == 7.0 + assert "CG_score" not in rows[0] + + def test_empty_rows_short_circuits(self): + hh = BoosterScorer(FakeBooster(["a"]), "hh") + svc = ScoringService(hh_scorer=hh) + assert svc.score_rows([]) == [] + + def test_missing_features_produce_nan_input(self): + hh = BoosterScorer(FakeBooster(["a", "b", "c"]), "hh") + svc = ScoringService(hh_scorer=hh) + rows = [{"a": "1"}] + svc.score_rows(rows) + # FakeBooster sums non-nan; 'b' and 'c' missing → sum = 1.0 + assert rows[0]["HH_score"] == 1.0 + + def test_from_paths_empty_disables(self, tmp_path): + svc = ScoringService.from_paths(hh_model_path="", cg_model_path="") + assert not svc.enabled + assert svc.hh is None + assert svc.cg is None + + def test_from_paths_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + ScoringService.from_paths( + hh_model_path=str(tmp_path / "does-not-exist.txt"), + cg_model_path="", + ) + + +class TestRealLightgbm: + """Round-trip against a real LightGBM booster: train tiny, dump, reload, score.""" + + @pytest.fixture + def booster_file(self, tmp_path): + import lightgbm as lgb + + rng = np.random.default_rng(seed=42) + X = rng.normal(size=(100, 3)) + y = (X[:, 0] + 0.5 * X[:, 1] > 0).astype(int) + train = lgb.Dataset(X, label=y, feature_name=["alpha", "beta", "gamma"]) + booster = lgb.train( + { + "objective": "binary", + "metric": "binary_logloss", + "verbosity": -1, + "num_leaves": 4, + }, + train, + num_boost_round=3, + ) + path = tmp_path / "model.txt" + booster.save_model(str(path)) + return path + + def test_loads_and_scores(self, booster_file): + svc = ScoringService.from_paths( + hh_model_path=str(booster_file), + cg_model_path="", + ) + assert svc.enabled + assert svc.hh is not None + assert set(svc.hh.feature_names) == {"alpha", "beta", "gamma"} + + rows = [{"alpha": "1.0", "beta": "0.5", "gamma": "0.1"}] + svc.score_rows(rows) + assert "HH_score" in rows[0] + score = rows[0]["HH_score"] + assert isinstance(score, float) + assert 0.0 <= score <= 1.0