@@ -287,3 +287,92 @@ class TestMissingSemsim: |
| 287 | 287 | result = probe.run(spec, ctx) |
| 288 | 288 | assert result.verdict == Verdict.SKIP |
| 289 | 289 | assert "semsim" in result.message |
| 290 | + |
| 291 | + def test_skip_when_sklearn_import_fails( |
| 292 | + self, monkeypatch: pytest.MonkeyPatch, monkeyed_embed: dict[str, np.ndarray] |
| 293 | + ) -> None: |
| 294 | + """Covers the ``_kmeans_cluster`` import-error SKIP branch directly. |
| 295 | + |
| 296 | + The ``_load_embedder`` raise branch is tested above; this test |
| 297 | + stubs ``_load_embedder`` to succeed and replaces |
| 298 | + ``_kmeans_cluster`` with a raiser that mimics an uninstalled |
| 299 | + sklearn. Before this test, the sklearn-missing SKIP path in |
| 300 | + ``probes/cluster_kl.py`` was unreachable under any test — the |
| 301 | + embedder raise always fired first. |
| 302 | + """ |
| 303 | + from dlm_sway.core.errors import BackendNotAvailableError |
| 304 | + |
| 305 | + for p in [f"p-{i}" for i in range(8)]: |
| 306 | + monkeyed_embed[p] = np.array([1.0, 0.0], dtype=np.float32) |
| 307 | + |
| 308 | + def sklearn_raiser(*_args: Any, **_kwargs: Any) -> Any: |
| 309 | + raise BackendNotAvailableError( |
| 310 | + "cluster_kl", |
| 311 | + extra="semsim", |
| 312 | + hint="cluster_kl needs scikit-learn for k-means clustering.", |
| 313 | + ) |
| 314 | + |
| 315 | + monkeypatch.setattr( |
| 316 | + "dlm_sway.probes.cluster_kl._kmeans_cluster", |
| 317 | + sklearn_raiser, |
| 318 | + ) |
| 319 | + probe = ClusterKLProbe() |
| 320 | + spec = probe.spec_cls( |
| 321 | + name="ck", |
| 322 | + kind="cluster_kl", |
| 323 | + prompts=[f"p-{i}" for i in range(8)], |
| 324 | + num_clusters=2, |
| 325 | + min_prompts=4, |
| 326 | + ) |
| 327 | + ctx = RunContext( |
| 328 | + backend=DummyDifferentialBackend(base=DummyResponses(), ft=DummyResponses()) |
| 329 | + ) |
| 330 | + result = probe.run(spec, ctx) |
| 331 | + assert result.verdict == Verdict.SKIP |
| 332 | + assert "semsim" in result.message |
| 333 | + assert "scikit-learn" in result.message |
| 334 | + |
| 335 | + |
| 336 | +class TestRealKMeans: |
| 337 | + """Exercise the actual ``sklearn.cluster.KMeans`` primitive. |
| 338 | + |
| 339 | + Every other test in this file monkeypatches ``_kmeans_cluster`` with |
| 340 | + an argmax stub so suites can run in CI environments without the |
| 341 | + ``[semsim]`` extra installed. That leaves the real sklearn path — |
| 342 | + the probe's entire reason for existing — uncovered. The tests here |
| 343 | + skip when sklearn isn't available and execute the real import |
| 344 | + otherwise. |
| 345 | + """ |
| 346 | + |
| 347 | + def test_real_kmeans_separates_two_gaussians(self) -> None: |
| 348 | + """Two clearly-separated clusters → k-means recovers the correct |
| 349 | + partition with a fixed seed.""" |
| 350 | + pytest.importorskip("sklearn") |
| 351 | + from dlm_sway.probes.cluster_kl import _kmeans_cluster |
| 352 | + |
| 353 | + rng = np.random.default_rng(0) |
| 354 | + # Cluster A centered at (0, 0); cluster B centered at (5, 0). |
| 355 | + group_a = rng.normal(loc=0.0, scale=0.5, size=(8, 2)).astype(np.float32) |
| 356 | + group_b = rng.normal(loc=(5.0, 0.0), scale=0.5, size=(8, 2)).astype(np.float32) |
| 357 | + embeddings = np.vstack([group_a, group_b]) |
| 358 | + labels = _kmeans_cluster(embeddings, k=2, seed=0) |
| 359 | + assert labels.shape == (16,) |
| 360 | + # All-A should share a label; all-B should share the other. |
| 361 | + label_a = set(labels[:8].tolist()) |
| 362 | + label_b = set(labels[8:].tolist()) |
| 363 | + assert len(label_a) == 1 |
| 364 | + assert len(label_b) == 1 |
| 365 | + assert label_a != label_b |
| 366 | + |
| 367 | + def test_real_kmeans_seed_is_deterministic(self) -> None: |
| 368 | + """Two runs with the same seed → identical label vectors. Pins |
| 369 | + the determinism contract in a way that the argmax stub can't. |
| 370 | + """ |
| 371 | + pytest.importorskip("sklearn") |
| 372 | + from dlm_sway.probes.cluster_kl import _kmeans_cluster |
| 373 | + |
| 374 | + rng = np.random.default_rng(0) |
| 375 | + embeddings = rng.normal(size=(20, 4)).astype(np.float32) |
| 376 | + labels_a = _kmeans_cluster(embeddings, k=3, seed=42) |
| 377 | + labels_b = _kmeans_cluster(embeddings, k=3, seed=42) |
| 378 | + assert np.array_equal(labels_a, labels_b) |