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
2 changes: 1 addition & 1 deletion docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Copy the block between `<!-- BEGIN` and `<!-- END` into your project's `AGENTS.m

**Indexed content:** Java production sources plus SQL and YAML (use `search` `table`: `java`, `sql`, `yaml`, or `all`).

**Ontology: 18** — if results look structurally wrong or empty across tools, the index may be missing, stale, or built with a different `ontology_version`; you cannot re-index via MCP — ask the operator to rebuild.
**Ontology: 19** — if results look structurally wrong or empty across tools, the index may be missing, stale, or built with a different `ontology_version`; you cannot re-index via MCP — ask the operator to rebuild.

**Responses:** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` may include two top-level fields: `hints_structured` (≤5 suggested next-tool calls) and `advisories` (≤5 pure informational strings). Each `hints_structured` entry has `tool`, `args`, `actionable`, `label`, and `reason`. `actionable=true` means you can call the tool directly with `args`; `actionable=false` means partial/advisory — fill missing values or use as guidance. `reason` explains why the hint was emitted. `advisories` carry context education (fuzzy strategy warnings, role collision explanations, etc.) with no tool call suggestion. For `search`/`find`, echoed `limit`/`offset`. Hints are advisory; ignore them when `success` is false.

Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Core library = **top-level `.py` modules** (`py-modules`); the installable **`ja
| Concern | Modules |
| --- | --- |
| Write path | `java_codebase_rag/cli.py`, `java_codebase_rag/pipeline.py`, `java_codebase_rag/lance_optimize.py`, `java_index_flow_lancedb.py`, `build_ast_graph.py` |
| Parse + ontology | `ast_java.py` (`ONTOLOGY_VERSION=18`), `java_ontology.py` (`EDGE_SCHEMA` + label sets), `graph_enrich.py`, `chunk_heuristics.py` |
| Parse + ontology | `ast_java.py` (`ONTOLOGY_VERSION=19`), `java_ontology.py` (`EDGE_SCHEMA` + label sets), `graph_enrich.py`, `chunk_heuristics.py` |
| Read path | `server.py`, `mcp_v2.py`, `ladybug_queries.py`, `search_lancedb.py`, `search_lexical.py`, `search_scoring.py`, `resolve_service.py`, `java_codebase_rag/read_payloads.py` |
| Hints + absence | `mcp_hints.py`, `graph_types.py`, `absence_types.py`, `absence_vocab.py`, `absence_diagnosis.py` |
| Config + paths | `java_codebase_rag/config.py`, `path_filtering.py`, `index_common.py`, `brownfield_events.py` |
Expand Down Expand Up @@ -59,7 +59,7 @@ java_codebase_rag/pipeline.py
```

- **`init`** — refuses a non-empty index dir (exit 2); full vectors + full graph.
- **`increment`** — CocoIndex `memo=True` catch-up (changed files only) + **incremental graph**. Falls back to **full** rebuild on any of: no graph · `ontology_version < 18` · crash marker (`.graph_increment_in_progress`) · dependent expansion > 50 files.
- **`increment`** — CocoIndex `memo=True` catch-up (changed files only) + **incremental graph**. Falls back to **full** rebuild on any of: no graph · `ontology_version < 19` · crash marker (`.graph_increment_in_progress`) · dependent expansion > 50 files.
- **`reprocess`** — default = full vectors + full graph; `--vectors-only` / `--graph-only` selective (mutually exclusive). Exit semantics in `cli._reprocess_exit_code`.

**Phantom nodes:** unresolved callees / supertypes (external libs, `java.lang`) become `Symbol` rows with `resolved=false` and empty filename — so every edge lands on *a* node. Skipped by dependent expansion and scoped deletion.
Expand All @@ -84,7 +84,7 @@ MCP tool call (server.py) ──asyncio.to_thread──▶ mcp_v2.*
| `neighbors` | Ladybug Cypher | one hop; `direction` + `edge_types` required; dot-key composed edges |
| `resolve` | Ladybug Cypher | per-kind generators exact→fuzzy; cap 10 candidates |

**Lexical fallback** is selected by import availability (`mcp_v2` guards `from search_lancedb import …`): same row contract, flagged via `lexical_mode` + advisory. It is **BM25-first**: `build_ast_graph` indexes `Symbol.search_text` (camelCase-split token soup) under a LadybugDB FTS index (`sym_fts`, Okapi BM25), and `search_lexical` fetches top-K candidates via `QUERY_FTS_INDEX` then re-ranks them with the name/type/fqn/role heuristic in `search_scoring`. The FTS index auto-maintains on `increment`; the heuristic scan is the fallback when the index/extension is absent (older graph, offline first run). **`jrag` CLI** calls the same `mcp_v2.*` functions — identical backends, only rendering differs.
**Lexical fallback** is selected by import availability (`mcp_v2` guards `from search_lancedb import …`): same row contract, flagged via `lexical_mode` + advisory. It is **BM25-first**: `build_ast_graph` indexes `Symbol.search_text` (camelCase-split token soup) under a LadybugDB FTS index (`sym_fts`, Okapi BM25), and `search_lexical` fetches top-K candidates via `QUERY_FTS_INDEX` then re-ranks them with the name/type/fqn/role heuristic in `search_lexical` (helpers from `search_scoring`). The FTS index auto-maintains on `increment`; the heuristic scan is the fallback when the index/extension is absent (older graph, offline first run). **`jrag` CLI** calls the same `mcp_v2.*` functions — identical backends, only rendering differs.

### Watch path (`jrag watch`) — warm reads + freshness

Expand Down
6 changes: 3 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ Deeper documentation for the brownfield blocks (`role_overrides`, `route_overrid

## 3. Graph layer

A deterministic property graph derived from tree-sitter Java parsing lives next to the LanceDB tables under the index directory (default `${JAVA_CODEBASE_RAG_INDEX_DIR:-./.java-codebase-rag}/code_graph.lbug`). Current ontology version: **18** (see [`EDGE-NAVIGATION.md`](./EDGE-NAVIGATION.md) for MCP-traversable edge shapes).
A deterministic property graph derived from tree-sitter Java parsing lives next to the LanceDB tables under the index directory (default `${JAVA_CODEBASE_RAG_INDEX_DIR:-./.java-codebase-rag}/code_graph.lbug`). Current ontology version: **19** (see [`EDGE-NAVIGATION.md`](./EDGE-NAVIGATION.md) for MCP-traversable edge shapes).

### Node kinds

Expand Down Expand Up @@ -403,7 +403,7 @@ Resolution order for `microservice`:

### Re-index required when ontology changes

Current ontology version is **18**. Any index built before this version must be rebuilt via `cocoindex update ... --full-reprocess -f` or a full `java-codebase-rag reprocess` (no selective flags) so vectors and graph stay aligned. Until re-indexed, the server defensively JSON-decodes string-form list columns so nothing explodes, but filters like `array_contains` will not work.
Current ontology version is **19**. Any index built before this version must be rebuilt via `cocoindex update ... --full-reprocess -f` or a full `java-codebase-rag reprocess` (no selective flags) so vectors and graph stay aligned. Until re-indexed, the server defensively JSON-decodes string-form list columns so nothing explodes, but filters like `array_contains` will not work.

Ontology **15** (CALLS-NOISE) adds `CALLS.callee_declaring_role`, `GraphMeta.pass3_unresolved_phantom_receiver` / `pass3_unresolved_chained`, and **supertype-walk dedup** at build time. PR-2 adds `edge_filter` on `neighbors`. **PR-3 (breaking):** receiver-failure sites (`chained_receiver`, unresolved-receiver `phantom`) are no longer `CALLS` rows — they live on `UnresolvedCallSite` + `UNRESOLVED_AT`. Default `neighbors(..., ['CALLS'])` returns fewer rows; use `include_unresolved=True` for a source-ordered interleaved transcript (`row_kind`), `describe(method_id).unresolved_call_sites` (capped), or `java-codebase-rag unresolved-calls list|stats`. Known-receiver-external JDK rows stay on `CALLS` with `resolved=false`.

Expand Down Expand Up @@ -454,7 +454,7 @@ Combined, these pull `processClientMessage` / `pickEligibleOperator` / `onOperat

#### Graph-only (macOS Intel) lexical ranking

On Intel Mac installs the vector stack is absent (see `README.md`), so `search` runs the **lexical backend** — keyword ranking over the symbol graph instead of embeddings, behind the same tool contract. It is **BM25-first**: at index time every `Symbol` gets a `search_text` column (camelCase-split tokens of name + fqn + signature + annotations + capabilities, tokenized with the same splitter the query path uses) and a LadybugDB FTS index (`sym_fts`, Okapi BM25, porter stemmer) over it. At query time `QUERY_FTS_INDEX` fetches the top-K candidates DB-side, which are then re-ranked in Python by the heuristic below and deduped by FQN.
On Intel Mac installs the vector stack is absent (see `README.md`), so `search` runs the **lexical backend** — keyword ranking over the symbol graph instead of embeddings, behind the same tool contract. It is **BM25-first**: at index time every `Symbol` gets a `search_text` column (camelCase-split tokens of name + fqn + signature + annotations + capabilities + package, tokenized with the same splitter the query path uses) and a LadybugDB FTS index (`sym_fts`, Okapi BM25, porter stemmer) over it. At query time `QUERY_FTS_INDEX` fetches the top-K candidates DB-side, which are then re-ranked in Python by the heuristic below and deduped by FQN.

The heuristic re-rank decides final order (what `--explain` reports as `relevance=` / `name=` / `type=` / `fqn=`, plus a `bm25=` component for the index score):

Expand Down
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ One repo, two stores, two audiences:
3. **Walk at read time; don't precompute answers.** `neighbors` is exactly one hop. Multi-hop traces, impact analysis, "explain feature X" are the **agent's** reasoning over repeated one-hop calls. There is deliberately no magic impact/trace tool.
4. **Static analysis is a lower bound.** `CALLS` excludes reflection, Spring AOP proxies, dynamic dispatch. `resolved=false` means *external* (JDK/Spring), not *missing*. Never present this as proof of a runtime call path.
5. **Empty results must be honest, not silent.** Every empty hit is classified: `correct_empty` (genuine leaf), `not_in_project`, `external_dependency`, or `refine_query` — with did-you-mean, vocabulary context, and (for hard absence) an auditable proof.
6. **The ontology version is the contract.** `ONTOLOGY_VERSION` (currently **18**) gates incremental rebuilds, drives a read-time staleness guard, and tells the agent the index shape. A semantic extraction change bumps it; old indexes fall back to full rebuild.
6. **The ontology version is the contract.** `ONTOLOGY_VERSION` (currently **19**) gates incremental rebuilds, drives a read-time staleness guard, and tells the agent the index shape. A semantic extraction change bumps it; old indexes fall back to full rebuild.
7. **The file always wins.** When the index disagrees with the open source file, the index is presumed stale or partial. Mismatch is a signal to rebuild, not a fact to report.
8. **Generated sources are first-class by default.** MapStruct / OpenAPI / protobuf / … are auto-detected **by content** (`@Generated`, header banners), indexed like hand-written code, and filterable (`exclude_generated`) — never silently down-ranked.

Expand Down
28 changes: 20 additions & 8 deletions src/java_codebase_rag/search/search_lexical.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
_dedup_by_fqn,
_query_tokens,
_split_identifier,
build_fts_query,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -272,8 +273,10 @@ def run_lexical_search(
BM25-first (fork A): when the LadybugDB ``sym_fts`` index exists, candidates are
fetched DB-side via Okapi BM25 over ``Symbol.search_text`` (killing the bounded
Python scan that silently missed matches past the cap on large repos) and then
re-ranked here by the name/type/fqn/role heuristic. Falls back to that heuristic
scan when the FTS index or extension is unavailable (older graph, offline first run).
re-ranked here by the name/type/fqn/role heuristic. The query is pre-split with the
same tokenizer as ``search_text`` so pasted camelCase identifiers match. Falls back
to the heuristic scan when the FTS index or extension is unavailable (older graph,
offline first run), or when the query is degenerate / the BM25 result is empty.

Raises ``RuntimeError`` (message contains "lexical search unavailable") if no
symbol graph exists — the caller maps that to a clean failure envelope. Returns
Expand All @@ -292,14 +295,23 @@ def run_lexical_search(
g = graph or LadybugGraph.get()

# --- candidate fetch: BM25 (FTS) preferred, heuristic scan fallback ---
# FTS indexes Symbol.search_text (camelCase-split tokens); pre-split the query the
# same way (build_fts_query) so a pasted identifier like "DistributionChunkService"
# matches — LadybugDB FTS's own tokenizer does not split camelCase. An empty split
# (degenerate / stopword-only query), an unavailable FTS index, OR an empty BM25
# result all fall back to the heuristic scan, which yields role-ranked output for
# degenerate queries and covers selective filters where BM25's top-K thins to nil.
bm25_scores: dict[str, float] = {}
use_fts = False
fts = _try_fts_candidates(g, query, filter, path_contains)
if fts is not None:
rows = fts["rows"]
bm25_scores = fts["scores"]
use_fts = True
else:
rows: list[dict] | None = None
q_fts = build_fts_query(query)
if q_fts:
fts = _try_fts_candidates(g, q_fts, filter, path_contains)
if fts is not None and fts["rows"]:
rows = fts["rows"]
bm25_scores = fts["scores"]
use_fts = True
if rows is None:
where, params = _lexical_where(filter, path_contains=path_contains)
# Always exclude structural Symbol nodes. Files and packages are :Symbol-labeled
# (kind='file'/'package') but aren't searchable code declarations — without this
Expand Down
26 changes: 26 additions & 0 deletions src/java_codebase_rag/search/search_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,32 @@ def _split_identifier(name: str) -> list[str]:
return [p for p in parts if p]


# Alphanumeric-word extractor for FTS query building (mirrors the index side, which
# runs the same regex over name/fqn/signature/annotations/capabilities/package fields).
_FTS_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")


def build_fts_query(text: str) -> str:
"""Tokenize a search query into the ``Symbol.search_text`` token space (fork A).

``search_text`` is indexed from ``_split_identifier`` tokens (camelCase / snake_case
split, lowercased). LadybugDB FTS's own tokenizer does NOT split camelCase, so a raw
pasted identifier like ``DistributionChunkService`` would match nothing — this
extracts alphanumeric words from the query and splits each via ``_split_identifier``
so the query lands in the index's token space. Tokens shorter than 2 chars are
dropped (mirrors the index side); duplicates collapse. Returns ``""`` for a query
with no usable tokens — the caller then falls back to the heuristic / role listing.
"""
out: list[str] = []
seen: set[str] = set()
for word in _FTS_WORD_RE.findall(text or ""):
for tok in _split_identifier(word):
if len(tok) >= 2 and tok not in seen:
seen.add(tok)
out.append(tok)
return " ".join(out)


def _symbol_bonus(r: dict, query_toks: set[str]) -> float:
"""Symbol-name overlap + action-verb bump for java chunks.

Expand Down
49 changes: 47 additions & 2 deletions tests/search/test_search_lexical.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,15 @@ def test_cap_truncation_advisory_fires_at_cap(monkeypatch, tmp_path: Path) -> No
assert any("repo cap" in a for a in adv), f"cap advisory should fire; got {adv}"


def test_cap_advisory_silent_below_cap(tmp_path: Path) -> None:
"""The cap advisory must NOT fire when the pool fits under the cap (no truncation)."""
def test_cap_advisory_silent_below_cap(monkeypatch, tmp_path: Path) -> None:
"""Heuristic-fallback path: the cap advisory must NOT fire when the pool fits under
the cap (no truncation). Forces the heuristic scan (the BM25 path has no cap) so the
invariant is actually exercised, not passed vacuously (fork A)."""
from java_codebase_rag.search import search_lexical

db = _build_corpus(tmp_path)
g = _graph(db)
monkeypatch.setattr(search_lexical, "_try_fts_candidates", lambda *a, **k: None)
adv: list[str] = []
run_lexical_search("distribution", limit=5, graph=g, advisories=adv)
assert adv == [], f"no advisory expected for a small pool; got {adv}"
Expand Down Expand Up @@ -390,3 +395,43 @@ def test_bm25_path_respects_role_filter(tmp_path: Path) -> None:
assert rows, "expected the controller hit through the role filter"
assert all(r["role"] == "CONTROLLER" for r in rows), [r["role"] for r in rows]
assert rows[0]["fqn"] == "ctrl.OperatorSessionController"


def test_symbol_search_text_populated_for_bm25(tmp_path: Path) -> None:
"""Fork A schema anchor: resolved Symbols carry a non-empty ``search_text`` whose
tokens include the camelCase-split name, so BM25 has something to index. Guards
against a regression that silently empties ``search_text`` (a broken
``_compute_symbol_search_text`` or a dropped COPY/SET field would make searches
silently return nil with zero test failures)."""
import ladybug
from java_codebase_rag.search.search_scoring import _split_identifier

db = _build_corpus(tmp_path)
conn = ladybug.Connection(ladybug.Database(str(db), read_only=True))
res = conn.execute(
"MATCH (s:Symbol) WHERE s.kind <> 'file' AND s.kind <> 'package' "
"RETURN s.name AS name, s.search_text AS st"
)
seen_class = False
while res.has_next():
name, st = res.get_next()
assert st, f"search_text empty for Symbol {name!r}"
if name and name[:1].isupper(): # class/type names (not <init>, methods)
toks = [t for t in _split_identifier(str(name)) if len(t) >= 2]
assert all(t in st.split() for t in toks), (
f"{name!r} tokens {toks} missing from search_text {st!r}"
)
seen_class = True
assert seen_class, "no class Symbols found in fixture"


def test_bm25_path_handles_pasted_camelcase_identifier(tmp_path: Path) -> None:
"""Fork A regression guard (review B1): a pasted camelCase identifier like
'DistributionChunkService' must match on the BM25 path — LadybugDB FTS does not split
camelCase, so the query is pre-split via build_fts_query before QUERY_FTS_INDEX.
Without the pre-split this returns []."""
db = _build_corpus(tmp_path)
rows = run_lexical_search("DistributionChunkService", limit=5, graph=_graph(db))
assert rows, "pasted camelCase identifier must match via the pre-split BM25 query"
assert rows[0]["fqn"] == "svc.DistributionChunkService"
assert "bm25" in rows[0]["_score_components"]
Loading