Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
3 changes: 2 additions & 1 deletion src/semble/ranking/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
14 changes: 2 additions & 12 deletions src/semble/ranking/boosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/semble/ranking/weighting.py
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions tests/test_git.py
Original file line number Diff line number Diff line change
@@ -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)
82 changes: 0 additions & 82 deletions tests/test_index.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import os
import subprocess
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest

Expand Down Expand Up @@ -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)
9 changes: 8 additions & 1 deletion tests/test_ranking.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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 == {}
Loading