diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78297bc..db8102e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,7 @@ jobs: uses: docker/login-action@v4 with: registry: ghcr.io - username: ${{ github.actor }} + username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata @@ -199,7 +199,7 @@ jobs: uses: docker/login-action@v4 with: registry: ghcr.io - username: ${{ github.actor }} + username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata diff --git a/benchmark/evaluate.py b/benchmark/evaluate.py index c705572..fa9c8cf 100644 --- a/benchmark/evaluate.py +++ b/benchmark/evaluate.py @@ -197,7 +197,7 @@ def run_cordon_analysis( - intermediates: Dict with embeddings, scores, windows (if return_intermediates=True) """ from cordon.analysis.scorer import DensityAnomalyScorer - from cordon.embedding import create_vectorizer + from cordon.embedding import create_embedder from cordon.ingestion.reader import LogFileReader from cordon.segmentation.windower import SlidingWindowSegmenter @@ -216,8 +216,8 @@ def run_cordon_analysis( segmenter = SlidingWindowSegmenter() windows = list(segmenter.segment(lines, config)) - vectorizer = create_vectorizer(config) - embedded = list(vectorizer.embed_windows(windows)) + embedder = create_embedder(config) + embedded = list(embedder.embed_windows(windows)) scorer = DensityAnomalyScorer() scored = scorer.score_windows(embedded, config) diff --git a/benchmark/visualize.py b/benchmark/visualize.py index 2deb0c0..ed2c929 100644 --- a/benchmark/visualize.py +++ b/benchmark/visualize.py @@ -47,7 +47,7 @@ from cordon import AnalysisConfig from cordon.analysis.scorer import DensityAnomalyScorer -from cordon.embedding import create_vectorizer +from cordon.embedding import create_embedder from cordon.ingestion.reader import LogFileReader from cordon.segmentation.windower import SlidingWindowSegmenter @@ -90,8 +90,8 @@ def get_embeddings( window_ranges = [(w.start_line, w.end_line) for w in windows] # Embed - vectorizer = create_vectorizer(config) - embedded = list(vectorizer.embed_windows(windows)) + embedder = create_embedder(config) + embedded = list(embedder.embed_windows(windows)) # Extract embeddings matrix embeddings = np.array([emb for _, emb in embedded]) diff --git a/src/cordon/embedding/__init__.py b/src/cordon/embedding/__init__.py index 486e672..5b95a72 100644 --- a/src/cordon/embedding/__init__.py +++ b/src/cordon/embedding/__init__.py @@ -7,28 +7,34 @@ from cordon.core.types import Embedder -def create_vectorizer(config: "AnalysisConfig") -> "Embedder": - """Factory function to create appropriate vectorizer based on config. +def create_embedder(config: "AnalysisConfig") -> "Embedder": + """Factory function to create the appropriate embedder for a config. Args: - config: Analysis configuration with backend selection + config: Analysis configuration with backend selection. Returns: - Vectorizer instance implementing the Embedder protocol + Embedder instance matching the configured backend. + + Raises: + ValueError: If the backend is not recognized. """ if config.backend == "remote": - from cordon.embedding.remote import RemoteVectorizer + from cordon.embedding.remote import RemoteEmbedder - return RemoteVectorizer(config) + return RemoteEmbedder(config) if config.backend == "llama-cpp": - from cordon.embedding.llama_cpp import LlamaCppVectorizer + from cordon.embedding.llama_cpp import LlamaCppEmbedder + + return LlamaCppEmbedder(config) - return LlamaCppVectorizer(config) + if config.backend == "sentence-transformers": + from cordon.embedding.transformer import TransformerEmbedder - from cordon.embedding.transformer import TransformerVectorizer + return TransformerEmbedder(config) - return TransformerVectorizer(config) + raise ValueError(f"Unknown backend: {config.backend}") -__all__ = ["create_vectorizer"] +__all__ = ["create_embedder"] diff --git a/src/cordon/embedding/llama_cpp.py b/src/cordon/embedding/llama_cpp.py index 953c95c..753a43e 100644 --- a/src/cordon/embedding/llama_cpp.py +++ b/src/cordon/embedding/llama_cpp.py @@ -1,24 +1,29 @@ +import logging from collections.abc import Iterable, Iterator from typing import Any import numpy as np import numpy.typing as npt +from tqdm import tqdm from cordon.core.config import AnalysisConfig from cordon.core.types import TextWindow +from cordon.embedding.normalize import normalize_embeddings + +logger = logging.getLogger(__name__) DEFAULT_REPO_ID = "second-state/All-MiniLM-L6-v2-Embedding-GGUF" DEFAULT_FILENAME = "all-MiniLM-L6-v2-Q4_K_M.gguf" -class LlamaCppVectorizer: +class LlamaCppEmbedder: """Convert text windows to embeddings using llama.cpp GGUF models.""" def __init__(self, config: AnalysisConfig) -> None: - """Initialize the vectorizer with llama.cpp model. + """Initialize the embedder with llama.cpp model. Args: - config: Analysis configuration specifying model and parameters + config: Analysis configuration specifying model and parameters. """ self.config = config self.model_path = config.model_path if config.model_path else self._get_default_model() @@ -46,28 +51,49 @@ def embed_windows( """Embed text windows into vector representations. Args: - windows: Iterable of text windows to embed + windows: Iterable of text windows to embed. Yields: - Tuples of (window, embedding) where embeddings are normalized - numpy arrays + Tuples of (window, embedding) where embeddings are L2-normalized + numpy arrays. """ - for window in windows: - result = self.model.create_embedding(window.content) - embedding_list = result["data"][0]["embedding"] - embedding_array = np.array(embedding_list, dtype=np.float32) - - norm = np.linalg.norm(embedding_array) - if norm > 0: - embedding_array = embedding_array / norm + window_list = list(windows) + if not window_list: + return + + batch_size = self.config.batch_size + total_batches = (len(window_list) + batch_size - 1) // batch_size + + for batch_start in tqdm( + range(0, len(window_list), batch_size), + desc="Generating embeddings", + total=total_batches, + unit="batch", + ): + batch = window_list[batch_start : batch_start + batch_size] + texts = [w.content for w in batch] + + try: + result = self.model.create_embedding(texts) + except Exception as error: + raise RuntimeError( + f"llama.cpp embedding failed on batch starting at window " + f"{batch[0].window_id}: {error}" + ) from error + + embeddings = np.array( + [item["embedding"] for item in result["data"]], + dtype=np.float32, + ) + embeddings = normalize_embeddings(embeddings) - yield window, embedding_array + yield from zip(batch, embeddings, strict=False) def _get_default_model(self) -> str: """Get path to default GGUF model, downloading if necessary. Returns: - Path to the model file + Path to the model file. """ try: from huggingface_hub import hf_hub_download @@ -78,12 +104,12 @@ def _get_default_model(self) -> str: ) from error try: - print(f"Downloading default GGUF model: {DEFAULT_FILENAME}") + logger.info("Downloading default GGUF model: %s", DEFAULT_FILENAME) model_path = hf_hub_download( repo_id=DEFAULT_REPO_ID, filename=DEFAULT_FILENAME, ) - print(f"Model downloaded to: {model_path}") + logger.info("Model downloaded to: %s", model_path) return str(model_path) except Exception as error: raise RuntimeError( diff --git a/src/cordon/embedding/normalize.py b/src/cordon/embedding/normalize.py new file mode 100644 index 0000000..b8c1604 --- /dev/null +++ b/src/cordon/embedding/normalize.py @@ -0,0 +1,35 @@ +"""Shared embedding normalization utilities.""" + +from typing import Any + +import numpy as np +import numpy.typing as npt + + +def normalize_embeddings( + embeddings: npt.NDArray[np.floating[Any]], +) -> npt.NDArray[np.floating[Any]]: + """L2-normalize embedding vectors. + + Handles both single vectors (1D) and batches (2D). Zero vectors + are left as-is to avoid division by zero. + + Args: + embeddings: Array of shape (dim,) or (batch, dim). + + Returns: + L2-normalized array of the same shape. + """ + result: npt.NDArray[np.floating[Any]] + + if embeddings.ndim == 1: + norm: np.floating[Any] = np.linalg.norm(embeddings) + if norm > 0: + result = embeddings / norm + return result + return embeddings + + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + safe_norms = np.maximum(norms, 1e-10) + result = embeddings / safe_norms + return result diff --git a/src/cordon/embedding/remote.py b/src/cordon/embedding/remote.py index 0286a64..19571f3 100644 --- a/src/cordon/embedding/remote.py +++ b/src/cordon/embedding/remote.py @@ -13,16 +13,17 @@ from cordon.core.config import AnalysisConfig from cordon.core.types import TextWindow +from cordon.embedding.normalize import normalize_embeddings -class RemoteVectorizer: +class RemoteEmbedder: """Convert text windows to embeddings using remote API providers via LiteLLM.""" def __init__(self, config: AnalysisConfig) -> None: - """Initialize the vectorizer with remote API configuration. + """Initialize the embedder with remote API configuration. Args: - config: Analysis configuration with remote backend settings + config: Analysis configuration with remote backend settings. """ self.config = config litellm.set_verbose = False @@ -33,10 +34,10 @@ def embed_windows( """Embed text windows into vector representations using remote API. Args: - windows: Iterable of text windows to embed + windows: Iterable of text windows to embed. Yields: - Tuples of (window, embedding) where embeddings are L2 normalized + Tuples of (window, embedding) where embeddings are L2-normalized. """ window_list = list(windows) @@ -55,6 +56,7 @@ def embed_windows( batch = window_list[batch_start_idx : batch_start_idx + batch_size] texts = [window.content for window in batch] + batch_results: list[tuple[TextWindow, npt.NDArray[np.floating[Any]]]] = [] try: response = litellm.embedding( model=self.config.model_name, @@ -65,15 +67,20 @@ def embed_windows( ) embeddings_data = response.data - for window, embedding_obj in zip(batch, embeddings_data, strict=True): - embedding_list = embedding_obj["embedding"] - embedding_array = np.array(embedding_list, dtype=np.float32) - norm = np.linalg.norm(embedding_array) - if norm > 0: - embedding_array = embedding_array / norm + try: + raw_embeddings = np.array( + [item["embedding"] for item in embeddings_data], + dtype=np.float32, + ) + except (KeyError, TypeError) as error: + raise RuntimeError( + f"Unexpected API response format from model '{self.config.model_name}'. " + f"Expected 'embedding' field in response data: {error}" + ) from error - yield window, embedding_array + normalized = normalize_embeddings(raw_embeddings) + batch_results = list(zip(batch, normalized, strict=True)) except AuthenticationError as error: raise RuntimeError( @@ -88,9 +95,15 @@ def embed_windows( except Timeout as error: raise RuntimeError( f"Request timeout for model '{self.config.model_name}'. " - f"Try increasing request_timeout (current: {self.config.request_timeout}s). Error: {error}" + f"Try increasing request_timeout (current: {self.config.request_timeout}s). " + f"Error: {error}" ) from error + except RuntimeError: + raise except Exception as error: raise RuntimeError( - f"Error calling remote embedding API for model '{self.config.model_name}': {error}" + f"Error calling remote embedding API for model " + f"'{self.config.model_name}': {error}" ) from error + + yield from batch_results diff --git a/src/cordon/embedding/transformer.py b/src/cordon/embedding/transformer.py index f2d9845..a4ec872 100644 --- a/src/cordon/embedding/transformer.py +++ b/src/cordon/embedding/transformer.py @@ -1,3 +1,4 @@ +import logging import warnings from collections.abc import Iterable, Iterator from typing import Any @@ -13,23 +14,35 @@ from cordon.core.types import TextWindow -class TransformerVectorizer: +class TransformerEmbedder: """Convert text windows to dense embeddings with hardware acceleration. - This vectorizer uses sentence-transformers models to create semantic - embeddings of text windows. It automatically detects and utilizes - available hardware acceleration (CUDA, MPS, or CPU). + Uses sentence-transformers models to create semantic embeddings of text + windows. Automatically detects and utilizes available hardware + acceleration (CUDA, MPS, or CPU). """ def __init__(self, config: AnalysisConfig) -> None: - """Initialize the vectorizer with a model. + """Initialize the embedder with a sentence-transformer model. Args: - config: Analysis configuration specifying model and device + config: Analysis configuration specifying model and device. + + Raises: + RuntimeError: If the model cannot be loaded. """ self.config = config self.device = detect_device(self.config.device) - self.model = SentenceTransformer(config.model_name) + + try: + self.model = SentenceTransformer(config.model_name) + except Exception as error: + raise RuntimeError( + f"Failed to load sentence-transformer model '{config.model_name}'. " + f"Verify the model name is correct and you have network access " + f"for first-time downloads. Error: {error}" + ) from error + self.model.to(self.device) self._truncation_warned = False @@ -39,11 +52,11 @@ def embed_windows( """Embed text windows into vector representations. Args: - windows: Iterable of text windows to embed + windows: Iterable of text windows to embed. Yields: - Tuples of (window, embedding) where embeddings are normalized - numpy arrays + Tuples of (window, embedding) where embeddings are L2-normalized + numpy arrays. """ window_list = list(windows) @@ -53,7 +66,6 @@ def embed_windows( if not self._truncation_warned: self._check_truncation_warning(window_list) - # clear GPU cache before starting if torch.cuda.is_available(): torch.cuda.empty_cache() @@ -77,17 +89,13 @@ def embed_windows( normalize_embeddings=True, ) - # aggressive memory cleanup - if torch.cuda.is_available(): - torch.cuda.empty_cache() - yield from zip(batch, embeddings, strict=False) def _check_truncation_warning(self, windows: list[TextWindow]) -> None: """Check if windows are likely to be truncated and warn user. Args: - windows: List of windows to check + windows: List of windows to check. """ if not windows: return @@ -132,5 +140,5 @@ def _check_truncation_warning(self, windows: list[TextWindow]) -> None: stacklevel=3, ) self._truncation_warned = True - except Exception: - pass + except (AttributeError, TypeError, ValueError): + logging.getLogger(__name__).debug("Could not check truncation warning", exc_info=True) diff --git a/src/cordon/pipeline.py b/src/cordon/pipeline.py index 2cc6787..464557d 100644 --- a/src/cordon/pipeline.py +++ b/src/cordon/pipeline.py @@ -7,7 +7,7 @@ from cordon.analysis.thresholder import Thresholder from cordon.core.config import AnalysisConfig from cordon.core.types import AnalysisResult, ScoredWindow -from cordon.embedding import create_vectorizer +from cordon.embedding import create_embedder from cordon.ingestion.reader import LogFileReader from cordon.postprocess.formatter import OutputFormatter from cordon.postprocess.merger import IntervalMerger @@ -26,19 +26,19 @@ def __init__(self, config: AnalysisConfig | None = None) -> None: """Initialize the analyzer with configuration. Args: - config: Analysis configuration (uses defaults if None) + config: Analysis configuration (uses defaults if None). """ self.config = config if config is not None else AnalysisConfig() - self._vectorizer = create_vectorizer(self.config) + self._embedder = create_embedder(self.config) def analyze_file(self, file_path: Path) -> str: """Analyze a log file and return formatted output. Args: - file_path: Path to the log file to analyze + file_path: Path to the log file to analyze. Returns: - Formatted string with XML-tagged significant blocks + Formatted string with XML-tagged significant blocks. """ result = self.analyze_file_detailed(file_path) return result.output @@ -47,10 +47,10 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult: """Analyze a log file and return detailed results. Args: - file_path: Path to the log file to analyze + file_path: Path to the log file to analyze. Returns: - Complete analysis result with metadata + Complete analysis result with metadata. """ start_time = time.time() @@ -64,7 +64,7 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult: windows = segmenter.segment(iter(lines_list), self.config) # stage 3: vectorization - embedded = list(self._vectorizer.embed_windows(windows)) + embedded = list(self._embedder.embed_windows(windows)) total_windows = len(embedded) # stage 4: scoring @@ -106,10 +106,10 @@ def _calculate_score_distribution(self, scored_windows: list[ScoredWindow]) -> d """Calculate statistical distribution of scores. Args: - scored_windows: List of scored windows + scored_windows: List of scored windows. Returns: - Dictionary with statistical measures + Dictionary with statistical measures. """ if not scored_windows: return { diff --git a/tests/test_llama_cpp.py b/tests/test_llama_cpp.py index ef52c38..5316702 100644 --- a/tests/test_llama_cpp.py +++ b/tests/test_llama_cpp.py @@ -1,4 +1,6 @@ -"""Unit tests for llama.cpp vectorizer backend.""" +"""Unit tests for llama.cpp embedder backend.""" + +import logging import numpy as np import pytest @@ -7,8 +9,8 @@ from cordon.core.types import TextWindow -class TestLlamaCppVectorizerConfiguration: - """Tests for LlamaCppVectorizer configuration and initialization.""" +class TestLlamaCppEmbedderConfiguration: + """Tests for LlamaCppEmbedder configuration and initialization.""" def test_missing_model_path_auto_downloads(self, monkeypatch, tmp_path) -> None: """Test that missing model_path triggers auto-download.""" @@ -28,10 +30,10 @@ def mock_hf_hub_download(repo_id, filename): config = AnalysisConfig(backend="llama-cpp", model_path=None) - from cordon.embedding.llama_cpp import LlamaCppVectorizer + from cordon.embedding.llama_cpp import LlamaCppEmbedder with pytest.raises((RuntimeError, ValueError)): - LlamaCppVectorizer(config) + LlamaCppEmbedder(config) def test_nonexistent_model_path_raises_error(self) -> None: """Test that nonexistent model file raises ValueError.""" @@ -54,15 +56,42 @@ def test_import_error_without_llama_cpp( assert config.backend == "llama-cpp" assert config.model_path == str(model_file) + def test_logging_used_instead_of_print(self, monkeypatch, tmp_path, caplog) -> None: + """Test that logging.info is used instead of print for downloads.""" + pytest.importorskip("llama_cpp") + + model_file = tmp_path / "all-MiniLM-L6-v2-Q4_K_M.gguf" + model_file.write_text("fake model") + + import sys + from unittest.mock import MagicMock + + mock_hub = MagicMock() + mock_hub.hf_hub_download = MagicMock(return_value=str(model_file)) + monkeypatch.setitem(sys.modules, "huggingface_hub", mock_hub) + + from cordon.embedding.llama_cpp import LlamaCppEmbedder + + config = AnalysisConfig(backend="llama-cpp", model_path=None) + + with caplog.at_level(logging.INFO, logger="cordon.embedding.llama_cpp"): + try: + LlamaCppEmbedder(config) + except (RuntimeError, ValueError): + pass + + log_messages = [r.message for r in caplog.records] + assert any("Downloading default GGUF model" in msg for msg in log_messages) + -class TestLlamaCppVectorizerFactory: +class TestLlamaCppEmbedderFactory: """Tests for factory function with llama.cpp backend.""" - def test_factory_creates_llama_vectorizer(self, tmp_path) -> None: - """Test that factory function creates LlamaCppVectorizer.""" + def test_factory_creates_llama_embedder(self, tmp_path) -> None: + """Test that factory function creates LlamaCppEmbedder.""" pytest.importorskip("llama_cpp") - from cordon.embedding import create_vectorizer + from cordon.embedding import create_embedder model_file = tmp_path / "model.gguf" model_file.touch() @@ -73,11 +102,11 @@ def test_factory_creates_llama_vectorizer(self, tmp_path) -> None: ) with pytest.raises((ValueError, RuntimeError)): - create_vectorizer(config) + create_embedder(config) -class TestLlamaCppVectorizerEmbedding: - """Tests for LlamaCppVectorizer embedding functionality.""" +class TestLlamaCppEmbedderEmbedding: + """Tests for LlamaCppEmbedder embedding functionality.""" @pytest.fixture def model_path(self) -> str: @@ -90,11 +119,11 @@ def model_path(self) -> str: return model_path @pytest.fixture - def vectorizer(self, model_path: str): - """Create a LlamaCppVectorizer instance for testing.""" + def embedder(self, model_path: str): + """Create a LlamaCppEmbedder instance for testing.""" pytest.importorskip("llama_cpp") - from cordon.embedding.llama_cpp import LlamaCppVectorizer + from cordon.embedding.llama_cpp import LlamaCppEmbedder config = AnalysisConfig( backend="llama-cpp", @@ -102,9 +131,9 @@ def vectorizer(self, model_path: str): n_ctx=512, n_gpu_layers=0, ) - return LlamaCppVectorizer(config) + return LlamaCppEmbedder(config) - def test_embed_single_window(self, vectorizer) -> None: + def test_embed_single_window(self, embedder) -> None: """Test embedding a single text window.""" window = TextWindow( content="Error: Connection timeout", @@ -113,7 +142,7 @@ def test_embed_single_window(self, vectorizer) -> None: window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) assert len(results) == 1 result_window, embedding = results[0] @@ -123,7 +152,7 @@ def test_embed_single_window(self, vectorizer) -> None: assert len(embedding.shape) == 1 assert embedding.shape[0] > 0 - def test_embed_multiple_windows(self, vectorizer) -> None: + def test_embed_multiple_windows(self, embedder) -> None: """Test embedding multiple text windows.""" windows = [ TextWindow( @@ -146,7 +175,7 @@ def test_embed_multiple_windows(self, vectorizer) -> None: ), ] - results = list(vectorizer.embed_windows(windows)) + results = list(embedder.embed_windows(windows)) assert len(results) == 3 for i, (result_window, embedding) in enumerate(results): @@ -154,7 +183,7 @@ def test_embed_multiple_windows(self, vectorizer) -> None: assert isinstance(embedding, np.ndarray) assert embedding.dtype == np.float32 - def test_embedding_normalization(self, vectorizer) -> None: + def test_embedding_normalization(self, embedder) -> None: """Test that embeddings are L2 normalized.""" window = TextWindow( content="Test content for normalization", @@ -163,13 +192,13 @@ def test_embedding_normalization(self, vectorizer) -> None: window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) _, embedding = results[0] norm = np.linalg.norm(embedding) assert np.isclose(norm, 1.0, atol=1e-6) - def test_embedding_consistency(self, vectorizer) -> None: + def test_embedding_consistency(self, embedder) -> None: """Test that same input produces same embedding.""" window = TextWindow( content="Consistent content test", @@ -178,20 +207,20 @@ def test_embedding_consistency(self, vectorizer) -> None: window_id=0, ) - results1 = list(vectorizer.embed_windows([window])) - results2 = list(vectorizer.embed_windows([window])) + results1 = list(embedder.embed_windows([window])) + results2 = list(embedder.embed_windows([window])) _, embedding1 = results1[0] _, embedding2 = results2[0] np.testing.assert_array_almost_equal(embedding1, embedding2, decimal=5) - def test_empty_windows_list(self, vectorizer) -> None: + def test_empty_windows_list(self, embedder) -> None: """Test embedding empty list of windows.""" - results = list(vectorizer.embed_windows([])) + results = list(embedder.embed_windows([])) assert len(results) == 0 - def test_semantic_similarity(self, vectorizer) -> None: + def test_semantic_similarity(self, embedder) -> None: """Test that semantically similar texts have similar embeddings.""" window1 = TextWindow( content="Error: Database connection failed", @@ -212,7 +241,7 @@ def test_semantic_similarity(self, vectorizer) -> None: window_id=2, ) - results = list(vectorizer.embed_windows([window1, window2, window3])) + results = list(embedder.embed_windows([window1, window2, window3])) _, emb1 = results[0] _, emb2 = results[1] _, emb3 = results[2] @@ -223,7 +252,7 @@ def test_semantic_similarity(self, vectorizer) -> None: assert sim_1_2 > sim_1_3 -class TestLlamaCppVectorizerIntegration: +class TestLlamaCppEmbedderIntegration: """Integration tests with the full analysis pipeline.""" def test_config_validation_for_llama_cpp(self, tmp_path) -> None: diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..fba1fe2 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,41 @@ +"""Tests for shared normalization utility.""" + +import numpy as np + +from cordon.embedding.normalize import normalize_embeddings + + +class TestNormalizeEmbeddings: + """Tests for L2 normalization of embedding vectors.""" + + def test_single_vector(self) -> None: + """Test normalization of a single 1D vector.""" + vec = np.array([3.0, 4.0], dtype=np.float32) + result = normalize_embeddings(vec) + assert np.isclose(np.linalg.norm(result), 1.0) + + def test_batch(self) -> None: + """Test normalization of a 2D batch of vectors.""" + batch = np.array([[3.0, 4.0], [1.0, 0.0]], dtype=np.float32) + result = normalize_embeddings(batch) + for row in result: + assert np.isclose(np.linalg.norm(row), 1.0) + + def test_zero_vector(self) -> None: + """Test that a zero vector is left as-is.""" + vec = np.zeros(3, dtype=np.float32) + result = normalize_embeddings(vec) + assert np.allclose(result, 0.0) + + def test_batch_with_zero(self) -> None: + """Test that zero vectors in a batch are handled safely.""" + batch = np.array([[3.0, 4.0], [0.0, 0.0]], dtype=np.float32) + result = normalize_embeddings(batch) + assert np.isclose(np.linalg.norm(result[0]), 1.0) + assert np.allclose(result[1], 0.0, atol=1e-9) + + def test_already_normalized(self) -> None: + """Test that an already-normalized vector is unchanged.""" + vec = np.array([1.0, 0.0, 0.0], dtype=np.float32) + result = normalize_embeddings(vec) + assert np.allclose(result, vec) diff --git a/tests/test_remote.py b/tests/test_remote.py index 7af264a..f1289e7 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -1,3 +1,5 @@ +"""Unit tests for remote embedder backend.""" + from unittest.mock import MagicMock, patch import numpy as np @@ -8,19 +10,19 @@ from cordon.core.types import TextWindow -class TestRemoteVectorizerConfiguration: - """Tests for RemoteVectorizer configuration and initialization.""" +class TestRemoteEmbedderConfiguration: + """Tests for RemoteEmbedder configuration and initialization.""" - def test_remote_backend_creates_remote_vectorizer(self) -> None: - """Test that remote backend creates RemoteVectorizer.""" - from cordon.embedding import create_vectorizer + def test_remote_backend_creates_remote_embedder(self) -> None: + """Test that remote backend creates RemoteEmbedder.""" + from cordon.embedding import create_embedder config = AnalysisConfig(backend="remote", model_name="openai/text-embedding-3-small") - vectorizer = create_vectorizer(config) + embedder = create_embedder(config) - from cordon.embedding.remote import RemoteVectorizer + from cordon.embedding.remote import RemoteEmbedder - assert isinstance(vectorizer, RemoteVectorizer) + assert isinstance(embedder, RemoteEmbedder) def test_config_validation_for_remote(self) -> None: """Test that AnalysisConfig validates remote backend.""" @@ -45,8 +47,8 @@ def test_invalid_timeout_raises_error(self) -> None: AnalysisConfig(backend="remote", request_timeout=-1) -class TestRemoteVectorizerEmbedding: - """Tests for RemoteVectorizer embedding functionality.""" +class TestRemoteEmbedderEmbedding: + """Tests for RemoteEmbedder embedding functionality.""" @pytest.fixture def mock_litellm_response(self): @@ -58,9 +60,9 @@ def mock_litellm_response(self): return mock_response @pytest.fixture - def vectorizer(self): - """Create a RemoteVectorizer instance for testing.""" - from cordon.embedding.remote import RemoteVectorizer + def embedder(self): + """Create a RemoteEmbedder instance for testing.""" + from cordon.embedding.remote import RemoteEmbedder config = AnalysisConfig( backend="remote", @@ -68,10 +70,10 @@ def vectorizer(self): api_key="test-key", batch_size=2, ) - return RemoteVectorizer(config) + return RemoteEmbedder(config) @patch("cordon.embedding.remote.litellm.embedding") - def test_embed_single_window(self, mock_embedding, vectorizer, mock_litellm_response) -> None: + def test_embed_single_window(self, mock_embedding, embedder, mock_litellm_response) -> None: """Test embedding a single text window.""" mock_embedding.return_value = mock_litellm_response @@ -82,7 +84,7 @@ def test_embed_single_window(self, mock_embedding, vectorizer, mock_litellm_resp window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) assert len(results) == 1 result_window, embedding = results[0] @@ -99,7 +101,7 @@ def test_embed_single_window(self, mock_embedding, vectorizer, mock_litellm_resp assert call_kwargs["api_key"] == "test-key" @patch("cordon.embedding.remote.litellm.embedding") - def test_embedding_normalization(self, mock_embedding, vectorizer) -> None: + def test_embedding_normalization(self, mock_embedding, embedder) -> None: """Test that embeddings are L2 normalized.""" mock_response = MagicMock() mock_response.data = [ @@ -114,21 +116,21 @@ def test_embedding_normalization(self, mock_embedding, vectorizer) -> None: window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) _, embedding = results[0] norm = np.linalg.norm(embedding) assert np.isclose(norm, 1.0, atol=1e-6) @patch("cordon.embedding.remote.litellm.embedding") - def test_empty_windows_list(self, mock_embedding, vectorizer) -> None: + def test_empty_windows_list(self, mock_embedding, embedder) -> None: """Test embedding empty list of windows.""" - results = list(vectorizer.embed_windows([])) + results = list(embedder.embed_windows([])) assert len(results) == 0 mock_embedding.assert_not_called() @patch("cordon.embedding.remote.litellm.embedding") - def test_batching(self, mock_embedding, vectorizer) -> None: + def test_batching(self, mock_embedding, embedder) -> None: """Test that batching works correctly.""" def mock_embedding_response(**kwargs): @@ -147,13 +149,13 @@ def mock_embedding_response(**kwargs): for i in range(1, 6) ] - results = list(vectorizer.embed_windows(windows)) + results = list(embedder.embed_windows(windows)) assert len(results) == 5 assert mock_embedding.call_count == 3 @patch("cordon.embedding.remote.litellm.embedding") - def test_authentication_error(self, mock_embedding, vectorizer) -> None: + def test_authentication_error(self, mock_embedding, embedder) -> None: """Test handling of authentication errors.""" mock_embedding.side_effect = AuthenticationError( message="Invalid API key", @@ -169,10 +171,10 @@ def test_authentication_error(self, mock_embedding, vectorizer) -> None: ) with pytest.raises(RuntimeError, match="Authentication failed"): - list(vectorizer.embed_windows([window])) + list(embedder.embed_windows([window])) @patch("cordon.embedding.remote.litellm.embedding") - def test_rate_limit_error(self, mock_embedding, vectorizer) -> None: + def test_rate_limit_error(self, mock_embedding, embedder) -> None: """Test handling of rate limit errors.""" mock_embedding.side_effect = RateLimitError( message="Too many requests", @@ -188,10 +190,10 @@ def test_rate_limit_error(self, mock_embedding, vectorizer) -> None: ) with pytest.raises(RuntimeError, match="Rate limit exceeded"): - list(vectorizer.embed_windows([window])) + list(embedder.embed_windows([window])) @patch("cordon.embedding.remote.litellm.embedding") - def test_timeout_error(self, mock_embedding, vectorizer) -> None: + def test_timeout_error(self, mock_embedding, embedder) -> None: """Test handling of timeout errors.""" mock_embedding.side_effect = Timeout( message="Request timeout after 60 seconds", @@ -207,10 +209,10 @@ def test_timeout_error(self, mock_embedding, vectorizer) -> None: ) with pytest.raises(RuntimeError, match="Request timeout"): - list(vectorizer.embed_windows([window])) + list(embedder.embed_windows([window])) @patch("cordon.embedding.remote.litellm.embedding") - def test_generic_error(self, mock_embedding, vectorizer) -> None: + def test_generic_error(self, mock_embedding, embedder) -> None: """Test handling of generic errors.""" mock_embedding.side_effect = Exception("Some unexpected error") @@ -222,4 +224,21 @@ def test_generic_error(self, mock_embedding, vectorizer) -> None: ) with pytest.raises(RuntimeError, match="Error calling remote embedding API"): - list(vectorizer.embed_windows([window])) + list(embedder.embed_windows([window])) + + @patch("cordon.embedding.remote.litellm.embedding") + def test_malformed_api_response(self, mock_embedding, embedder) -> None: + """Test handling of malformed API response without 'embedding' field.""" + mock_response = MagicMock() + mock_response.data = [{"wrong_field": [0.1, 0.2]}] + mock_embedding.return_value = mock_response + + window = TextWindow( + content="Test content", + start_line=1, + end_line=1, + window_id=0, + ) + + with pytest.raises(RuntimeError, match="Unexpected API response format"): + list(embedder.embed_windows([window])) diff --git a/tests/test_transformer.py b/tests/test_transformer.py index 45e047b..c934824 100644 --- a/tests/test_transformer.py +++ b/tests/test_transformer.py @@ -1,4 +1,6 @@ -"""Unit tests for sentence-transformers vectorizer backend.""" +"""Unit tests for sentence-transformers embedder backend.""" + +from unittest.mock import patch import numpy as np import pytest @@ -7,62 +9,71 @@ from cordon.core.types import TextWindow -class TestTransformerVectorizerConfiguration: - """Tests for TransformerVectorizer configuration and initialization.""" +class TestTransformerEmbedderConfiguration: + """Tests for TransformerEmbedder configuration and initialization.""" def test_default_backend_creates_transformer(self) -> None: - """Test that default backend creates TransformerVectorizer.""" - from cordon.embedding import create_vectorizer + """Test that default backend creates TransformerEmbedder.""" + from cordon.embedding import create_embedder config = AnalysisConfig() - vectorizer = create_vectorizer(config) + embedder = create_embedder(config) - from cordon.embedding.transformer import TransformerVectorizer + from cordon.embedding.transformer import TransformerEmbedder - assert isinstance(vectorizer, TransformerVectorizer) + assert isinstance(embedder, TransformerEmbedder) def test_explicit_sentence_transformers_backend(self) -> None: """Test explicit sentence-transformers backend selection.""" - from cordon.embedding import create_vectorizer + from cordon.embedding import create_embedder config = AnalysisConfig(backend="sentence-transformers") - vectorizer = create_vectorizer(config) + embedder = create_embedder(config) - from cordon.embedding.transformer import TransformerVectorizer + from cordon.embedding.transformer import TransformerEmbedder - assert isinstance(vectorizer, TransformerVectorizer) + assert isinstance(embedder, TransformerEmbedder) def test_device_detection(self) -> None: """Test device detection logic.""" - from cordon.embedding.transformer import TransformerVectorizer + from cordon.embedding.transformer import TransformerEmbedder config = AnalysisConfig(device="cpu") - vectorizer = TransformerVectorizer(config) + embedder = TransformerEmbedder(config) - assert vectorizer.device == "cpu" + assert embedder.device == "cpu" def test_custom_model_name(self) -> None: """Test custom model name configuration.""" - from cordon.embedding.transformer import TransformerVectorizer + from cordon.embedding.transformer import TransformerEmbedder config = AnalysisConfig(model_name="all-MiniLM-L6-v2") - vectorizer = TransformerVectorizer(config) + embedder = TransformerEmbedder(config) + + assert embedder.config.model_name == "all-MiniLM-L6-v2" + + @patch("cordon.embedding.transformer.SentenceTransformer") + def test_model_loading_failure(self, mock_st) -> None: + """Test that model loading failure raises RuntimeError.""" + mock_st.side_effect = OSError("Model not found") + with pytest.raises(RuntimeError, match="Failed to load"): + from cordon.embedding.transformer import TransformerEmbedder - assert vectorizer.config.model_name == "all-MiniLM-L6-v2" + TransformerEmbedder(AnalysisConfig(device="cpu")) -class TestTransformerVectorizerEmbedding: - """Tests for TransformerVectorizer embedding functionality.""" +class TestTransformerEmbedderEmbedding: + """Tests for TransformerEmbedder embedding functionality.""" @pytest.fixture - def vectorizer(self): - """Create a TransformerVectorizer instance for testing.""" - from cordon.embedding.transformer import TransformerVectorizer + def embedder(self): + """Create a TransformerEmbedder instance for testing.""" + from cordon.embedding.transformer import TransformerEmbedder config = AnalysisConfig(device="cpu", batch_size=2) - return TransformerVectorizer(config) + return TransformerEmbedder(config) - def test_embed_single_window(self, vectorizer) -> None: + def test_embed_single_window(self, embedder) -> None: """Test embedding a single text window.""" window = TextWindow( content="Error: Connection timeout", @@ -71,7 +82,7 @@ def test_embed_single_window(self, vectorizer) -> None: window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) assert len(results) == 1 result_window, embedding = results[0] @@ -81,7 +92,7 @@ def test_embed_single_window(self, vectorizer) -> None: assert len(embedding.shape) == 1 assert embedding.shape[0] > 0 - def test_embed_multiple_windows(self, vectorizer) -> None: + def test_embed_multiple_windows(self, embedder) -> None: """Test embedding multiple text windows.""" windows = [ TextWindow( @@ -104,7 +115,7 @@ def test_embed_multiple_windows(self, vectorizer) -> None: ), ] - results = list(vectorizer.embed_windows(windows)) + results = list(embedder.embed_windows(windows)) assert len(results) == 3 for i, (result_window, embedding) in enumerate(results): @@ -112,7 +123,7 @@ def test_embed_multiple_windows(self, vectorizer) -> None: assert isinstance(embedding, np.ndarray) assert embedding.dtype == np.float32 - def test_embedding_normalization(self, vectorizer) -> None: + def test_embedding_normalization(self, embedder) -> None: """Test that embeddings are L2 normalized.""" window = TextWindow( content="Test content for normalization", @@ -121,13 +132,13 @@ def test_embedding_normalization(self, vectorizer) -> None: window_id=0, ) - results = list(vectorizer.embed_windows([window])) + results = list(embedder.embed_windows([window])) _, embedding = results[0] norm = np.linalg.norm(embedding) assert np.isclose(norm, 1.0, atol=1e-6) - def test_embedding_consistency(self, vectorizer) -> None: + def test_embedding_consistency(self, embedder) -> None: """Test that same input produces same embedding.""" window = TextWindow( content="Consistent content test", @@ -136,27 +147,27 @@ def test_embedding_consistency(self, vectorizer) -> None: window_id=0, ) - results1 = list(vectorizer.embed_windows([window])) - results2 = list(vectorizer.embed_windows([window])) + results1 = list(embedder.embed_windows([window])) + results2 = list(embedder.embed_windows([window])) _, embedding1 = results1[0] _, embedding2 = results2[0] np.testing.assert_array_almost_equal(embedding1, embedding2, decimal=5) - def test_empty_windows_list(self, vectorizer) -> None: + def test_empty_windows_list(self, embedder) -> None: """Test embedding empty list of windows.""" - results = list(vectorizer.embed_windows([])) + results = list(embedder.embed_windows([])) assert len(results) == 0 - def test_batching(self, vectorizer) -> None: + def test_batching(self, embedder) -> None: """Test that batching works correctly.""" windows = [ TextWindow(content=f"Log line {i}", start_line=i, end_line=i, window_id=i - 1) for i in range(1, 6) ] - results = list(vectorizer.embed_windows(windows)) + results = list(embedder.embed_windows(windows)) assert len(results) == 5 for i, (window, embedding) in enumerate(results): @@ -165,17 +176,17 @@ def test_batching(self, vectorizer) -> None: assert embedding.shape[0] > 0 -class TestTransformerVectorizerIntegration: +class TestTransformerEmbedderIntegration: """Integration tests with the full analysis pipeline.""" def test_factory_creates_transformer_by_default(self) -> None: - """Test that factory creates TransformerVectorizer by default.""" - from cordon.embedding import create_vectorizer + """Test that factory creates TransformerEmbedder by default.""" + from cordon.embedding import create_embedder config = AnalysisConfig() - vectorizer = create_vectorizer(config) + embedder = create_embedder(config) - from cordon.embedding.transformer import TransformerVectorizer + from cordon.embedding.transformer import TransformerEmbedder - assert isinstance(vectorizer, TransformerVectorizer) - assert vectorizer.config.backend == "sentence-transformers" + assert isinstance(embedder, TransformerEmbedder) + assert embedder.config.backend == "sentence-transformers"