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
427 changes: 427 additions & 0 deletions app/search/references.py

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions app/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
)
from app.search.errors import QueryTooBroadError
from app.search.grep import FileCursor, grep_search
from app.search.references import EdgeSite, ReferenceResult, resolve_references
from app.search.semantic import _semantic_search_payload as semantic_search_payload # noqa: F401
from app.search.symbols import SymbolResult, symbol_search

Expand Down Expand Up @@ -908,3 +909,173 @@ def get_file_payload(
"found": found,
"commit": commit,
}


# ------------------------------------------------------------------- reference resolution


def _site_payload(site: EdgeSite, name_map: dict[int, str]) -> dict[str, Any]:
"""Shape one resolved :class:`~app.search.references.EdgeSite` for the wire.

``symbol_id`` is deliberately absent from each candidate dict (D1/D4): it is a query-time
ranking tiebreak, never persisted, never a caller-facing identifier.
"""
enclosing_symbol = (
{"name": site.enclosing_name, "kind": site.enclosing_kind}
if site.enclosing_name is not None
else None
)
return {
"repo": name_map.get(site.repo_id, str(site.repo_id)),
"file": site.path,
"line": site.line,
"edge_kind": site.edge_kind,
"target_name": site.target_name,
"enclosing_symbol": enclosing_symbol,
"resolution": site.resolution,
"candidate_count": site.candidate_count,
"candidates_truncated": site.candidates_truncated,
"candidates": [
{
"repo": name_map.get(candidate.repo_id, str(candidate.repo_id)),
"file": candidate.path,
"line": candidate.start_line,
"name": candidate.name,
"kind": candidate.kind,
"same_repo": candidate.same_repo,
"same_file": candidate.same_file,
"kind_match": candidate.kind_match,
}
for candidate in site.candidates
],
}


def _reference_result_to_payload(
result: ReferenceResult, name_map: dict[int, str]
) -> dict[str, Any]:
"""Shared envelope shape for :func:`find_references_payload` / :func:`list_imports_payload`:
``sites`` + ``site_count`` + a ``resolution_summary`` histogram + truncation flags."""
resolution_summary = {"unique": 0, "ambiguous": 0, "unresolved": 0}
for site in result.sites:
resolution_summary[site.resolution] += 1
return {
"sites": [_site_payload(site, name_map) for site in result.sites],
"site_count": len(result.sites),
"resolution_summary": resolution_summary,
"truncated": result.truncated,
"truncation_reason": result.truncation_reason,
}


def _reference_repo_name_map(conn: Any, result: ReferenceResult, cfg: Settings) -> dict[int, str]:
"""Resolve every repo id across a :class:`ReferenceResult`'s sites AND candidates to names.

Run in a SEPARATE ``conn.begin()`` + ``SET LOCAL statement_timeout`` AFTER
:func:`resolve_references` returns -- its own ``set_config`` committed with its
transaction, so this lookup would otherwise run uncapped (mirrors ``search_code_payload``'s
post-leg ``_repo_name_map`` call).
"""
repo_ids = {site.repo_id for site in result.sites}
repo_ids |= {candidate.repo_id for site in result.sites for candidate in site.candidates}
if not repo_ids:
return {}
with conn.begin():
conn.exec_driver_sql(f"SET LOCAL statement_timeout = {int(cfg.statement_timeout_ms)}")
return _repo_name_map(conn)


def find_references_payload(
engine: Engine, cfg: Settings, name: str, limit: int, branch: str | None = None
) -> dict[str, Any]:
"""Resolve ``name``'s call sites to ranked candidate-set definitions.

Corpus-wide (no ``repo`` scope) over ``edge_kind="call"`` edges. Ambiguity is never
collapsed: an ``"ambiguous"`` site's ``candidates`` list carries every ranked candidate up
to the per-name cap (AC1). ``limit`` is the caller's already-clamped row limit (mirrors
:func:`search_code_payload` -- clamping is the caller's responsibility, e.g. the future
MCP tool registration in #87).
"""
with engine.connect() as conn:
try:
result = resolve_references(
conn,
target_name=name,
edge_kind="call",
branch=branch,
row_limit=limit,
statement_timeout_ms=cfg.statement_timeout_ms,
)
except QueryTooBroadError:
return {
"query": name,
"kind": "references",
"symbol": name,
"branch": branch,
"sites": [],
"site_count": 0,
"resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0},
"truncated": True,
"truncation_reason": None,
"query_too_broad": True,
}
name_map = _reference_repo_name_map(conn, result, cfg)

return {
"query": name,
"kind": "references",
"symbol": name,
"branch": branch,
"query_too_broad": False,
**_reference_result_to_payload(result, name_map),
}


def list_imports_payload(
engine: Engine, cfg: Settings, repo: str, limit: int, branch: str | None = None
) -> dict[str, Any]:
"""Enumerate a repo's ``import`` edge sites (``repo`` is REQUIRED -- see D8: a corpus-wide
listing would filter on ``edge_kind`` alone, the trailing column of
``ix_reference_edges_repo_kind (repo_id, edge_kind)``, which is not index-served).

``repo_known=False`` is a structured "no such repo" miss (mirrors ``get_file_payload``'s
``found: False``) -- distinct from a known repo with zero import sites, which returns
``repo_known=True`` and an empty ``sites`` list. Import edges are largely EXTERNAL by
design (D3: exact dotted-path match only, no last-segment split), so most sites are
expected to resolve ``"unresolved"`` -- that is not itself an error.
"""
with engine.connect() as conn:
try:
result = resolve_references(
conn,
edge_kind="import",
repo=repo,
branch=branch,
row_limit=limit,
statement_timeout_ms=cfg.statement_timeout_ms,
)
except QueryTooBroadError:
return {
"query": repo,
"kind": "imports",
"repo": repo,
"branch": branch,
"repo_known": True,
"sites": [],
"site_count": 0,
"resolution_summary": {"unique": 0, "ambiguous": 0, "unresolved": 0},
"truncated": True,
"truncation_reason": None,
"query_too_broad": True,
}
name_map = _reference_repo_name_map(conn, result, cfg)

return {
"query": repo,
"kind": "imports",
"repo": repo,
"branch": branch,
"repo_known": result.repo_known,
"query_too_broad": False,
**_reference_result_to_payload(result, name_map),
}
104 changes: 98 additions & 6 deletions docs/runbooks/reference-edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ landed on a given deploy target.
`reference_edges` is a **raw, unresolved** call/import edge extracted from one file's
content-version: `(edge_kind, target_name, line, enclosing_*)` per site tree-sitter finds,
with `edge_kind IN ('call', 'import')` enforced by a CHECK constraint. It is part of the
knowledge-graph epic (#82): a later child (#86) resolves `target_name` to a concrete
`symbols` row at **query time**, by name-join — this table deliberately carries **no
foreign key to `symbols`**. Symbol ids churn on every per-file delete-and-reinsert, and an
FK would couple the two rewrite orders inside the indexing transaction for no query
knowledge-graph epic (#82): `target_name` is resolved to concrete `symbols` rows at
**query time**, by name-join — shipped in #86 (see §4) — this table deliberately carries
**no foreign key to `symbols`**. Symbol ids churn on every per-file delete-and-reinsert, and
an FK would couple the two rewrite orders inside the indexing transaction for no query
benefit.

The enclosing symbol (the function/class a call or import site sits inside, if any) is
Expand Down Expand Up @@ -67,10 +67,10 @@ bump behaved for `chunks`.

| Index | Serves |
|---|---|
| `ix_reference_edges_target_name` (btree) | The resolver's name-equality join (`symbols.name = reference_edges.target_name`) once #86 ships |
| `ix_reference_edges_target_name` (btree) | The resolver's name-equality join (`symbols.name = reference_edges.target_name`), shipped in #86 (§4) |
| `ix_reference_edges_target_trgm` (GIN, `gin_trgm_ops`) | Partial/substring reference lookups, parity with `ix_symbols_name_trgm` |
| `ix_reference_edges_file_id` (btree) | The per-file delete-and-reinsert writer (#84) and the `ON DELETE CASCADE` fired by the sweep/reconcile paths — Postgres does not auto-index a foreign key, and both are hot paths |
| `ix_reference_edges_repo_kind` (btree, `(repo_id, edge_kind)`) | Per-repo kind scans (e.g. a future `list_imports` MCP tool) |
| `ix_reference_edges_repo_kind` (btree, `(repo_id, edge_kind)`) | Per-repo kind scans, e.g. `list_imports_payload`'s required `repo` scope (§4, #86) |

## 3. Deploy coupling — this migration is NOT schema-only

Expand Down Expand Up @@ -116,6 +116,98 @@ SELECT has_table_privilege('<app-sp-client-id>', 'reference_edges', 'SELECT'),
Both must return `true`. If either is `false`, run the re-grant command above — it is
idempotent, safe to run against a target that's already current.

## 4. Query-time resolution (#86)

`app/search/references.py` resolves a raw `reference_edges` row's `target_name` to the
`symbols` rows it could plausibly mean, entirely at query time — nothing from this
resolution is ever written back to `reference_edges` or `symbols`, and no extraction-time
symbol FK was added. `resolve_references(conn, ...)` is the entry point; `app/service.py`
wraps it in two additive payload builders, `find_references_payload` (corpus-wide,
`edge_kind="call"`) and `list_imports_payload` (`edge_kind="import"`, `repo` required). MCP
tool registration for these is a later child (#87), not #86.

**Two-query design, deliberately not one joined query.** Query 1 selects matching edge
sites from `reference_edges`/`files`/`repos`; query 2 selects candidate `symbols` for the
distinct `target_name`s query 1 returned, from `symbols`/`files`/`repos`. Neither query
references the other's tables. A single `reference_edges JOIN symbols ON target_name =
name` (both reaching through `files`/`repos`) would let SQLAlchemy auto-correlate the two
`files`/`repos` legs against each other, silently mis-scoping which candidate belongs to
which site — the same auto-correlation hazard `app/search/symbols.py` avoids for `sym:`
lookups. Query 2 bounds its **fetch itself** (not just the returned payload) with a SQL
window function per `target_name` (`ROW_NUMBER() OVER (PARTITION BY symbols.name ORDER BY
...)`, capped at `DEFAULT_CANDIDATE_CAP = 32`), so a hot name (`get`, `run`, `__init__`)
never pulls its entire corpus-wide match set into memory. `COUNT(*) OVER` carries the TRUE
pre-cap count alongside the trimmed rows, so ambiguity is never rewritten to "unique" just
because the candidate list was capped.

**Candidate-set contract.** Each edge site resolves to zero, one, or many ranked
candidates — `resolution` is `"unresolved"` (0), `"unique"` (1), or `"ambiguous"` (2+),
derived from the true pre-cap `candidate_count`. Ranking (same-repo before cross-repo, then
kind-appropriate, then same-file, tiebreaking on `(repo_id, path, start_line, symbol_id)`
for a deterministic total order) runs in Python after query 2, since every signal is
relational to the `(site, candidate)` pair. Ranking is **membership-preserving**: a
lower-ranked candidate is sorted later, never dropped, so genuine ambiguity is always
represented in full (up to the cap) rather than silently collapsed to one answer.

**Import edges resolve to their full dotted path, no last-segment split.** `import`
`target_name` is the complete dotted path as written (see §1); `symbols.name` is bare, so
an import edge resolves to a candidate only in the rare case a symbol is literally named
that full dotted string. This is pinned deliberately, not a gap: (1) it keeps one
exact-equality, index-served predicate identical for both edge kinds, with no functional
index and no client-side splitting; (2) a dotted import genuinely points at an
external/stdlib module most of the time, so representing it as `"unresolved"` (= external)
is *correct*, not a miss; (3) a last-segment heuristic (`a.b.get` → every symbol named
`get`) would manufacture false ambiguity and defeat precision. `list_imports_payload`'s
value is *enumerating* import sites with their `target_name`, not resolving them to local
definitions.

**`repo` is required for `list_imports_payload`.** A corpus-wide import listing would
filter on `edge_kind` alone — the trailing column of `ix_reference_edges_repo_kind
(repo_id, edge_kind)`, not index-served on its own — so corpus-wide listing is out of
scope. `repo_known: False` is a structured "no such repo" miss (mirrors `get_file_payload`'s
`found: False`), distinguishable from a known repo with zero import sites
(`repo_known: True`, `sites: []`).

**Branch scoping matches `search_code`/`get_file` exactly**, applied independently to BOTH
the edge site's file and each candidate's file: an explicit `branch` uses
`files.branches @> ARRAY[:branch]`; omitted, it falls back to
`coalesce(repos.default_branch, 'HEAD') = ANY(files.branches)` — the same predicate
`get_file_payload` uses, asserted byte-identical in `tests/unit/test_references.py` and
exercised end-to-end in `tests/integration/test_references.py`.

**Quality measurement (`scripts/measure_reference_resolution.py`).** An offline script
reuses `app.search.references.build_candidate_count_select` and `classify_resolution` — the
SAME join semantics and branch predicate the live resolver's query 2 uses — so its
distribution agrees with the serve path by construction rather than re-implementing the
join. **`call` edges are the primary headline metric**, the only number compared against
the epic's deep-dive baseline (28.8% unique / 33.4% ambiguous / 37.8% external); the
`import`-edge distribution is reported separately, labeled informational (expected close to
0% resolution, validating the exact-dotted-match decision above). Run it with:

```
uv run python scripts/measure_reference_resolution.py --edge-kind both
```

Recorded distribution (self-indexed corpus: this repo's own git-tracked source tree —
206 files, 2,947 symbols, 15,412 reference edges across Python/JS/TS/TSX — default branch,
measured on 2026-07-23; see the #86 PR body for the full script output):

```
call edges -- HEADLINE AC4 metric (n=14226):
unique 4144 29.1% (baseline 28.8%)
ambiguous 4208 29.6% (baseline 33.4%)
unresolved 5874 41.3% (baseline 37.8%)

import edges -- informational, expected ~0% resolution (validates D3) (n=1186):
unique 15 1.3%
ambiguous 8 0.7%
unresolved 1163 98.1%
```

The re-measured `call`-edge distribution tracks the baseline closely (within ~4 points on
every bucket); `import` edges resolve at ~2% total, confirming they are overwhelmingly
external/stdlib targets as D3 predicts.

## Reference

- [multi-branch.md §3](multi-branch.md#3-deploy-coupling--this-migration-is-not-schema-only) —
Expand Down
Loading
Loading