diff --git a/Makefile b/Makefile index af897b87a..b7a689289 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ install: test: uv run pytest +test-no-git: + uv run pytest --ignore=tests/test_git.py + lint: uv run ruff check src/ tests/ uv run pydoclint src/ diff --git a/src/semble/ranking/__init__.py b/src/semble/ranking/__init__.py index c77d51ed4..1542c085b 100644 --- a/src/semble/ranking/__init__.py +++ b/src/semble/ranking/__init__.py @@ -1,4 +1,5 @@ -from semble.ranking.boosting import apply_query_boost, boost_multi_chunk_files, resolve_alpha +from semble.ranking.boosting import apply_query_boost, boost_multi_chunk_files from semble.ranking.penalties import rerank_topk +from semble.ranking.weighting import resolve_alpha __all__ = ["apply_query_boost", "boost_multi_chunk_files", "rerank_topk", "resolve_alpha"] diff --git a/src/semble/ranking/boosting.py b/src/semble/ranking/boosting.py index 65dc6b0e7..fc00d058b 100644 --- a/src/semble/ranking/boosting.py +++ b/src/semble/ranking/boosting.py @@ -31,9 +31,6 @@ # Half-strength: the symbol may be incidental to the NL query. _EMBEDDED_SYMBOL_BOOST_SCALE = 0.5 -_ALPHA_SYMBOL = 0.3 # lean BM25 for exact keyword matching -_ALPHA_NL = 0.5 # balanced semantic + BM25 - # Case-sensitive: IGNORECASE produces false positives like "Module" in Python docs # or "Class" method calls in Ruby. _DEFINITION_KEYWORDS = ( @@ -88,13 +85,6 @@ ) -def resolve_alpha(query: str, alpha: float | None) -> float: - """Return the blending weight for semantic scores, auto-detecting from query type.""" - if alpha is not None: - return alpha - return _ALPHA_SYMBOL if _is_symbol_query(query) else _ALPHA_NL - - def apply_query_boost( combined_scores: dict[Chunk, float], query: str, @@ -107,7 +97,7 @@ def apply_query_boost( max_score = max(combined_scores.values()) boosted = dict(combined_scores) - if _is_symbol_query(query): + if is_symbol_query(query): _boost_symbol_definitions(boosted, query, max_score, all_chunks) else: _boost_stem_matches(boosted, query, max_score) @@ -139,7 +129,7 @@ def boost_multi_chunk_files(scores: dict[Chunk, float]) -> None: scores[chunk] += boost_unit * file_sum[file_path] / max_file_sum -def _is_symbol_query(query: str) -> bool: +def is_symbol_query(query: str) -> bool: """Return True if the query looks like a bare symbol or namespace-qualified identifier.""" return _SYMBOL_QUERY_RE.match(query.strip()) is not None diff --git a/src/semble/ranking/weighting.py b/src/semble/ranking/weighting.py new file mode 100644 index 000000000..e023d8a19 --- /dev/null +++ b/src/semble/ranking/weighting.py @@ -0,0 +1,11 @@ +from semble.ranking.boosting import is_symbol_query + +_ALPHA_SYMBOL = 0.3 # lean BM25 for exact keyword matching +_ALPHA_NL = 0.5 # balanced semantic + BM25 + + +def resolve_alpha(query: str, alpha: float | None) -> float: + """Return the blending weight for semantic scores, auto-detecting from query type.""" + if alpha is not None: + return alpha + return _ALPHA_SYMBOL if is_symbol_query(query) else _ALPHA_NL diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 000000000..246750224 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,87 @@ +import os +import subprocess +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from semble import SembleIndex + +_GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "t@t.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "t@t.com", +} + + +def _make_git_repo(path: Path) -> None: + """Initialise a bare git repo at path; author identity comes from _GIT_ENV.""" + subprocess.run(["git", "init", str(path)], check=True, capture_output=True) + + +def _commit_file(repo: Path, name: str, content: str, message: str = "add file") -> None: + """Write a file, stage it, and commit it inside repo.""" + (repo / name).write_text(content) + subprocess.run(["git", "-C", str(repo), "add", name], check=True, capture_output=True, env=_GIT_ENV) + subprocess.run(["git", "-C", str(repo), "commit", "-m", message], check=True, capture_output=True, env=_GIT_ENV) + + +@pytest.fixture +def git_repo(tmp_path: Path) -> Path: + """Create a minimal local git repository with one Python file.""" + _make_git_repo(tmp_path) + _commit_file(tmp_path, "main.py", "def hello():\n return 'hello'\n") + return tmp_path + + +def test_from_git_indexes_local_repo_with_relative_paths(mock_model: Any, git_repo: Path) -> None: + """from_git clones a local repo, indexes it, and keeps chunk paths repo-relative.""" + idx = SembleIndex.from_git(str(git_repo), model=mock_model) + assert idx.stats.indexed_files >= 1 + assert idx.stats.total_chunks > 0 + assert any("main.py" in c.file_path for c in idx.chunks) + assert all(not Path(c.file_path).is_absolute() for c in idx.chunks) + + +def test_from_git_with_branch(mock_model: Any, tmp_path: Path) -> None: + """from_git with ref= checks out the specified branch.""" + repo = tmp_path / "repo" + repo.mkdir() + _make_git_repo(repo) + _commit_file(repo, "main.py", "def on_main(): pass\n", "main") + subprocess.run(["git", "-C", str(repo), "checkout", "-b", "feature"], check=True, capture_output=True) + _commit_file(repo, "feature.py", "def on_feature(): pass\n", "feature") + + idx = SembleIndex.from_git(str(repo), ref="feature", model=mock_model) + file_names = {Path(c.file_path).name for c in idx.chunks} + assert "feature.py" in file_names + + +@pytest.mark.parametrize( + ("kind", "expected_exc"), + [("missing", FileNotFoundError), ("file", NotADirectoryError)], +) +def test_from_path_rejects_invalid_paths( + mock_model: Any, tmp_path: Path, kind: str, expected_exc: type[Exception] +) -> None: + """from_path raises FileNotFoundError for missing paths and NotADirectoryError for files.""" + if kind == "missing": + target = tmp_path / "does_not_exist" + else: + target = tmp_path / "not_a_dir.py" + target.write_text("x = 1\n") + with pytest.raises(expected_exc): + SembleIndex.from_path(target, model=mock_model) + + +def test_from_git_raises_on_failure(mock_model: Any) -> None: + """from_git raises RuntimeError when the clone fails or git is not installed.""" + with pytest.raises(RuntimeError, match="git clone failed"): + SembleIndex.from_git("/nonexistent/path/that/does/not/exist", model=mock_model) + + with patch("semble.index.index.subprocess.run", side_effect=FileNotFoundError): + with pytest.raises(RuntimeError, match="git is not installed"): + SembleIndex.from_git("https://github.com/x/y", model=mock_model) diff --git a/tests/test_index.py b/tests/test_index.py index 78a520851..c759f3d20 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,8 +1,5 @@ -import os -import subprocess from pathlib import Path from typing import Any -from unittest.mock import patch import pytest @@ -96,82 +93,3 @@ def test_find_related(indexed_index: SembleIndex) -> None: assert [r.chunk for r in indexed_index.find_related(result, top_k=3)] == [ r.chunk for r in indexed_index.find_related(result.chunk, top_k=3) ] - - -_GIT_ENV = { - **os.environ, - "GIT_AUTHOR_NAME": "test", - "GIT_AUTHOR_EMAIL": "t@t.com", - "GIT_COMMITTER_NAME": "test", - "GIT_COMMITTER_EMAIL": "t@t.com", -} - - -def _make_git_repo(path: Path) -> None: - """Initialise a bare git repo at path; author identity comes from _GIT_ENV.""" - subprocess.run(["git", "init", str(path)], check=True, capture_output=True) - - -def _commit_file(repo: Path, name: str, content: str, message: str = "add file") -> None: - """Write a file, stage it, and commit it inside repo.""" - (repo / name).write_text(content) - subprocess.run(["git", "-C", str(repo), "add", name], check=True, capture_output=True, env=_GIT_ENV) - subprocess.run(["git", "-C", str(repo), "commit", "-m", message], check=True, capture_output=True, env=_GIT_ENV) - - -@pytest.fixture -def git_repo(tmp_path: Path) -> Path: - """Create a minimal local git repository with one Python file.""" - _make_git_repo(tmp_path) - _commit_file(tmp_path, "main.py", "def hello():\n return 'hello'\n") - return tmp_path - - -def test_from_git_indexes_local_repo_with_relative_paths(mock_model: Any, git_repo: Path) -> None: - """from_git clones a local repo, indexes it, and keeps chunk paths repo-relative.""" - idx = SembleIndex.from_git(str(git_repo), model=mock_model) - assert idx.stats.indexed_files >= 1 - assert idx.stats.total_chunks > 0 - assert any("main.py" in c.file_path for c in idx.chunks) - assert all(not Path(c.file_path).is_absolute() for c in idx.chunks) - - -def test_from_git_with_branch(mock_model: Any, tmp_path: Path) -> None: - """from_git with ref= checks out the specified branch.""" - repo = tmp_path / "repo" - repo.mkdir() - _make_git_repo(repo) - _commit_file(repo, "main.py", "def on_main(): pass\n", "main") - subprocess.run(["git", "-C", str(repo), "checkout", "-b", "feature"], check=True, capture_output=True) - _commit_file(repo, "feature.py", "def on_feature(): pass\n", "feature") - - idx = SembleIndex.from_git(str(repo), ref="feature", model=mock_model) - file_names = {Path(c.file_path).name for c in idx.chunks} - assert "feature.py" in file_names - - -@pytest.mark.parametrize( - ("kind", "expected_exc"), - [("missing", FileNotFoundError), ("file", NotADirectoryError)], -) -def test_from_path_rejects_invalid_paths( - mock_model: Any, tmp_path: Path, kind: str, expected_exc: type[Exception] -) -> None: - """from_path raises FileNotFoundError for missing paths and NotADirectoryError for files.""" - if kind == "missing": - target = tmp_path / "does_not_exist" - else: - target = tmp_path / "not_a_dir.py" - target.write_text("x = 1\n") - with pytest.raises(expected_exc): - SembleIndex.from_path(target, model=mock_model) - - -def test_from_git_raises_on_failure(mock_model: Any) -> None: - """from_git raises RuntimeError when the clone fails or git is not installed.""" - with pytest.raises(RuntimeError, match="git clone failed"): - SembleIndex.from_git("/nonexistent/path/that/does/not/exist", model=mock_model) - - with patch("semble.index.index.subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(RuntimeError, match="git is not installed"): - SembleIndex.from_git("https://github.com/x/y", model=mock_model) diff --git a/tests/test_ranking.py b/tests/test_ranking.py index 486dee4b6..6a3339778 100644 --- a/tests/test_ranking.py +++ b/tests/test_ranking.py @@ -1,7 +1,8 @@ import pytest -from semble.ranking.boosting import apply_query_boost, boost_multi_chunk_files, resolve_alpha +from semble.ranking.boosting import apply_query_boost, boost_multi_chunk_files from semble.ranking.penalties import rerank_topk +from semble.ranking.weighting import resolve_alpha from tests.conftest import make_chunk @@ -147,3 +148,9 @@ def test_boost_multi_chunk_files() -> None: scores: dict = {c1: 1.0, c2: 0.8, c3: 1.0} boost_multi_chunk_files(scores) assert scores[c1] > 1.0 + + +def test_boosting_with_empty() -> None: + """Test that boosting with empty chunks return None.""" + boosted = apply_query_boost({}, "query", []) + assert boosted == {}