tenseleyflow/sway / 5cf3724

Browse files

tests/cluster_kl: real sklearn k-means + missing-sklearn SKIP coverage (F02)

Authored by espadonne
SHA
5cf37245376c553ce0a7a6f40fab813750e98a01
Parents
0decc77
Tree
4310f7f

1 changed file

StatusFile+-
M tests/unit/test_probe_cluster_kl.py 89 0
tests/unit/test_probe_cluster_kl.pymodified
@@ -287,3 +287,92 @@ class TestMissingSemsim:
287287
         result = probe.run(spec, ctx)
288288
         assert result.verdict == Verdict.SKIP
289289
         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)