import numpy as np import pytest from tutor.ml.cefr.inference import CEFRClassifier, build_onnx_feed, softmax def test_softmax_rows_sum_to_one_and_is_stable() -> None: logits = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [1000.0, 1000.0, 0.0, 0.0, 0.0, 0.0]]) probs = softmax(logits) assert np.allclose(probs.sum(axis=-1), 1.0) assert probs[0].argmax() == 5 assert probs[1][0] == pytest.approx(0.5) # no overflow on large logits def test_build_onnx_feed_handles_token_type_ids_and_rejects_unknown() -> None: ids = np.ones((2, 4), dtype=np.int64) mask = np.ones((2, 4), dtype=np.int64) feed = build_onnx_feed(["input_ids", "attention_mask", "token_type_ids"], ids, mask) assert set(feed) == {"input_ids", "attention_mask", "token_type_ids"} assert feed["token_type_ids"].sum() == 0 with pytest.raises(ValueError, match="unexpected ONNX model input"): build_onnx_feed(["pixel_values"], ids, mask) class _FakeClassifier(CEFRClassifier): """Real chunking + aggregation, canned model output.""" def __init__(self, probs_row: list[float]) -> None: super().__init__(session=None, tokenizer=None, target_words=50, max_words=80) self._probs_row = probs_row self.seen_chunks: list[str] = [] def _predict_probs(self, texts: list[str]) -> list[list[float]]: self.seen_chunks.extend(texts) return [self._probs_row for _ in texts] def test_classify_text_chunks_and_aggregates() -> None: b2_row = [0.02, 0.02, 0.1, 0.8, 0.04, 0.02] classifier = _FakeClassifier(b2_row) long_text = ". ".join(" ".join(f"w{i}" for i in range(11)) + " end" for _ in range(30)) + "." prediction = classifier.classify_text(long_text) assert len(classifier.seen_chunks) > 1 # the real chunker actually ran assert prediction.n_chunks == len(classifier.seen_chunks) assert prediction.level == "B2" assert prediction.score == pytest.approx(2.88, abs=0.01) assert prediction.per_level["B2"] == pytest.approx(0.8) def test_classify_text_rejects_empty_input() -> None: classifier = _FakeClassifier([1 / 6] * 6) with pytest.raises(ValueError, match="empty text"): classifier.classify_text(" ")