From c5d11d153e7139626f4d204f59125b2071122efb Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 00:18:15 +0300 Subject: [PATCH 01/17] docs(propose): absence-diagnosis design spec Design spec for disambiguating empty exploration results: an agent can't tell "refine my query" from "this symbol genuinely isn't in this project," so it loops on impossible searches. Proposes a 3-layer absence-diagnosis engine (verdict + cause + per-cause help) backed by a precomputed, versioned vocabulary index derived from the graph's Symbol nodes. Applies to all five exploration tools on both MCP and CLI. Proposed at propose/ABSENCE-DIAGNOSIS-PROPOSE.md. Co-Authored-By: Claude --- propose/ABSENCE-DIAGNOSIS-PROPOSE.md | 612 +++++++++++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 propose/ABSENCE-DIAGNOSIS-PROPOSE.md diff --git a/propose/ABSENCE-DIAGNOSIS-PROPOSE.md b/propose/ABSENCE-DIAGNOSIS-PROPOSE.md new file mode 100644 index 00000000..752da406 --- /dev/null +++ b/propose/ABSENCE-DIAGNOSIS-PROPOSE.md @@ -0,0 +1,612 @@ +# Proposal: Absence Diagnosis — disambiguating empty exploration results + +Status: **active (design)**. Grounded against current source (2026-07-08); every +backend symbol/line range cited is verified against `master` (`168e31a`). This is +a **design spec** — it describes WHAT to build and WHY, with references to +classes, methods, fields, configs, DTOs, and contracts. It contains no +implementation logic; method bodies and algorithms belong to the implementing +plan (`plans/active/PLAN-ABSENCE-DIAGNOSIS.md`, produced next). + +Implements the design agreed in the brainstorming session; see "Decisions log". + +--- + +## TLDR + +Today an empty result from `search` / `find` / `neighbors` is a bare +`results=[]` with **no metadata** (`mcp_v2.py:944`, `:1036`, `:1639`). That +emptiness is ambiguous: it can mean *"your query is bad — refine"* **or** *"this +symbol genuinely is not part of this project — stop."* Agents routinely lock onto +the first interpretation when the second is true, and loop. + +This proposal adds an **absence-diagnosis engine**. Every exploration tool's +empty result carries: + +- a **verdict** — `refine_query` | `not_in_project` | `external_dependency` | + `correct_empty`; +- a **cause** — `identifier_miss` | `nl_miss` | `filter_miss` | `external` | + `meaningful_empty`; and +- **semantically-appropriate help** for that cause — did-you-mean candidates, + project vocabulary context, filter relaxation, or an external identity. + +It is backed by a **precomputed, versioned vocabulary index** derived from the +graph's `Symbol` nodes, so "does this name exist / what's closest / is it +external" is a bounded-time lookup, not a per-empty scan. + +--- + +## Problem + +An agent exploring a codebase via the MCP server or `jrag` CLI often searches for +a class or method that **does not exist in this project** — it was assumed, +misremembered, hallucinated, or carried over from another repo. The empty result +it gets back is indistinguishable from "bad query," so the agent keeps refining +instead of stopping. The two readings are conflated because the empty result +carries no distinguishing signal. + +### What exists today (the gap) + +- `search` / `find` / `neighbors` return `success=True, results=[]` with **no + per-empty metadata** — no `total_hits`, no suggestions, no "is this in the + project" field. Only `advisories` + `hints_structured`, and the relevant + existing hints all assume the symbol *might* exist and say "keep looking" + (`mcp_hints.py:541-565`, `:593-605`, `:294-366`). +- Only `resolve` has a first-class "nothing matched" status (`status="none"`, + `resolve_service.py:570`); only `describe` reports absence as a failure + (`success=False`, `mcp_v2.py:1096`). +- **No fuzzy / did-you-mean / edit-distance logic exists anywhere** in the + codebase. Only prefix/suffix/CONTAINS ladders in `resolve_service.py:170-244`, + and the word "fuzzy" is overloaded to mean graph-edge-resolution-strategy + (`FUZZY_STRATEGY_SET`, `java_ontology.py:106`) or "use `search`". +- The agent docs frame empties as "keep looking" (`docs/AGENT-GUIDE.md` recovery + playbook, `:263-268`) or "maybe not indexed" (proof-of-absence caveat, `:29`) — + nothing says "stop, this genuinely is not here." + +### What already exists (the building blocks) + +- The graph **is** the project's symbol vocabulary: `Symbol` nodes + (`_SYM_COLS`, `ladybug_queries.py:296-300`) with `name`, `fqn`, `kind`, + `module`, `microservice`, `role`, `resolved`. +- **Project-vs-external is already tracked**, just not exposed to the agent: + - `Symbol.resolved: bool` — `false` for **phantoms** (types referenced but + never parsed from source: JDK/Spring/library; `build_ast_graph.py:1189`, + `graph_enrich.py:1689`). + - `_EXTERNAL_PREFIXES` — hardcoded allowlist `java.`, `javax.`, `jakarta.`, + `org.springframework.`, `lombok.` (`ladybug_queries.py:240`), consulted only + internally for traversal (`:1226`, `:1353`, `:1528`) via `_is_external_fqn` + (`:267`). + - CALLS-edge `attrs.resolved` / HTTP-ASYNC `attrs.match` + (`mcp_v2.py:1157-1159`). +- **`ontology_version`** is a real version key: an `INT64` on the Kùzu + `GraphMeta` node (`build_ast_graph.py:2916`, set `:3704`, read `:3811`), with a + Python `ONTOLOGY_VERSION` constant (`java_index_flow_lancedb.py:464`) and + graph-side version guards (`ladybug_queries.py:377`). This is the natural + invalidation key for a derived index. +- All artifacts persist under **`JAVA_CODEBASE_RAG_INDEX_DIR`** (default + `./.java-codebase-rag`): Lance tables + Kùzu + cocoindex state + (`java_index_flow_lancedb.py:7`, `:311-317`). A sidecar index has an obvious + home. +- **The CLI already prototypes the disambiguation pattern** — `Envelope.status` + incl. `"not_found"` (`jrag_envelope.py:39`, render `jrag_render.py:227`), + `is_external_entrypoint` ("this zero is correct"; `jrag_envelope.py:116`, set + `jrag.py:2393`), and `_zero_result_guidance` (relax each filter dimension, + report where matches live; `jrag.py:4139`). The MCP layer never got this + treatment; this proposal unifies both under one vocabulary. + +--- + +## Goals + +1. Every exploration-tool empty result carries an unambiguous verdict + cause + + cause-appropriate help. +2. Distinguish **absent** (name nowhere in the project) from **external** + (referenced-but-undefined) from **refine** (near-match / reformulation + needed). +3. Did-you-mean that lets an agent **pivot in one shot** (no second guessing + loop), with a **conservative** absence threshold (false-absent is the + catastrophic failure mode). +4. A precomputed, versioned vocabulary index so the above is bounded-time and + scales to enterprise codebases. +5. One absence vocabulary across **both** MCP and CLI, and across **all five** + exploration tools. + +## Non-goals + +- Changing `search` ranking or the semantic retrieval path. +- Adding new exploration tools beyond surfacing absence on the existing five. +- Detecting absence for non-Java tables (`sql`, `yaml`) in this revision — + scoped to the Java symbol vocabulary. (Extensible later.) +- Refusing queries or otherwise coercing agent behavior — the tool **informs**; + the agent decides. + +--- + +## The absence vocabulary (contract) + +Every empty result carries one `AbsenceDiagnosis`. Two classifiers plus a set of +optional help payloads; only the payload matching the cause is populated. + +### Verdict — what the agent should do + +``` +AbsenceVerdict = Literal[ + "refine_query", # empty, but help is offered — keep looking (with help) + "not_in_project", # identifier-shaped, no near-match within threshold — stop + "external_dependency", # target is referenced-but-undefined (JDK/Spring/library/phantom) + "correct_empty", # the zero is meaningful and correct (e.g. external entrypoint) +] +``` + +### Cause — why; selects the help payload + +``` +AbsenceCause = Literal[ + "identifier_miss", # identifier-shaped query, no hit + "nl_miss", # free-text / semantic query, no hit + "filter_miss", # structured find filter, no hit + "external", # target is external / phantom + "meaningful_empty", # a traversal root whose zero is correct (generalizes is_external_entrypoint) +] +``` + +### The DTO (contract only — no logic) + +``` +AbsenceDiagnosis: + verdict: AbsenceVerdict + cause: AbsenceCause + message: str # one agent-readable line, always present + + # populated only for identifier_miss / not_in_project: + closest_symbols: list[NodeRef] = [] # reuse NodeRef (graph_types.py:28) + distances: list[float] = [] # parallel; 0..1, lower = closer + proof: AbsenceProof | None = None # backs a hard not_in_project call + + # populated only for external: + external_identity: ExternalIdentity | None = None + + # populated only for nl_miss: + vocabulary_context: VocabularyContext | None = None + + # populated only for filter_miss: + filter_relaxation: FilterRelaxation | None = None +``` + +``` +AbsenceProof: + nearest_distance: float + symbol_count_scanned: int + thresholds_applied: { close: float, absent_floor: float } + query_shape: "identifier" # why did-you-mean ran + +ExternalIdentity: + fqn: str + reason: Literal["prefix", "phantom", "unresolved-call"] + source: str | None # e.g. "org.springframework" / phantom node id + +VocabularyContext: + top_modules: list[(module, count)] + top_microservices: list[(microservice, count)] + roles_present: list[(role, count)] + frequent_name_tokens: list[str] # to help reformulate in the codebase's own words + +FilterRelaxation: + per_dimension: list[{ + dimension: str, # e.g. "role", "module", "microservice" + constrained_value: str | None, + matches_under_relaxation: int, + suggested_value: str | None + }] +``` + +`proof` is what makes a conservative `not_in_project` verdict **auditable and +trustworthy** rather than a bare assertion — the thing that lets an agent commit +to "stop" instead of second-guessing and looping. + +--- + +## Architecture — three layers + +``` +┌─ Layer 3: TOOL INTEGRATION ──────────────────────────────────────┐ +│ MCP: search · find · neighbors · describe · resolve │ +│ CLI: Envelope (generalizes status / is_external_entrypoint) │ +│ │ on empty path: absence = diagnose(...) │ +└────────┼─────────────────────────────────────────────────────────┘ + ▼ +┌─ Layer 2: ABSENCE DIAGNOSIS module (new, stateless) ─────────────┐ +│ diagnose(tool, query|filter, root_node, scope, index, graph) │ +│ -> AbsenceDiagnosis │ +│ classifies CAUSE -> emits semantically-correct help per cause │ +└────────┼─────────────────────────────────────────────────────────┘ + ▼ reads +┌─ Layer 1: VOCABULARY INDEX asset (precomputed, persisted) ───────┐ +│ derived from Symbol nodes: names + fqns + resolved flag + │ +│ external classification. Versioned by ontology_version. │ +│ Built at end of graph build; lazily rebuilt if missing/stale. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +The graph's `Symbol` nodes **are** the vocabulary. Layer 1 is a derived, +search-optimized projection — not new source-of-truth data. + +--- + +## Layer 1 — Vocabulary index asset + +### What it stores (two parts) + +**Part A — Symbol manifest.** One record per `Symbol` node (types *and* +members; `kind` is carried so candidates render correctly via `NodeRef`): + +``` +SymbolRecord: + node_id, fqn, simple_name, normalized_name, + kind, module, microservice, role, resolved +``` + +`normalized_name` = lowercased, generics/signature stripped — the key did-you-mean +matches against. Feeds candidate construction and `vocabulary_context` +aggregation (extends `module_counts`/`microservice_counts`, +`ladybug_queries.py:987`). + +**Part B — Name-lookup structure (n-gram inverted index).** Maps each q-gram +(q=3) of every `normalized_name` to the record ids containing it. A query name's +grams union to a small candidate set, re-ranked by similarity. Gives +**bounded-time, typo-tolerant** lookup (catches mid-word typos like +`PaymnetClient`, which prefix-only matching misses) without scanning the whole +vocabulary. + +**External detection needs no separate structure:** a phantom *is* a `Symbol` +node with `resolved=false`, so it is already in the manifest. "Is X external?" = +manifest lookup by name, then check `resolved` + `_is_external_fqn` +(`ladybug_queries.py:267`). O(1)-ish. + +### Format & storage + +Sidecar file in `JAVA_CODEBASE_RAG_INDEX_DIR`, serialized **msgpack** with a +header: + +``` +header: { magic, format_version, ontology_version, built_at, symbol_count } +body: SymbolRecord[] + dict[gram, [record_idx...]] +``` + +Why sidecar msgpack over a Kùzu table or Lance table: fuzzy n-gram lookup is not +a native Kùzu/Lance query shape — it is loaded into an in-memory structure +regardless, so a decoupled binary loads fastest and avoids entangling graph/vector +internals. (SQLite is the inspectable alternative; msgpack is the default for +load speed.) + +### Build timing + +At the **end of the graph build**, immediately after `GraphMeta` is written +(`build_ast_graph.py:3704`). At that point all `Symbol` nodes exist; the step +enumerates them and emits the manifest + n-gram index. Pure derivation — no +re-parsing. Also exposed as a **standalone CLI subcommand** so the index can be +(re)built without a full reprocess (for backfill / repair). + +### Versioning + +The file header stamps the `ontology_version` it was built against. On load, +compare to the graph's live `GraphMeta.ontology_version` +(`build_ast_graph.py:3811`). **Mismatch ⇒ stale ⇒ discard and rebuild.** Reuses +the existing version-guard pattern (`ladybug_queries.py:377`). Reprocess refreshes +it automatically (same flow that writes `GraphMeta`). + +### Loading & lazy backfill (graceful degradation) + +- **MCP server:** load into a singleton at startup, held alongside the + `LadybugGraph` instance. +- **CLI:** load per invocation through the same loader. +- **Missing or stale sidecar** (e.g. an index built before this feature ships): + the diagnosis layer **lazily rebuilds in-process** from the graph (enumerate + `Symbol` nodes via the existing `MATCH (s:Symbol)` path / + `find_by_name_or_fqn`, `ladybug_queries.py:997`) on first use, caches in + memory, and writes the sidecar for next time. **Old indexes work immediately**; + the first empty pays a one-time build, every load after is a file read. No + forced reprocess. + +### Scalability + +50k-symbol enterprise repo: manifest ~10 MB, n-gram index ~500k entries, msgpack +file ~5–15 MB, loads in well under a second. Per-empty fuzzy lookup ≈ a few +hundred candidate comparisons → low-single-digit ms. + +--- + +## Layer 2 — Absence diagnosis module + +A stateless module, one entry point. + +### Interface (contract) + +``` +diagnose( + tool: Literal["search","find","neighbors","describe","resolve"], + query: str | None, # search / resolve (NL or identifier) + filt: NodeFilter | None, # find (structured filter) + root_node: NodeRef | None, # neighbors / describe (resolved subject) + scope: { microservice, module }, + vocab: VocabularyIndex, # Layer 1 + graph: LadybugGraph, # for filter-relaxation probes +) -> AbsenceDiagnosis +``` + +### Cause classification — decision procedure + +**Precedence: `external_dependency` wins.** If the target is external, that is +the most decisive signal — emit it first and stop. + +1. **`root_node` present** (neighbors empty — the subject *exists*, it has an + id) → never "symbol absent." Sub-cases distinguished by the existing + neighbors-empty hint logic (`mcp_hints.py:294-366`): + - Subject is external (`resolved=false` or external FQN) → cause `external`. + - Zero reflects the subject's genuine nature — a leaf with no callees, or an + external HTTP entrypoint with no in-repo callers → verdict `correct_empty`, + cause `meaningful_empty` (generalizes `is_external_entrypoint`, + `jrag_envelope.py:116`). + - Requested edge type/direction is inapplicable to the subject's kind → + verdict `refine_query`, help = the existing neighbors edge-type hints. +2. **`filt` present** (find empty): + - Identifier-shaped filter (`_find_has_identifier_shaped_filter`, + `mcp_hints.py:280`) with a near-match that is *filtered out* by other + dimensions → cause `filter_miss`. + - Identifier-shaped with **no** near-match → cause `identifier_miss`. + - Broad / non-identifier filter (e.g. `role=REPOSITORY`) → cause `filter_miss`. + - *(Distinguishing the two identifier cases: run did-you-mean on the + identifier value; a close hit excluded by another dimension ⇒ filter_miss + — relaxation reveals it; nothing close ⇒ identifier_miss.)* +3. **`query` present** (search / resolve / `describe` not-found): + - `describe` not-found by `fqn` is an `identifier_miss` (did-you-mean on the + fqn); not-found by `node_id` yields a minimal `refine_query` with no + did-you-mean (an unknown id, not a misspelled name). + - Classify query shape via the identifier-shape heuristic (CamelCase token / + dotted FQN / `Cls#method`, no stopwords) vs free-text NL. + - Identifier-shaped → cause `identifier_miss` (did-you-mean). + - NL → cause `nl_miss` (vocabulary context — string did-you-mean is + meaningless for NL). + +### Did-you-mean ranking + the conservative threshold policy + +Candidate generation (Layer 1 n-gram lookup) → rank by normalized string +similarity (metric family: Jaro-Winkler / normalized edit-distance, ∈ [0,1]; +exact metric finalized in the plan) → keep top **N** (default 5). +`distance = 1 − similarity`, parallel to `closest_symbols`. + +**Two-band threshold, config-tunable** (see Configuration): + +| Best similarity | Verdict | Rationale | +|---|---|---| +| ≥ `close` (≈0.85, illustrative) | `refine_query` | likely typo/misremember — show the near-match | +| `< absent_floor` (≈0.4, illustrative) **and** identifier-shaped | `not_in_project` | confidently absent — stop | +| middle band | `refine_query` | **conservative default** — never commit to absent when uncertain | + +**Always** return the nearest N candidates + distances, regardless of verdict — +that is both the one-shot pivot (kills the loop) and the `proof`. This realizes +the locked asymmetry: false-absent is catastrophic, false-refine is one cheap +extra query. + +### Per-cause help payloads + +| Cause | Payload | Source | +|---|---|---| +| `identifier_miss` | `closest_symbols` + `distances` + `proof` | Layer 1 ranking | +| `nl_miss` | `vocabulary_context` | manifest aggregation (extends `module_counts`/`microservice_counts`, `ladybug_queries.py:987`) | +| `filter_miss` | `filter_relaxation` | ports `_zero_result_guidance` (`jrag.py:4139`) into a structured payload, now MCP-available | +| `external` | `external_identity` | `_is_external_fqn` (`:267`) + `resolved=false` + CALLS-edge `resolved` attr | +| `meaningful_empty` | `message` (+ existing entrypoint context) | generalizes `is_external_entrypoint` (`jrag_envelope.py:116`) | + +### Relationship to existing hints + +The diagnosis **subsumes** the relevant `mcp_hints.py` branches (resolve-none +`:541`, find-identifier `:593`, neighbors-empty `:294`, search-weak `:575`) — +those become special cases of the five causes. The `hints_structured` / +`StructuredHint` machinery remains the **transport** for "next-action" +suggestions (e.g. "call `resolve(...)`"); `AbsenceDiagnosis` is the **diagnosis** +carried alongside. They compose, not conflict. + +--- + +## Layer 3 — Tool integration + +### The shared field (additive, non-breaking) + +Every output model gains one optional field: + +``` +absence: AbsenceDiagnosis | None = None +``` + +`None` on success-with-results; populated only on the empty / not-found path. +Added to `SearchOutput`, `FindOutput`, `NeighborsOutput`, `DescribeOutput` +(`mcp_v2.py:509`, `:525`, `:546`, `:554`), `ResolveOutput` +(`resolve_service.py:121`), and the CLI `Envelope` (`jrag_envelope.py:86`). MCP +JSON stays backward-compatible (clients ignore unknown optional fields). + +### Per-tool wiring (one thin call site each) + +| Tool | Empty path | `diagnose(...)` inputs | +|---|---|---| +| `search` | `mcp_v2.py:944` | `tool="search", query=…` | +| `find` | `mcp_v2.py:1036` | `tool="find", filt=…` | +| `neighbors` | `mcp_v2.py:1639` | `tool="neighbors", root_node=…` | +| `describe` | `mcp_v2.py:1096` (today `success=False`) | `tool="describe", query=fqn` → now actionable did-you-mean | +| `resolve` | `resolve_service.py:570` (`status="none"`) | `tool="resolve", query=…` → rich absence replaces the bare "try search" hint | + +Each empty branch: detect empty → `absence = diagnose(...)` → attach. The CLI +renderer maps `absence` to human text (extends `_render_not_found`, +`jrag_render.py:227`). + +### CLI alignment + +The CLI `Envelope` already has `status`, `is_external_entrypoint`, and +`_zero_result_guidance`. The `absence` field generalizes them: `status="not_found"` +maps onto the absence verdicts; `is_external_entrypoint` becomes the +`correct_empty` / `meaningful_empty` case; `_zero_result_guidance` becomes +`filter_relaxation`. The renderer keeps producing equivalent human text so +existing CLI consumers see no regression. + +--- + +## External detection + +Driven by three signals (checked in precedence order): + +1. **Prefix** — `_EXTERNAL_PREFIXES` (`ladybug_queries.py:240`) → `reason="prefix"`. +2. **Phantom** — `Symbol.resolved=false` node with a matching name → + `reason="phantom"`. +3. **Unresolved call target** — a CALLS edge with `attrs.resolved=false` + (`mcp_v2.py:1157`) whose callee matches → `reason="unresolved-call"`. + +Note `resolve` today does **not** filter on `s.resolved` +(`_resolve_symbol_candidates`, `resolve_service.py:170-244`), so it can already +return a phantom as a candidate — this proposal makes that explicit and labeled +rather than incidental. + +--- + +## Configuration + +New tunables exposed via the existing config surface (`docs/CONFIGURATION.md`: +env vars / project YAML): + +| Knob | Default (illustrative) | Purpose | +|---|---|---| +| `absence_close_threshold` | 0.85 | similarity ≥ this ⇒ `refine_query` (near-match) | +| `absence_absent_floor` | 0.40 | similarity < this (identifier-shaped) ⇒ `not_in_project` | +| `absence_candidate_count` (N) | 5 | nearest symbols returned | +| `absence_ngram_q` | 3 | n-gram width | +| `absence_diag_enabled` | true | master toggle (degrades to today's behavior if false) | + +Exact defaults are finalized in the plan and validated by the did-you-mean quality +tests (incl. the false-absent guard). + +--- + +## Error handling / degradation + +**The diagnosis layer must never turn an empty result into an error.** Empties +stay `success=True` (`describe` keeps its current `success=False` semantics, now +with actionable `absence`). + +- **Missing / stale vocab index** → lazy rebuild (Layer 1). +- **Rebuild fails / graph unreadable** → degrade to `refine_query` + a `message` + that the diagnosis index is unavailable; fall back to existing hints; return + `absence=None` rather than failing. +- **Empty / unindexed project** (no `Symbol` nodes) → `refine_query` with + message "index appears empty/unindexed — verify the project was indexed" + (mirrors the `AGENT-GUIDE.md` proof-of-absence caveat, `:29`). Never a false + `not_in_project`. +- **Diagnosis exception** → catch, log, emit minimal/none. The tool still returns + its normal empty payload. +- **Rebuild-too-slow guardrail** (huge repo, cold cache) → cap rebuild cost; if + exceeded, fall back to `refine_query` rather than blocking the query. + +--- + +## Testing + +- **`diagnose` unit matrix** — parameterized over (cause × verdict): + identifier_miss-absent, identifier_miss-refine (close typo), nl_miss, + filter_miss (relaxation correct), external (prefix / phantom / unresolved-call), + meaningful_empty. Small fixture graph. +- **Index build / load** — manifest + n-gram contents from a fixture graph; + msgpack round-trip; version header written/read; stale-version ⇒ rebuild. +- **Did-you-mean quality + false-absent guard** — known typo ⇒ expected closest; + near-match ⇒ `refine` (not absent); nothing-close ⇒ `not_in_project`; middle + band ⇒ `refine`; and explicitly: a symbol that *exists* under an unusual name + must never yield `not_in_project`. +- **Per-tool integration** — each of the five empty paths attaches the right + `absence`. +- **CLI envelope / JSON** — `absence` serialized; renderer text per verdict; + `is_external_entrypoint` still renders under `correct_empty`. +- **Backfill** — missing sidecar ⇒ first empty rebuilds + writes file ⇒ second + load reads file. +- **Regression** — the subsumed `mcp_hints` branches still produce equivalent + guidance. +- **Performance guard** (optional) — 50k-symbol fixture ⇒ empty-path diagnosis + under a latency budget; sidecar load < 1s. + +All tests follow `AGENTS.md`: `.venv/bin/python`, editable install, erase stale +`tests/*/.java-codebase-rag` indexes, run the full suite once at task end. + +--- + +## Migration / rollout + +- **Index acquisition:** lazy backfill means existing indexes gain the feature on + first empty (one-time in-process build, then persisted). No forced reprocess; + a reprocess refreshes it. +- **`ontology_version`:** bumping is **not required** for the data model (the + vocab index is derived, versioned by the *current* `ontology_version`, not a + new one). The plan confirms whether the sidecar's `format_version` warrants a + distinct bump. +- **Backward compat:** the `absence` field is optional; MCP clients that ignore + unknown fields are unaffected. CLI text output is preserved (renderer maps the + new vocabulary to existing phrasing). +- **Docs:** update `docs/AGENT-GUIDE.md` (the recovery playbook + proof-of-absence + caveat) to document the verdicts and how to read `absence`; note the new config + knobs in `docs/CONFIGURATION.md`. + +--- + +## Decisions log + +1. **Distinguish absent / external / refine** (not collapse them) — the right + next action differs for each. *(brainstorming Q1)* +2. **Richness = per-cause help** (not "verdict only" or "always did-you-mean"): + string did-you-mean is only meaningful for identifier-shaped queries; NL + empties get vocabulary context; filter empties get relaxation. Correctness + over cost. *(Q2)* +3. **Conservative `not_in_project` threshold, but always return candidates + proof** + — false-absent is catastrophic, false-refine is one cheap extra query. + Candidates kill the loop via pivot, so conservative does not mean weak. + *(Q3)* +4. **All five tools, both surfaces, one vocabulary** — the most-correct scope; the + CLI already prototypes it, the MCP layer is the gap. *(Q4)* +5. **Precomputed vocabulary index (approach C)** — only option that is both + most-correct and most-efficient at scale; fits the existing + enrichment/`ontology_version` grain. +6. **`correct_empty` verdict added** (generalizes `is_external_entrypoint`) — a + meaningful zero is distinct from a failure; prevents misreading. *(checkpoint 4)* +7. **External-wins precedence** — if the target is external, say so first. +8. **Sidecar msgpack** for the index (load speed); SQLite noted as the + inspectable alternative. +9. **`n`-gram (q=3) lookup** for typo-tolerant, bounded-time fuzzy match. + +--- + +## Grounding references (verified against `master` `168e31a`, 2026-07-08) + +- **MCP tools & registration:** `server.py:590-842` (`search`, `find`, + `describe`, `neighbors`, `resolve`). +- **Output models:** `SearchOutput` `mcp_v2.py:509`, `FindOutput` `:525`, + `DescribeOutput` `:546`, `NeighborsOutput` `:554`, `ResolveOutput` + `resolve_service.py:121`; envelope fields `success/message/advisories/ + hints_structured`; `StructuredHint` `graph_types.py:39`, `NodeRef` `:28`. +- **Empty paths:** `search` `mcp_v2.py:944`, `find` `:1036`, `describe` `:1096`, + `neighbors` `:1639`, `resolve` none `resolve_service.py:570`. +- **Resolve machinery:** `_resolve_symbol_candidates` `resolve_service.py:170-244` + (no `s.resolved` filter), `_resolve_rank_candidates` `:504`. +- **Symbol universe / enumeration:** `_SYM_COLS` `ladybug_queries.py:296`, + `find_v2` MATCH `(s:Symbol)` `mcp_v2.py:999`, `module_counts`/`microservice_counts` + `ladybug_queries.py:987`, `find_by_name_or_fqn` `:997`. +- **External / phantom:** `Symbol.resolved` `ladybug_queries.py:127`, + phantom `build_ast_graph.py:1189` + `graph_enrich.py:1689`, + `_EXTERNAL_PREFIXES` `ladybug_queries.py:240`, `_is_external_fqn` `:267` + (used `:1226`, `:1353`, `:1528`), CALLS `attrs.resolved` `mcp_v2.py:1157`, + HTTP/ASYNC `attrs.match` `:1158`. +- **Versioning & persistence:** `ontology_version` schema `build_ast_graph.py:2916`, + set `:3704`, read `:3811`, guard `ladybug_queries.py:377`, constant + `ONTOLOGY_VERSION` `java_index_flow_lancedb.py:464`; `JAVA_CODEBASE_RAG_INDEX_DIR` + `java_index_flow_lancedb.py:7`, `:311`; enrichment entrypoint + `enrich_chunk` `java_index_flow_lancedb.py:385`, `:433`. +- **Existing hints (subsumed):** `mcp_hints.py:541` (resolve-none), `:593` / + `:280` (find identifier-shape), `:294` (neighbors-empty), `:575` (search-weak). +- **CLI prototypes:** `Envelope` `jrag_envelope.py:86`, `EnvelopeStatus` `:39`, + `is_external_entrypoint` `:116` (set `jrag.py:2393`), `_zero_result_guidance` + `jrag.py:4139`, `_render_not_found` `jrag_render.py:227`. +- **Docs:** `docs/AGENT-GUIDE.md:29` (proof-of-absence), `:17` (ontology caveat), + `:263-268` (recovery playbook), `:158` (resolve status table); `docs/CONFIGURATION.md` + (hints/advisories toggle `:162`). From ac54926ba3dfda019e3d69befd06a5dd5fde685a Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 00:30:21 +0300 Subject: [PATCH 02/17] docs(plan): absence-diagnosis implementation plan PR-by-PR plan (PR-ABS-0..5) for the absence-diagnosis feature, grounded against source. Decomposes into shared types/config, the vocabulary index asset, the diagnosis module, MCP integration, CLI alignment, and docs. Corrects the spec's msgpack choice to stdlib JSON (msgpack is not a dependency; format is encapsulated behind absence_vocab load/save). Co-Authored-By: Claude --- plans/active/PLAN-ABSENCE-DIAGNOSIS.md | 854 +++++++++++++++++++++++++ 1 file changed, 854 insertions(+) create mode 100644 plans/active/PLAN-ABSENCE-DIAGNOSIS.md diff --git a/plans/active/PLAN-ABSENCE-DIAGNOSIS.md b/plans/active/PLAN-ABSENCE-DIAGNOSIS.md new file mode 100644 index 00000000..57bd2dce --- /dev/null +++ b/plans/active/PLAN-ABSENCE-DIAGNOSIS.md @@ -0,0 +1,854 @@ +# Absence Diagnosis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan PR-by-PR. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every exploration-tool empty result an unambiguous verdict +(absent / external / refine / correct-empty) plus cause-specific help, so agents +stop looping on searches for symbols that aren't in the project. + +**Architecture:** A 3-layer engine — (1) a precomputed, `ontology_version`-stamped +vocabulary index derived from `Symbol` nodes; (2) a stateless `diagnose(...)` +module that classifies an empty result and emits per-cause help; (3) tool +integration that attaches an `AbsenceDiagnosis` to all five MCP outputs and the +CLI `Envelope`. + +**Tech Stack:** Python 3.11, pydantic v2, Kùzu (`LadybugGraph`), stdlib `json`, +pytest. No new third-party dependencies. + +## Global Constraints + +(From the spec `propose/ABSENCE-DIAGNOSIS-PROPOSE.md`, verbatim values.) +- Python `>=3.11`; pydantic `>=2.0,<3`; no new dependencies (msgpack is **not** + added — see Resolved decisions). +- Editable install only — `.venv/bin/python`; `tests/conftest.py` enforces it. +- New top-level modules must be added to `[tool.setuptools] py-modules` in + `pyproject.toml` so they ship in the built wheel. +- Every empty result stays `success=True` except `describe` (keeps + `success=False`); the diagnosis layer never turns an empty into an error. +- The `absence` field is additive/optional on all output models — MCP JSON stays + backward-compatible. +- Tests follow `AGENTS.md`: erase stale `tests/*/.java-codebase-rag` first; run + the relevant subset during dev, the full suite once at task end. + +--- + +Status: **active (planning)**. Intended home when approved: +`plans/active/PLAN-ABSENCE-DIAGNOSIS.md`. Implements the approved spec +`propose/ABSENCE-DIAGNOSIS-PROPOSE.md` (commit `7a2a56c`). + +> **Grounded against current source (2026-07-08) by direct read of `mcp_v2.py`, +> `resolve_service.py`, `graph_types.py`, `mcp_hints.py`, `ladybug_queries.py`, +> `build_ast_graph.py`, `jrag.py`, `jrag_envelope.py`, `jrag_render.py`, +> `config.py`, `pyproject.toml`, `tests/conftest.py`, `tests/test_mcp_v2.py`, +> `tests/test_resolve_service.py`, `tests/test_mcp_hints.py`, +> `plans/active/PLAN-SEARCH.md`.** Every edit site below is cited `file:line`. + +Depends on: nothing external. PR-ABS-0 is the foundation; 1 and 2 build on 0; 3 +and 4 build on 2 (and can land in parallel after 2); 5 is docs and lands last. + +## Why (context) + +Empty results from `search`/`find`/`neighbors` are bare `results=[]` with no +metadata (`mcp_v2.py:944`, `:1036`, `:1639`), so an agent can't tell "refine my +query" from "this symbol genuinely isn't in this project." It loops. The graph +already knows the answer (it holds every `Symbol`, and `Symbol.resolved=false` +marks phantoms/external types). This plan surfaces that knowledge as a +first-class `AbsenceDiagnosis` on every empty result. + +## Principles (do not relitigate in review) + +1. **Conservative absence.** False-absent (telling an agent to abandon a real + target) is catastrophic; false-refine is one cheap extra query. Default to + `refine_query` in the middle band; commit to `not_in_project` only when the + nearest symbol is far AND the query is identifier-shaped. +2. **Always return candidates + proof.** Regardless of verdict, return the + nearest N symbols + distances — the one-shot pivot that kills the loop, and + the auditable proof behind a hard `not_in_project`. +3. **Per-cause help, not one-size.** String did-you-mean only for + identifier-shaped queries (meaningful only there); NL empties get vocabulary + context; filter empties get relaxation; external targets get an identity. +4. **External wins.** If the target is external/phantom, say so first. +5. **Diagnosis never fails the tool.** Best-effort enrichment; degrade to + `refine_query`/`None` on any internal error. + +## Architecture (where the code lives) + +- **New module `absence_types.py`** — the `AbsenceDiagnosis` DTO + sub-DTOs + + `AbsenceVerdict`/`AbsenceCause` literals. Imported by `mcp_v2.py`, + `resolve_service.py`, `absence_diagnosis.py`, `jrag_envelope.py`. +- **New module `absence_vocab.py`** — `VocabularyIndex`: build from graph, load, + save, lookup, external check. Built at end of graph build in + `build_ast_graph.write_ladybug` (`:4168`); persisted as a sidecar JSON under + `JAVA_CODEBASE_RAG_INDEX_DIR`; lazily rebuilt if missing/stale. +- **New module `absence_diagnosis.py`** — `diagnose(...)`: the stateless + classifier + per-cause help assembler. Reuses `_find_has_identifier_shaped_filter` + (`mcp_hints.py:280`), `_is_external_fqn` (`ladybug_queries.py:267`), + `module_counts`/`microservice_counts` (`ladybug_queries.py:987`). +- **Modified `mcp_v2.py`** — add `absence` field to `SearchOutput`/`FindOutput`/ + `DescribeOutput`/`NeighborsOutput` (`:509/:525/:546/:554`); call `diagnose(...)` + in the 4 empty paths (`:953`, `:1052`, `:1096`, `:1638`). +- **Modified `resolve_service.py`** — add `absence` to `ResolveOutput` (`:121`, + `extra="forbid"` — declared field is fine); call `diagnose(...)` in the + `status="none"` branch (`:570`). +- **Modified `build_ast_graph.py`** — build + persist the vocab index at the end + of `write_ladybug` (after `_write_meta`, `:4208`). +- **Modified `config.py`** — 5 absence knobs (env + YAML) on + `ResolvedOperatorConfig` (`:353`); add a `_pick_float` helper. +- **Modified `java_codebase_rag/jrag_envelope.py` + `jrag_render.py`** — add + `absence` to `Envelope` (`:86`) + serialization; render verdicts to text; + generalize `is_external_entrypoint` under `correct_empty`. +- **Modified `pyproject.toml`** — add the 3 new modules to `py-modules`. +- **Docs** — `docs/AGENT-GUIDE.md`, `docs/CONFIGURATION.md`. + +## PR breakdown — overview + +| PR | Scope | Ontology bump | Key files | Independent of | +|---|---|---|---|---| +| PR-ABS-0 | Shared types + `absence` field on 5 models + config knobs | none | `absence_types.py` (new), `mcp_v2.py`, `resolve_service.py`, `config.py`, `pyproject.toml` | — (foundation) | +| PR-ABS-1 | Vocabulary index asset (build/load/save/lookup/backfill) | none | `absence_vocab.py` (new), `build_ast_graph.py`, `jrag.py` (subcmd) | PR-ABS-0 | +| PR-ABS-2 | `diagnose(...)` module — classifier + per-cause help | none | `absence_diagnosis.py` (new) | PR-ABS-0, PR-ABS-1 | +| PR-ABS-3 | MCP tool integration (5 empty paths) | none | `mcp_v2.py`, `resolve_service.py` | PR-ABS-2 | +| PR-ABS-4 | CLI envelope alignment + renderer | none | `jrag_envelope.py`, `jrag_render.py` | PR-ABS-2 | +| PR-ABS-5 | Docs (AGENT-GUIDE recovery playbook + CONFIGURATION knobs) | none | `docs/AGENT-GUIDE.md`, `docs/CONFIGURATION.md` | PR-ABS-3, PR-ABS-4 | + +Landing order: 0 → 1 → 2 → (3 ‖ 4) → 5. + +## Resolved design decisions + +| Topic | Decision | +|---|---| +| Serialization format | **Sidecar JSON (stdlib), not msgpack.** Grounding: msgpack is not a dependency and the codebase uses `json` throughout. `absence_vocab` owns load/save so the format is swappable to msgpack later if a perf test demands it. *(Spec said msgpack; corrected after grounding.)* | +| Index storage | Sidecar file `vocab_index.json` under `JAVA_CODEBASE_RAG_INDEX_DIR`, header-stamped with `ontology_version`. Not a GraphMeta property (the n-gram index can be multi-MB for large repos; GraphMeta is read wholesale on every `meta()` call). | +| Did-you-mean metric | A normalized string similarity ∈ [0,1] (Jaro-Winkler family); exact variant chosen in PR-ABS-2 and pinned by the false-absent guard test. `distance = 1 − similarity`. | +| Threshold defaults | `close=0.85`, `absent_floor=0.40`, `candidate_count=5`, `ngram_q=3` (illustrative defaults in spec; finalized/validated by PR-ABS-2 tests). Config-tunable. | +| `resolve` phantoms | `resolve` does not filter `s.resolved` (`resolve_service.py:170-244`); this plan makes phantom matches explicit via `external_identity` rather than adding a filter. | +| Hints coexistence | `absence` and `hints_structured`/`advisories` compose. The subsumed `mcp_hints` branches are left in place for now (no removal in this plan) to avoid a regression surface; a follow-up can retire them. | +| `correct_empty` | Added (4th verdict), generalizing `is_external_entrypoint`. | + +--- + +# PR-ABS-0 — Shared types, `absence` field on 5 models, config knobs + +**Goal:** Land the contracts every later PR consumes — the `AbsenceDiagnosis` DTO, +the optional `absence` field on all five MCP output models, and the config +knobs — with no behavior change yet (`absence` stays `None` everywhere). + +**Key facts (verified):** +- Output models are independent classes (no shared base); envelope fields + repeated per model: `mcp_v2.py:509` (`SearchOutput`), `:525` (`FindOutput`), + `:546` (`DescribeOutput`), `:554` (`NeighborsOutput`), + `resolve_service.py:121` (`ResolveOutput`). +- `ResolveOutput`/`ResolveCandidate` set `model_config = ConfigDict(extra="forbid")` + (`resolve_service.py:114`, `:122`) — a *declared* `absence` field is fine. +- Config precedence CLI > env > YAML > default; env prefix + `JAVA_CODEBASE_RAG_`; pickers `_pick_str`/`_pick_bool` exist, no `_pick_float` + (`config.py:396`, `:439`); `ResolvedOperatorConfig` is a frozen dataclass + (`config.py:353`); end-to-end model = `hints_enabled` + (`config.py:562` → field `:361` → consumer `server.py:854`). +- Existing top-level py-modules are listed in `pyproject.toml` `[tool.setuptools] + py-modules` (`:70-89`). + +## File-by-file changes + +### 0.1 `absence_types.py` (new) + +Pydantic v2 models. Field names and types are the contract later PRs import. + +``` +AbsenceVerdict = Literal["refine_query","not_in_project","external_dependency","correct_empty"] +AbsenceCause = Literal["identifier_miss","nl_miss","filter_miss","external","meaningful_empty"] +ExternalReason = Literal["prefix","phantom","unresolved-call"] + +AbsenceProof(BaseModel): # backs a hard not_in_project + nearest_distance: float + symbol_count_scanned: int + thresholds_applied: dict[str,float] # {"close":..,"absent_floor":..} + query_shape: Literal["identifier"] + +ExternalIdentity(BaseModel): + fqn: str + reason: ExternalReason + source: str | None = None + +VocabularyContext(BaseModel): + top_modules: list[tuple[str,int]] + top_microservices: list[tuple[str,int]] + roles_present: list[tuple[str,int]] + frequent_name_tokens: list[str] + +FilterRelaxationDim(BaseModel): + dimension: str + constrained_value: str | None + matches_under_relaxation: int + suggested_value: str | None + +FilterRelaxation(BaseModel): + per_dimension: list[FilterRelaxationDim] + +AbsenceDiagnosis(BaseModel): + verdict: AbsenceVerdict + cause: AbsenceCause + message: str + closest_symbols: list[NodeRef] = [] # reuse graph_types.NodeRef + distances: list[float] = [] + proof: AbsenceProof | None = None + external_identity: ExternalIdentity | None = None + vocabulary_context: VocabularyContext | None = None + filter_relaxation: FilterRelaxation | None = None +``` + +Import `NodeRef` from `graph_types` (`graph_types.py:28`). Use +`Field(default_factory=list)` for the two list fields (match the repo's pattern +at `mcp_v2.py:511`). + +### 0.2 `mcp_v2.py` (modified) + +Add one field to each of the four output classes, after `hints_structured`: +`absence: AbsenceDiagnosis | None = None` (`SearchOutput` `:522`, +`FindOutput` `:543`, `DescribeOutput` `:551`, `NeighborsOutput` `:569`). Import +`AbsenceDiagnosis` from `absence_types` near `mcp_v2.py:34`. + +### 0.3 `resolve_service.py` (modified) + +Add `absence: AbsenceDiagnosis | None = None` to `ResolveOutput` +(`resolve_service.py:131`, after `hints_structured`). Import `AbsenceDiagnosis` +near `resolve_service.py:13`. (Declared field — compatible with `extra="forbid"`.) + +### 0.4 `config.py` (modified) + +- Add a `_pick_float(env_key, yaml_dict, yaml_path, default) -> tuple[float, + SettingSource]` helper mirroring `_pick_bool` (`config.py:439`): precedence + CLI → env (parse via `float(...)`, fall back to default on `ValueError`) → YAML + path-walk → default. +- Add 5 fields to `ResolvedOperatorConfig` (`config.py:353`) + matching + `*_source: SettingSource`: `absence_close_threshold: float = 0.85`, + `absence_absent_floor: float = 0.40`, `absence_candidate_count: int = 5`, + `absence_ngram_q: int = 3`, `absence_diag_enabled: bool = True`. (Int knobs + reuse a `_pick_int` added the same way, or `_pick_str`+`int(...)`; pick one and + be consistent.) +- Read them in `resolve_operator_config` (`config.py:499`) with env names + `JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD`, + `_ABSENCE_ABSENT_FLOOR`, `_ABSENCE_CANDIDATE_COUNT`, `_ABSENCE_NGRAM_Q`, + `_ABSENCE_DIAG_ENABLED`; YAML paths under a new `absence:` section + (`("absence","close_threshold")`, etc.). + +### 0.5 `pyproject.toml` (modified) + +Add `absence_types`, `absence_vocab`, `absence_diagnosis` to +`[tool.setuptools] py-modules` (`pyproject.toml:70-89`) so they ship in the +wheel. (Only `absence_types` exists after this PR; list all three now to avoid a +later packaging edit.) + +## Tests for PR-ABS-0 + +`tests/test_absence_types.py` (new): +- `AbsenceDiagnosis` constructs with only `verdict`/`cause`/`message`; the four + optional payloads default to `None`/`[]`. +- `AbsenceProof`, `ExternalIdentity`, `VocabularyContext`, `FilterRelaxation` + round-trip via `.model_dump()` → re-parse (pydantic equality). +- Each of the 5 MCP output models accepts `absence=None` (default) and an + `AbsenceDiagnosis` instance; `.model_dump()["absence"]` reflects it. +- `ResolveOutput.model_dump()` with an `absence` set does not raise under + `extra="forbid"`. + +`tests/test_config.py` (extend, or new if absent): +- `resolve_operator_config(...)` returns the 5 absence fields with defaults when + no env/YAML is set. +- Setting `JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD=0.9` (monkeypatched env) + yields `absence_close_threshold == 0.9`; a non-numeric value falls back to the + default (0.85) without raising. + +## Definition of done (PR-ABS-0) +- [ ] `absence_types.py` exists with the DTOs above; `pyproject.toml` lists the 3 + new modules. +- [ ] All 5 output models carry an optional `absence` field; existing tests still + pass (field is additive). +- [ ] Config knobs readable with defaults; `_pick_float` added. +- [ ] `pytest tests/test_absence_types.py tests/test_config.py -q` green. +- [ ] PR title: `feat(absence): shared diagnosis types, absence field, config knobs`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Write failing model/contract tests | `tests/test_absence_types.py` | `import absence_types` fails (module missing) | +| 2 | Create `absence_types.py` with the DTOs; add to `pyproject.toml` py-modules | `absence_types.py`, `pyproject.toml` | model tests pass | +| 3 | Write failing config-knob tests | `tests/test_config.py` | tests fail (knobs absent) | +| 4 | Add `_pick_float`/`_pick_int`, 5 fields, env+YAML reads | `config.py` | config tests pass | +| 5 | Add `absence` field + import to the 5 output models | `mcp_v2.py`, `resolve_service.py` | `tests/test_mcp_v2.py` + `tests/test_resolve_service.py` still green (additive) | +| 6 | Run targeted suite; commit | — | green; commit | + +--- + +# PR-ABS-1 — Vocabulary index asset (Layer 1) + +**Goal:** A `VocabularyIndex` that can be built from a `LadybugGraph`, persisted +as a versioned sidecar JSON, loaded, and queried for did-you-mean candidates and +external membership. Wired into the graph build so reprocess produces it; a CLI +subcommand rebuilds it standalone. + +**Key facts (verified):** +- `LadybugGraph.get(db_path=None)` resolves path via `resolve_ladybug_path` + (`ladybug_queries.py:370`, `:97`); `_rows(cypher, params)` runs a query + (`:401`). +- Symbol enumeration query shape (from `find_v2`, `mcp_v2.py:999`): + `MATCH (s:Symbol) ... RETURN s.id, s.fqn, s.name, s.kind, s.module, + s.microservice, s.role, s.resolved`. `_SYM_COLS` lists all Symbol columns + (`ladybug_queries.py:296`). +- `_is_external_fqn(fqn)` + `_EXTERNAL_PREFIXES` (`ladybug_queries.py:267/240`). +- Graph build: `write_ladybug(...)` (`build_ast_graph.py:4168`) calls + `_write_meta(conn, tables, source_root)` (`:4208`) last, then closes the db. + The build runs via `run_build_ast_graph` subprocess (`pipeline.py:359`), + invoked by `cli.py:_cmd_reprocess` (`:516`). +- Index dir: `JAVA_CODEBASE_RAG_INDEX_DIR` (default `./.java-codebase-rag`); + graph db filename `code_graph.lbug`. +- `pyproject.toml` has no `msgpack`; stdlib `json` is the convention. + +## File-by-file changes + +### 1.1 `absence_vocab.py` (new) + +A `VocabularyIndex` class (no pydantic; plain class + module functions for +load/save to keep the hot path light). Contracts: + +``` +@dataclass SymbolRecord: + node_id: str; fqn: str; simple_name: str; normalized_name: str + kind: str; module: str|None; microservice: str|None; role: str|None + resolved: bool + +class VocabularyIndex: + def __init__(self, records: list[SymbolRecord], ngram_index: dict[str,list[int]], q: int): ... + @classmethod + def build(cls, graph: LadybugGraph, *, q: int) -> "VocabularyIndex": ... + def save(self, path: Path, *, ontology_version: int) -> None: ... + @classmethod + def load(cls, path: Path) -> "VocabularyIndex": ... # raises VocabIndexStale if header ontology_version != graph's + def lookup(self, name: str, *, limit: int) -> list[tuple[SymbolRecord,float]]: ... # ranked (record, similarity) + def is_external(self, name: str) -> tuple[bool, str|None]: ... # (is_ext, reason) via prefix/phantom + @property + def symbol_count(self) -> int: ... +``` + +Behavior (no code in the plan — described): +- `build`: enumerate all `Symbol` nodes via one `MATCH (s:Symbol) RETURN ...` + (columns above); build `SymbolRecord` list (`normalized_name` = lowercased + `simple_name` with generics/`#method`/signature stripped); build the q-gram + inverted index `dict[gram -> [record_idx]]` from each `normalized_name`'s q-grams. +- `save`: write JSON `{"format_version":1,"ontology_version":, + "built_at":,"symbol_count":,"q":,"records":[...],"ngrams":{...}}` + to `path`. +- `load`: parse JSON; if `ontology_version` != expected, raise a + `VocabIndexStale` exception (caller triggers rebuild). +- `lookup`: take the query name's q-grams → union candidate record indexes → + compute similarity (delegate the metric to `absence_diagnosis`; here return the + candidate records unranked or with a placeholder — **see Interfaces**) → return + top-`limit` `(record, similarity)`. To avoid a circular import, `lookup` + returns candidate records; **ranking by similarity is done in + `absence_diagnosis`** (PR-ABS-2). Document this split. +- `is_external`: match `name` against records; if a record exists with + `resolved=False` → `(True, "phantom")`; else if `_is_external_fqn(fqn)` on a + constructed/known fqn → `(True, "prefix")`; else `(False, None)`. + +Sidecar path constant: `VOCAB_INDEX_FILENAME = "vocab_index.json"` (lives next to +`code_graph.lbug` under the index dir). + +### 1.2 `build_ast_graph.py` (modified) + +In `write_ladybug` (`build_ast_graph.py:4168`), after the `_write_meta(...)` +call (`:4208`) and before `conn.close()`, build and persist the vocab index: +instantiate `VocabularyIndex.build(graph, q=cfg.ngram_q)` (the build uses a fresh +read-only `LadybugGraph` over the just-written db, or reuses the in-memory +`tables` — pick the `LadybugGraph` path for simplicity) and `.save(sidecar_path, +ontology_version=ONTOLOGY_VERSION)`. Wrap in try/except: a build failure must not +fail the graph build (log + continue; the diagnosis layer will lazy-rebuild). + +### 1.3 `jrag.py` (modified) — standalone rebuild subcommand + +Add a `jrag vocab-index` subcommand (next to existing admin-style commands; the +CLI dispatch is in `java_codebase_rag/jrag.py` around `:466-1102`). Behavior: +resolve the index dir + graph via `resolve_operator_config`, call +`VocabularyIndex.build(...).save(...)`, print `symbol_count` + path. (For +backfill/repair without a full reprocess.) + +### 1.4 Lazy-load helper (in `absence_vocab.py`) + +`get_vocabulary_index(graph, cfg) -> VocabularyIndex`: module-level cached +singleton (keyed by graph db path). Tries `VocabularyIndex.load(sidecar)`; on +`VocabIndexStale`/`FileNotFoundError`/exception, calls `build(...)` from the +graph, `.save(...)` (best-effort), caches, returns. This is the single entry +point the diagnosis layer (PR-ABS-2) and tools (PR-ABS-3) use. + +## Interfaces + +- **Consumes (from PR-ABS-0):** `cfg.absence_ngram_q`; `LadybugGraph` API + (`get`, `_rows`, `_is_external_fqn`); `JAVA_CODEBASE_RAG_INDEX_DIR`. +- **Produces (for PR-ABS-2/3):** + - `VocabularyIndex.lookup(name, limit) -> list[SymbolRecord]` (candidates; ranking in PR-ABS-2). + - `VocabularyIndex.is_external(name) -> tuple[bool, str|None]`. + - `VocabularyIndex.symbol_count -> int`. + - `get_vocabulary_index(graph, cfg) -> VocabularyIndex` (cached, lazy-backfill). + - `SymbolRecord` dataclass fields (above). + - Sidecar schema (JSON shape above) — the persistence contract. + +## Tests for PR-ABS-1 + +`tests/test_absence_vocab.py` (new), using the `ladybug_graph`/`ladybug_db_path` +session fixtures (`tests/conftest.py`): +- `VocabularyIndex.build(graph, q=3)` returns an index whose `symbol_count` ≥ the + graph's class-symbol count (assert ≥1 for `tests/bank-chat-system`). +- `save` then `load` round-trips: `symbol_count` equal; a known symbol's + `simple_name` present in records. +- `load` on a sidecar whose header `ontology_version` differs from the graph's + raises `VocabIndexStale`. +- `lookup("ChatService", limit=5)` (a real name in the corpus) returns that + record among candidates; `lookup("ChatService")` for a typoed + `"ChatServic"` still returns `ChatService` among candidates (n-gram recall). +- `is_external` on an external-prefix fqn (e.g. `"java.util.List"`) → `(True, + "prefix")`; on a phantom present in the corpus → `(True, "phantom")`; on a + real project symbol → `(False, None)`. +- `get_vocabulary_index`: first call with no sidecar builds + saves; second call + loads from file (assert build runs once — monkeypatch/spy on `build`). +- Build-failure resilience: monkeypatch `build` to raise → `write_ladybug` still + succeeds (graph written; sidecar absent). + +## Definition of done (PR-ABS-1) +- [ ] `absence_vocab.py` with `VocabularyIndex` + `get_vocabulary_index`; + `pyproject.toml` lists it (done in PR-ABS-0). +- [ ] `write_ladybug` writes `vocab_index.json`; failure doesn't break the build. +- [ ] `jrag vocab-index` rebuilds standalone. +- [ ] `pytest tests/test_absence_vocab.py -q` green. +- [ ] PR title: `feat(absence): precomputed vocabulary index asset + build hook`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Write failing vocab tests (build/round-trip/stale/lookup/external) | `tests/test_absence_vocab.py` | import fails | +| 2 | Implement `SymbolRecord`, `VocabularyIndex.build/save/load/lookup/is_external` | `absence_vocab.py` | round-trip + stale + lookup tests pass | +| 3 | Implement `get_vocabulary_index` lazy/backfill | `absence_vocab.py` | get/build-once test passes | +| 4 | Hook build into `write_ladybug` (try/except, no-fail) | `build_ast_graph.py` | reprocess produces sidecar; failure-isolated test passes | +| 5 | Add `jrag vocab-index` subcommand | `jrag.py` | manual: `jrag vocab-index` prints count + path | +| 6 | Run targeted suite; commit | — | green; commit | + +--- + +# PR-ABS-2 — `diagnose(...)` module (Layer 2) + +**Goal:** The stateless classifier + per-cause help assembler. Pure function of +its inputs (incl. the `VocabularyIndex`); the single place absence logic lives. + +**Key facts (verified):** +- `_find_has_identifier_shaped_filter(kind, flt)` (`mcp_hints.py:280`) — identifier + filter keys per kind: symbol→`fqn_contains`, route→`path_contains`, + client→`target_service`/`target_path_contains` (`mcp_hints.py:152`). +- `module_counts()`/`microservice_counts()` (`ladybug_queries.py:987`) → + `{name: resolved-type count}` (resolved types only). +- `NodeRef` fields (`graph_types.py:28`): id/kind/fqn/name/symbol_kind/ + microservice/module/role. +- `_zero_result_guidance(args, graph) -> str|None` (`jrag.py:4139`) — single-dim + filter relaxation; emits a human string. Port its *logic* (probe unfiltered, + tally the dim, top-3 alternatives, suggest most-common) into the structured + `FilterRelaxation` payload, parameterized on `(filter_dims, graph)` instead of + `argparse.Namespace`. + +## File-by-file changes + +### 2.1 `absence_diagnosis.py` (new) + +``` +def diagnose( + *, + tool: Literal["search","find","neighbors","describe","resolve"], + query: str | None, + filt: dict | None, # find's model_dump'd filter + filter_kind: str | None, # find's kind, for identifier-shape test + root_node: NodeRef | None, # neighbors/describe subject + scope: dict[str,str], # {"microservice":..,"module":..} + vocab: VocabularyIndex, + graph: LadybugGraph, + cfg, # ResolvedOperatorConfig (thresholds) +) -> AbsenceDiagnosis | None: ... # None on master-toggle off / unrecoverable error +``` + +Behavior (decision procedure — design, not code): +- If `not cfg.absence_diag_enabled`: return `None`. +- **External-wins:** if `query`/`filt`/`root_node` yields an external target + (`vocab.is_external(...)` or `_is_external_fqn` on `root_node.fqn` or a phantom + match), return `verdict="external_dependency"`, `cause="external"`, + `external_identity={fqn, reason, source}`, `message` stating "referenced, not + defined in this project." +- **root_node present (neighbors):** if `root_node` external → `external` (above, + already handled). Else if the zero is meaningful (leaf / external entrypoint — + reuse the conditions behind `is_external_entrypoint`, `jrag_envelope.py:116` / + `jrag.py:2393`) → `correct_empty`/`meaningful_empty`. Else (inapplicable edge + type/direction) → `refine_query` with a message pointing at + `describe.edge_summary`. +- **query present (search/resolve/describe-by-fqn):** classify identifier-shape + (CamelCase token / dotted FQN / `Cls#method`, no spaces/stopwords — extend the + heuristic from `_find_has_identifier_shaped_filter`'s spirit). For + `describe`-by-`node_id` (not fqn) → `refine_query`, no did-you-mean. + - Identifier-shaped → run did-you-mean (below). + - NL → `nl_miss`: assemble `vocabulary_context` from `module_counts`/ + `microservice_counts` + role tally + frequent name tokens; `verdict="refine_query"`. +- **filt present (find):** if identifier-shaped (`_find_has_identifier_shaped_filter`) + → run did-you-mean on the identifier value; if a close hit exists it's + `filter_miss` with `filter_relaxation` showing where it lives, else + `identifier_miss`. If broad/non-identifier → `filter_miss` with `filter_relaxation`. + +**Did-you-mean (identifier case):** +- `candidates = vocab.lookup(identifier, limit=cfg.absence_candidate_count)` → + rank by normalized string similarity ∈ [0,1] (Jaro-Winkler family; exact variant + pinned by tests) → top N `(SymbolRecord, similarity)`. +- Map to `closest_symbols: list[NodeRef]` (build `NodeRef` from each record) and + parallel `distances = [1-sim for ...]`. +- Verdict: best similarity ≥ `cfg.absence_close_threshold` → `refine_query`; + best < `cfg.absence_absent_floor` AND identifier-shaped → `not_in_project` with + `proof={nearest_distance, symbol_count_scanned=vocab.symbol_count, + thresholds_applied, query_shape="identifier"}`; middle band → `refine_query`. +- Always populate `closest_symbols`/`distances` (even for `not_in_project` — the + proof + nearest names). + +**Filter relaxation (filter_miss):** port `_zero_result_guidance`'s logic into +`FilterRelaxation.per_dimension`: for each constrained filter dimension present +(role/module/microservice/fqn_contains/...), probe `find_v2`/`search_v2` with +that dimension relaxed, tally where matches live, set `matches_under_relaxation` ++ `suggested_value` (most-common bucket). Return the structured payload. + +**Robustness:** the whole function is wrapped so any exception → log + return a +minimal `refine_query` `AbsenceDiagnosis` (or `None` if even that can't be built). + +## Interfaces + +- **Consumes:** `AbsenceDiagnosis` + sub-DTOs (PR-ABS-0); `VocabularyIndex` + + `SymbolRecord` + `get_vocabulary_index` (PR-ABS-1); `NodeRef` (`graph_types`); + `_find_has_identifier_shaped_filter` (`mcp_hints.py:280`); `_is_external_fqn` + (`ladybug_queries.py:267`); `module_counts`/`microservice_counts` + (`ladybug_queries.py:987`); config thresholds (PR-ABS-0). +- **Produces (for PR-ABS-3/4):** `diagnose(...) -> AbsenceDiagnosis | None` with + the exact signature above. This is the single call site each tool makes. + +## Tests for PR-ABS-2 + +`tests/test_absence_diagnosis.py` (new). Mirror `tests/test_mcp_hints.py`'s +pure-generator style — call `diagnose(...)` with a real `ladybug_graph` fixture + +its `VocabularyIndex` (synthetic where possible, graph-backed where needed): + +Unit matrix (cause × verdict), each naming scenario + expected result: +- identifier, known typo (e.g. `"ChatServic"` vs corpus `ChatService`) → + `refine_query`, `closest_symbols` non-empty, `ChatService` present, best + distance small. +- identifier, nothing close (e.g. `"zzzNoSuchClass123"`) → `not_in_project`, + `proof` populated, `closest_symbols` still returned (nearest-by-name), best + distance ≥ `absent_floor`. +- middle-band name (a plausible-but-absent identifier) → `refine_query` (NOT + `not_in_project`) — **the false-absent guard.** +- **False-absent guard (explicit):** a symbol that *exists* under an unusual name + must never yield `not_in_project` when queried exactly. +- NL query (`"how does chat routing work"`) → `nl_miss`, `vocabulary_context` + populated with the corpus's top modules/services, NO `closest_symbols`. +- find, identifier-shaped filter excluded by another dim → `filter_miss`, + `filter_relaxation.per_dimension` non-empty. +- find, broad filter (e.g. `role=REPOSITORY` absent) → `filter_miss`. +- external target (`"java.util.List"`) → `external_dependency`, `external_identity.reason=="prefix"`. +- phantom target present in corpus → `reason=="phantom"`. +- neighbors of a leaf/entrypoint → `correct_empty`. +- neighbors, wrong edge type → `refine_query`. +- master toggle off (`cfg.absence_diag_enabled=False`) → returns `None`. +- exception path (monkeypatch `vocab.lookup` to raise) → returns a `refine_query` + (no exception escapes). + +## Definition of done (PR-ABS-2) +- [ ] `absence_diagnosis.py` with `diagnose(...)`; full unit matrix green. +- [ ] Conservative threshold + false-absent guard validated. +- [ ] `pytest tests/test_absence_diagnosis.py -q` green. +- [ ] PR title: `feat(absence): diagnosis module — classifier + per-cause help`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Write failing unit-matrix tests | `tests/test_absence_diagnosis.py` | import fails | +| 2 | Implement identifier-shape classifier + did-you-mean + thresholds | `absence_diagnosis.py` | identifier tests + false-absent guard pass | +| 3 | Implement external-wins + neighbors branches | `absence_diagnosis.py` | external/neighbors tests pass | +| 4 | Implement nl_miss (vocabulary_context) + filter_miss (port relaxation) | `absence_diagnosis.py` | nl/filter tests pass | +| 5 | Add master-toggle + exception guard | `absence_diagnosis.py` | toggle + exception tests pass | +| 6 | Run targeted suite; commit | — | green; commit | + +--- + +# PR-ABS-3 — MCP tool integration (Layer 3, MCP) + +**Goal:** Wire `diagnose(...)` into the five empty paths and attach the result to +each output's `absence` field. No change to non-empty results. + +**Key facts (verified):** +- `search` empty flows through the success return at `mcp_v2.py:953-967` + (`hits==[]`); the `LadybugGraph` handle is the `graph` param (`:830`) — no local + `g`; in scope: `query`, `table`, `nf`, `limit`, `offset`. +- `find` empty at `mcp_v2.py:1052-1061` (`refs==[]`); local `g = graph or + LadybugGraph.get()` (`:980`); in scope: `kind`, `nf`, `filter_dump` + (`nf.model_dump(exclude_none=True)`, `:1037`). +- `describe` two not-found returns: fqn `mcp_v2.py:1096`, node_id `:1108`; + local `g` (`:1079`); in scope: `fqn`, `id`, `has_fqn`, `has_id`. +- `neighbors` empty at `mcp_v2.py:1638-1646` (`sliced==[]`); local `g` (`:1388`); + in scope: `first_origin`, `origin_kind`, `subject_record`, `requested_edge_types`. +- `resolve` none at `resolve_service.py:570-578`; in scope: `trimmed`, `hint_kind`; + outer `resolve_v2` (`:612`) has `microservice`, `module`, `graph`. + +## File-by-file changes + +### 3.1 `mcp_v2.py` (modified) — search/find/describe/neighbors + +For each empty path, when the result list is empty (`not hits` / `not refs` / +`not sliced`) or at the not-found returns, build `AbsenceDiagnosis` via +`absence_diagnosis.diagnose(...)` and pass `absence=diag` into the output +constructor. Inputs per tool: +- **search:** `tool="search"`, `query=query`, `filt=None`, `root_node=None`, + `scope={}`, `vocab=get_vocabulary_index(graph, cfg)`, `graph=graph or + LadybugGraph.get()`, `cfg`. (Search must resolve a graph handle since it has no + local `g`.) +- **find:** `tool="find"`, `query=None`, `filt=filter_dump`, `filter_kind=kind`, + `root_node=None`, `graph=g`, ... +- **describe:** fqn path → `tool="describe"`, `query=fqn`; node_id path → + `tool="describe"`, `query=None` (→ minimal `refine_query`). +- **neighbors:** `tool="neighbors"`, `root_node=` (build a `NodeRef` from the in-scope origin data), `query=None`. + +`cfg` must be reachable in `mcp_v2` — thread it from `server.create_mcp_server` +(where `set_hints_enabled(cfg.hints_enabled)` is already called, `server.py:854`) +into the tool functions, or read via a module-level setter mirroring +`set_hints_enabled`. Pick the setter pattern (least churn): add +`set_absence_config(cfg)` in `mcp_v2`, called from `server.py:854`. + +Keep the existing `_hints_or_skip`/`hints_structured` calls untouched (compose). + +### 3.2 `resolve_service.py` (modified) — resolve none + +In `_resolve_finalize_success` (`resolve_service.py:565`), the `not matches` +branch (`:570`): before/after building the `ResolveOutput`, call `diagnose(...)` +with `tool="resolve"`, `query=trimmed`, `hint_kind` mapped to `filter_kind` where +relevant, `graph` from the outer `resolve_v2` (`:612`), and attach via the +existing `model_copy(update=...)` at `:603-607` (add `"absence": diag`). + +## Interfaces + +- **Consumes:** `diagnose(...)` (PR-ABS-2); `get_vocabulary_index` (PR-ABS-1); + `cfg` thresholds (PR-ABS-0); the `absence` field (PR-ABS-0). +- **Produces:** MCP outputs whose `absence` field is populated on empty paths. + +## Tests for PR-ABS-3 + +`tests/test_absence_mcp_integration.py` (new), graph-backed via `ladybug_graph` +(direct tool calls, mirroring `tests/test_mcp_v2.py`): +- `search_v2("zzzNoSuchClass123", graph=...)` → `out.absence.verdict == + "not_in_project"`, `out.absence.proof` populated. +- `search_v2("ChatServic", graph=...)` (typo) → `out.absence.verdict == + "refine_query"`, `ChatService` in `out.absence.closest_symbols`. +- `search_v2("java.util.List", ...)` → `out.absence.verdict == + "external_dependency"`. +- `find_v2(kind="symbol", filter={"fqn_contains":"zzzNoMatch"}, graph=...)` → + `out.absence` populated (`identifier_miss` or `filter_miss`). +- `describe_v2(fqn="com.no.such.Type", graph=...)` → `out.success is False` AND + `out.absence.verdict == "not_in_project"` (did-you-mean attached to the failure). +- `neighbors_v2(, ["CALLS"], graph=...)` → `out.absence.vercord == + "correct_empty"`. +- Non-empty results: `search_v2("ChatService", ...)` with hits → + `out.absence is None`. +- `resolve_v2("zzzNoSuch", graph=...)` → `out.status=="none"` AND + `out.absence.verdict == "not_in_project"`. + +## Definition of done (PR-ABS-3) +- [ ] All 5 MCP empty paths attach `absence`; non-empty results unaffected. +- [ ] `tests/test_mcp_v2.py` + `tests/test_resolve_service.py` still green. +- [ ] `pytest tests/test_absence_mcp_integration.py -q` green. +- [ ] PR title: `feat(absence): wire diagnosis into the 5 MCP empty paths`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Write failing integration tests (5 tools) | `tests/test_absence_mcp_integration.py` | tests fail (absence stays None) | +| 2 | Add `set_absence_config` + thread cfg from `server.py:854` | `mcp_v2.py`, `server.py` | cfg reachable | +| 3 | Wire search + find empty paths | `mcp_v2.py` | search/find integration tests pass | +| 4 | Wire describe (both returns) + neighbors | `mcp_v2.py` | describe/neighbors tests pass | +| 5 | Wire resolve none | `resolve_service.py` | resolve test passes | +| 6 | Run targeted suite; commit | — | green; commit | + +--- + +# PR-ABS-4 — CLI envelope alignment + renderer (Layer 3, CLI) + +**Goal:** The `jrag` CLI speaks the same absence vocabulary — `Envelope.absence` +serialized, resolve-none carries it, and the renderer maps verdicts to text +(generalizing `is_external_entrypoint` under `correct_empty`). + +**Key facts (verified):** +- `Envelope` is a `@dataclass` (`jrag_envelope.py:86-120`); fields incl. `status`, + `is_external_entrypoint`, `message`; `to_dict()` (`:122`) omits empty + optionals, emits `is_external_entrypoint` only when truthy (`:152`); `to_json`/ + `_to_idfree_dict` (`:156`). +- `EnvelopeStatus = Literal["ok","ambiguous","not_found","error"]` (`:39`). +- resolve-none → `not_found` at `jrag_envelope.py:606-613` (returns + `Envelope(status="not_found", message=...)`). +- Renderer: `_render_not_found` (`jrag_render.py:227`); traversal empty block + with `is_external_entrypoint` text (`:407-426`); listing empty (`:650-653`). + +## File-by-file changes + +### 4.1 `java_codebase_rag/jrag_envelope.py` (modified) +- Add `absence: AbsenceDiagnosis | None = None` to `Envelope` (`:120`). +- In `to_dict()` (`:122`) and `_to_idfree_dict`/`to_json`: emit `absence` (as + `.model_dump()` when present, omit when `None`), matching the + `is_external_entrypoint` truthy-omit pattern. +- resolve-none conversion (`:606-613`): pull `out.absence` from the `ResolveOutput` + onto the returned `Envelope(...)`. + +### 4.2 `java_codebase_rag/jrag_render.py` (modified) +- Extend `_render_not_found` (`:227`): when `envelope.absence` is present, append + the verdict + `message` (+ a compact "did-you-mean: a, b, c" line when + `closest_symbols` is non-empty). +- Traversal empty block (`:407-426`): the `is_external_entrypoint` branch now also + covers `absence.verdict == "correct_empty"` (same "external entrypoint — no + in-repo callers" text, or a `correct_empty` message). Map other verdicts to a + short line (`not_in_project`/`external_dependency`/`refine_query`) above the + existing zero-line. +- Listing empty (`:650-653`): when `absence` present, render the verdict line + before/instead of the bare `0 `. + +## Interfaces + +- **Consumes:** `AbsenceDiagnosis` (PR-ABS-0); the MCP outputs' `absence` (PR-ABS-3). +- **Produces:** CLI text + JSON reflecting absence verdicts. + +## Tests for PR-ABS-4 + +`tests/test_jrag_envelope_absence.py` (new): +- `Envelope(status="not_found", absence=).to_dict()` includes `absence` + with the verdict; `Envelope(status="ok")` omits it. +- `_render_not_found` for a `not_in_project` envelope prints the verdict + a + did-you-mean line when `closest_symbols` non-empty. +- `is_external_entrypoint=True` and `absence.verdict=="correct_empty"` render the + same "external entrypoint" text (no regression). +- resolve `status="none"` (with `out.absence`) → envelope carries `absence`, + rendered as not-found + verdict. + +## Definition of done (PR-ABS-4) +- [ ] `Envelope.absence` serialized; renderer maps all 4 verdicts. +- [ ] Existing CLI render tests still green; `is_external_entrypoint` unchanged. +- [ ] `pytest tests/test_jrag_envelope_absence.py -q` green. +- [ ] PR title: `feat(absence): CLI envelope + renderer alignment`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Write failing envelope/render tests | `tests/test_jrag_envelope_absence.py` | tests fail | +| 2 | Add `absence` field + serialization | `jrag_envelope.py` | to_dict test passes | +| 3 | Thread resolve-none `out.absence` into Envelope | `jrag_envelope.py:606` | resolve-none test passes | +| 4 | Extend renderer (not-found, traversal, listing) | `jrag_render.py` | render tests pass | +| 5 | Run targeted suite; commit | — | green; commit | + +--- + +# PR-ABS-5 — Docs + +**Goal:** Operators and agents know the verdicts and how to read `absence`; the +new knobs are documented. + +## File-by-file changes + +### 5.1 `docs/AGENT-GUIDE.md` +- Replace/augment the "Empty `search`/`neighbors`" rows in the recovery playbook + (`:263-268`) with the verdict vocabulary: read `absence.verdict` first + (`not_in_project` → stop; `external_dependency` → it's a dep; `refine_query` → + use `closest_symbols`/`vocabulary_context`/`filter_relaxation`; `correct_empty` + → the zero is right). +- Update the proof-of-absence caveat (`:29`) to point at `absence.proof` as the + auditable signal. + +### 5.2 `docs/CONFIGURATION.md` +- Document the 5 `absence_*` env vars + the `absence:` YAML section, with + defaults and the "raise `absence_absent_floor` to be more conservative" guidance. + +## Definition of done (PR-ABS-5) +- [ ] AGENT-GUIDE recovery playbook + proof-of-absence updated. +- [ ] CONFIGURATION documents the 5 knobs. +- [ ] PR title: `docs(absence): verdict vocabulary + config knobs`. + +## Implementation steps + +| # | Step | File(s) | Done when | +|---|---|---|---| +| 1 | Update AGENT-GUIDE recovery playbook + proof-of-absence | `docs/AGENT-GUIDE.md` | verdicts documented | +| 2 | Document 5 config knobs | `docs/CONFIGURATION.md` | knobs + defaults present | +| 3 | Commit | — | commit | + +--- + +# Cross-PR risks and mitigations + +| # | Risk | Severity | Mitigation | +|---|---|---|---| +| 1 | Sidecar JSON grows large on enterprise repos; slow load | Med | `VocabularyIndex` owns load/save (swap to msgpack later); perf-guard test in PR-ABS-1; lazy build is cached per process | +| 2 | `diagnose(...)` adds latency to every empty | Med | Empty results are the minority path; index lookup is bounded; master toggle `absence_diag_enabled` + exception guard | +| 3 | `resolve` `extra="forbid"` rejects undeclared fields | Low | `absence` is a declared field (PR-ABS-0); covered by a model-dump test | +| 4 | `search` has no local graph handle | Low | Resolve `graph or LadybugGraph.get()` in the empty path (PR-ABS-3) | +| 5 | False-absent on unusual naming | High | Conservative two-band threshold + explicit false-absent guard test (PR-ABS-2); defaults tunable via config | +| 6 | Build hook breaks reprocess | High | try/except around the vocab build in `write_ladybug`; failure-isolated test (PR-ABS-1) | +| 7 | Subsumed `mcp_hints` branches drift from `absence` | Low | Left in place this plan (compose); follow-up retires them | + +# Out of scope + +- Non-Java tables (`sql`, `yaml`) absence diagnosis. +- Removing/retiring the subsumed `mcp_hints` branches. +- Switching the index format to msgpack or adding compression. +- A new MCP tool (membership is surfaced on existing tools + `resolve`). + +# Whole-plan done definition + +1. All 5 MCP outputs + CLI `Envelope` carry a populated `absence` on empty paths; + non-empty results unaffected. +2. `absence_diag_enabled=False` restores pre-feature behavior (all `absence=None`). +3. The false-absent guard test passes (no real symbol is ever declared absent). +4. `vocab_index.json` is produced by reprocess and by `jrag vocab-index`; missing + or stale sidecar is lazily rebuilt without error. +5. Docs updated; full test suite green (run once at task end per `AGENTS.md`). + +# Verification (end-to-end, on `tests/bank-chat-system`) + +```bash +.venv/bin/pip install -e . +rm -rf tests/*/.java-codebase-rag tests/*/.java-codebase-rag.{yml,hosts} +# Rebuild the graph + vocab index for the fixture, then exercise the verdicts: +JAVA_CODEBASE_RAG_INDEX_DIR=tests/bank-chat-system/.java-codebase-rag .venv/bin/jrag status +.venv/bin/jrag search zzzNoSuchClass123 # expect: not_in_project + proof +.venv/bin/jrag search ChatServic # expect: refine_query + did-you-mean -> ChatService +.venv/bin/jrag search "java.util.List" # expect: external_dependency +.venv/bin/jrag find --kind symbol --fqn-contains zzzNoMatch # expect: absence populated +# Toggle off -> pre-feature behavior: +JAVA_CODEBASE_RAG_ABSENCE_DIAG_ENABLED=false .venv/bin/jrag search zzzNoSuchClass123 # absence absent +# Full suite at the end: +.venv/bin/python -m pytest -q +``` + +# Tracking + +- PR-ABS-0 — _pending_ +- PR-ABS-1 — _pending_ +- PR-ABS-2 — _pending_ +- PR-ABS-3 — _pending_ +- PR-ABS-4 — _pending_ +- PR-ABS-5 — _pending_ + +# Notes + +- Serialization deviation from the spec (msgpack → stdlib JSON) is documented in + Resolved decisions; the format is encapsulated behind `absence_vocab`'s + load/save so it is swappable. +- The `diagnose(...)` ranking metric is left to a Jaro-Winkler-family variant + pinned by the PR-ABS-2 tests (the spec defers the exact metric to the plan; + this plan defers the exact variant to the tests, which is the contract that + matters). From add354210188c5a1e19b61934727705fe3cb2a8c Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 00:39:49 +0300 Subject: [PATCH 03/17] feat(absence): shared diagnosis types, absence field, config knobs - Create absence_types.py with AbsenceDiagnosis DTOs (AbsenceProof, ExternalIdentity, VocabularyContext, FilterRelaxation) - Add optional absence: AbsenceDiagnosis | None = None field to 5 MCP output models (SearchOutput, FindOutput, DescribeOutput, NeighborsOutput, ResolveOutput) - Add config knobs: absence_close_threshold, absence_absent_floor, absence_candidate_count, absence_ngram_q, absence_diag_enabled - Add _pick_float and _pick_int helpers to config.py for numeric config parsing - Update pyproject.toml to include absence_types, absence_vocab, absence_diagnosis modules All changes are additive/no behavior change (absence stays None everywhere). Tests: test_absence_types.py (DTOs and output model fields), test_config.py (knobs with defaults). Co-Authored-By: Claude --- absence_types.py | 124 +++++++++++++++++++ java_codebase_rag/config.py | 112 +++++++++++++++++ mcp_v2.py | 5 + pyproject.toml | 3 + resolve_service.py | 2 + tests/test_absence_types.py | 240 ++++++++++++++++++++++++++++++++++++ tests/test_config.py | 76 ++++++++++++ 7 files changed, 562 insertions(+) create mode 100644 absence_types.py create mode 100644 tests/test_absence_types.py diff --git a/absence_types.py b/absence_types.py new file mode 100644 index 00000000..aa91ea0a --- /dev/null +++ b/absence_types.py @@ -0,0 +1,124 @@ +"""Absence diagnosis data transfer objects. + +These types define the contract for explaining empty MCP tool results. +Later PRs (ABS-2, ABS-3) populate these fields; ABS-0 only declares them. +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from graph_types import NodeRef + +__all__ = [ + "AbsenceVerdict", + "AbsenceCause", + "ExternalReason", + "AbsenceProof", + "ExternalIdentity", + "VocabularyContext", + "FilterRelaxationDim", + "FilterRelaxation", + "AbsenceDiagnosis", +] + +# Literal types for verdicts and causes +AbsenceVerdict = Literal["refine_query", "not_in_project", "external_dependency", "correct_empty"] +AbsenceCause = Literal["identifier_miss", "nl_miss", "filter_miss", "external", "meaningful_empty"] +ExternalReason = Literal["prefix", "phantom", "unresolved-call"] + + +class AbsenceProof(BaseModel): + """Evidence backing a hard 'not_in_project' verdict. + + Attributes: + nearest_distance: Distance to the closest symbol found (0-1) + symbol_count_scanned: Total symbols examined during search + thresholds_applied: The distance thresholds used in the decision + query_shape: Shape of the original query (currently only "identifier") + """ + nearest_distance: float + symbol_count_scanned: int + thresholds_applied: dict[str, float] + query_shape: Literal["identifier"] + + +class ExternalIdentity(BaseModel): + """Identifies an external dependency that caused the empty result. + + Attributes: + fqn: Fully qualified name of the external symbol + reason: Why we believe this is external (prefix, phantom, unresolved call) + source: Optional source name (e.g., "maven", "gradle") + """ + fqn: str + reason: ExternalReason + source: str | None = None + + +class VocabularyContext(BaseModel): + """Project vocabulary statistics to inform query refinement. + + Attributes: + top_modules: Most frequent modules with counts + top_microservices: Most frequent microservices with counts + roles_present: Symbol roles present with counts + frequent_name_tokens: Common tokens in symbol names + """ + top_modules: list[tuple[str, int]] + top_microservices: list[tuple[str, int]] + roles_present: list[tuple[str, int]] + frequent_name_tokens: list[str] + + +class FilterRelaxationDim(BaseModel): + """Relaxation analysis for a single filter dimension. + + Attributes: + dimension: The filter dimension (e.g., "microservice", "role") + constrained_value: The value that constrained results + matches_under_relaxation: Results if this dimension were relaxed + suggested_value: Optional alternative value to try + """ + dimension: str + constrained_value: str | None + matches_under_relaxation: int + suggested_value: str | None + + +class FilterRelaxation(BaseModel): + """Analysis of how relaxing filters would affect results. + + Attributes: + per_dimension: List of relaxation options per dimension + """ + per_dimension: list[FilterRelaxationDim] + + +class AbsenceDiagnosis(BaseModel): + """Explains why an MCP tool returned no results. + + This is the main DTO that the 5 MCP output models optionally carry. + In PR-ABS-0, the `absence` field stays None everywhere — later PRs + populate it with diagnosis logic. + + Attributes: + verdict: High-level judgment on the empty result + cause: Specific cause that led to this verdict + message: Human-readable explanation + closest_symbols: Symbols closest to the query (if any) + distances: Corresponding distance values + proof: Evidence for not_in_project verdict + external_identity: External dependency info + vocabulary_context: Project vocabulary for refinement + filter_relaxation: Filter relaxation suggestions + """ + verdict: AbsenceVerdict + cause: AbsenceCause + message: str + closest_symbols: list[NodeRef] = Field(default_factory=list) + distances: list[float] = Field(default_factory=list) + proof: AbsenceProof | None = None + external_identity: ExternalIdentity | None = None + vocabulary_context: VocabularyContext | None = None + filter_relaxation: FilterRelaxation | None = None diff --git a/java_codebase_rag/config.py b/java_codebase_rag/config.py index c6e6e47d..b9e07766 100644 --- a/java_codebase_rag/config.py +++ b/java_codebase_rag/config.py @@ -363,6 +363,17 @@ class ResolvedOperatorConfig: embedding_model_source: SettingSource embedding_device_source: SettingSource hints_enabled_source: SettingSource + # Absence diagnosis config knobs (PR-ABS-0) + absence_close_threshold: float + absence_absent_floor: float + absence_candidate_count: int + absence_ngram_q: int + absence_diag_enabled: bool + absence_close_threshold_source: SettingSource + absence_absent_floor_source: SettingSource + absence_candidate_count_source: SettingSource + absence_ngram_q_source: SettingSource + absence_diag_enabled_source: SettingSource # Absolute path of the YAML actually loaded (None when built-in defaults were # used with no config file). Recorded into the index dir at index time so a # later discovery run from a sibling/cwd can relocate this config. @@ -459,6 +470,66 @@ def _pick_bool( return default, "default" +def _pick_float( + *, + env_key: str, + yaml_dict: dict[str, Any], + yaml_path: tuple[str, ...], + default: float, +) -> tuple[float, SettingSource]: + """Pick a float setting from env (parsed via float(...)), YAML, or default. + + Precedence: CLI > env > YAML > default. Env values that fail to parse as float + fall back to the default (matching the brief's requirement for graceful degradation). + """ + env_raw = os.environ.get(env_key, "").strip() + if env_raw: + try: + return float(env_raw), "env" + except ValueError: + # Invalid env value falls back to default (per brief) + pass + cur: Any = yaml_dict + for part in yaml_path: + if not isinstance(cur, dict) or part not in cur: + cur = None + break + cur = cur.get(part) + if isinstance(cur, (int, float)): + return float(cur), "yaml" + return default, "default" + + +def _pick_int( + *, + env_key: str, + yaml_dict: dict[str, Any], + yaml_path: tuple[str, ...], + default: int, +) -> tuple[int, SettingSource]: + """Pick an int setting from env (parsed via int(...)), YAML, or default. + + Precedence: CLI > env > YAML > default. Env values that fail to parse as int + fall back to the default (matching the brief's requirement for graceful degradation). + """ + env_raw = os.environ.get(env_key, "").strip() + if env_raw: + try: + return int(env_raw), "env" + except ValueError: + # Invalid env value falls back to default (per brief) + pass + cur: Any = yaml_dict + for part in yaml_path: + if not isinstance(cur, dict) or part not in cur: + cur = None + break + cur = cur.get(part) + if isinstance(cur, int): + return cur, "yaml" + return default, "default" + + def _resolve_index_dir_path( *, source_root: Path, @@ -565,6 +636,37 @@ def resolve_operator_config( yaml_path=("hints", "enabled"), default=True, ) + # Absence diagnosis config (PR-ABS-0) + abs_close, abs_close_src = _pick_float( + env_key="JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD", + yaml_dict=yaml_dict, + yaml_path=("absence", "close_threshold"), + default=0.85, + ) + abs_floor, abs_floor_src = _pick_float( + env_key="JAVA_CODEBASE_RAG_ABSENCE_ABSENT_FLOOR", + yaml_dict=yaml_dict, + yaml_path=("absence", "absent_floor"), + default=0.40, + ) + abs_cand, abs_cand_src = _pick_int( + env_key="JAVA_CODEBASE_RAG_ABSENCE_CANDIDATE_COUNT", + yaml_dict=yaml_dict, + yaml_path=("absence", "candidate_count"), + default=5, + ) + abs_q, abs_q_src = _pick_int( + env_key="JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", + yaml_dict=yaml_dict, + yaml_path=("absence", "ngram_q"), + default=3, + ) + abs_diag, abs_diag_src = _pick_bool( + env_key="JAVA_CODEBASE_RAG_ABSENCE_DIAG_ENABLED", + yaml_dict=yaml_dict, + yaml_path=("absence", "diag_enabled"), + default=True, + ) ku = index_dir / "code_graph.lbug" coco = index_dir / "cocoindex.db" return ResolvedOperatorConfig( @@ -579,6 +681,16 @@ def resolve_operator_config( embedding_model_source=model_src, embedding_device_source=device_src, hints_enabled_source=hints_src, + absence_close_threshold=abs_close, + absence_absent_floor=abs_floor, + absence_candidate_count=abs_cand, + absence_ngram_q=abs_q, + absence_diag_enabled=abs_diag, + absence_close_threshold_source=abs_close_src, + absence_absent_floor_source=abs_floor_src, + absence_candidate_count_source=abs_cand_src, + absence_ngram_q_source=abs_q_src, + absence_diag_enabled_source=abs_diag_src, yaml_config_path=find_yaml_config_file(config_dir), ) diff --git a/mcp_v2.py b/mcp_v2.py index 5befac2c..c6011740 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -31,6 +31,7 @@ # installs ship without torch/lancedb); it is imported lazily in _get_sentence_transformer. from sentence_transformers import SentenceTransformer +from absence_types import AbsenceDiagnosis from graph_types import ( NodeRef, StructuredHint, @@ -524,6 +525,7 @@ class SearchOutput(BaseModel): default=False, description="True when results come from the graph-only lexical (keyword) backend instead of semantic/vector search.", ) + absence: AbsenceDiagnosis | None = None class FindOutput(BaseModel): @@ -545,6 +547,7 @@ class FindOutput(BaseModel): ) advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion") hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION) + absence: AbsenceDiagnosis | None = None class DescribeOutput(BaseModel): @@ -553,6 +556,7 @@ class DescribeOutput(BaseModel): message: str | None = None advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion") hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION) + absence: AbsenceDiagnosis | None = None class NeighborsOutput(BaseModel): @@ -571,6 +575,7 @@ class NeighborsOutput(BaseModel): ) advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion") hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION) + absence: AbsenceDiagnosis | None = None # Re-exported from resolve_service.py (imported at end of module to avoid circular import) diff --git a/pyproject.toml b/pyproject.toml index 8548dd06..d13de329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,9 @@ jrag = "java_codebase_rag.jrag:_console_script_main" [tool.setuptools] packages = ["java_codebase_rag"] py-modules = [ + "absence_diagnosis", + "absence_types", + "absence_vocab", "ast_java", "brownfield_events", "build_ast_graph", diff --git a/resolve_service.py b/resolve_service.py index c0f61ba2..58081e7c 100644 --- a/resolve_service.py +++ b/resolve_service.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field +from absence_types import AbsenceDiagnosis from graph_types import ( NodeRef, StructuredHint, @@ -129,6 +130,7 @@ class ResolveOutput(BaseModel): resolved_identifier: str | None = None advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion") hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION) + absence: AbsenceDiagnosis | None = None def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]: diff --git a/tests/test_absence_types.py b/tests/test_absence_types.py new file mode 100644 index 00000000..42934ae9 --- /dev/null +++ b/tests/test_absence_types.py @@ -0,0 +1,240 @@ +"""Tests for absence_types.py DTOs and absence field on output models.""" + +import pytest +from pydantic import ValidationError + +# These imports will fail until absence_types.py is created +from absence_types import ( + AbsenceDiagnosis, + AbsenceProof, + ExternalIdentity, + ExternalReason, + VocabularyContext, + FilterRelaxation, + FilterRelaxationDim, + AbsenceVerdict, + AbsenceCause, +) + +# Import the 5 MCP output models +from mcp_v2 import SearchOutput, FindOutput, DescribeOutput, NeighborsOutput +from resolve_service import ResolveOutput + + +class TestAbsenceTypesDTOs: + """Tests for absence_types.py data transfer objects.""" + + def test_absence_diagnosis_minimal_construct(self): + """AbsenceDiagnosis constructs with only verdict/cause/message; optional payloads default to None/[].""" + diagnosis = AbsenceDiagnosis( + verdict="refine_query", + cause="identifier_miss", + message="Query too broad" + ) + + assert diagnosis.verdict == "refine_query" + assert diagnosis.cause == "identifier_miss" + assert diagnosis.message == "Query too broad" + assert diagnosis.closest_symbols == [] + assert diagnosis.distances == [] + assert diagnosis.proof is None + assert diagnosis.external_identity is None + assert diagnosis.vocabulary_context is None + assert diagnosis.filter_relaxation is None + + def test_absence_proof_roundtrip(self): + """AbsenceProof round-trips via .model_dump() -> re-parse (pydantic equality).""" + original = AbsenceProof( + nearest_distance=0.92, + symbol_count_scanned=1500, + thresholds_applied={"close": 0.85, "absent_floor": 0.40}, + query_shape="identifier" + ) + + dumped = original.model_dump() + parsed = AbsenceProof(**dumped) + + assert parsed == original + + def test_external_identity_roundtrip(self): + """ExternalIdentity round-trips via .model_dump() -> re-parse.""" + original = ExternalIdentity( + fqn="com.example.external.LibraryClass", + reason="prefix", + source="maven" + ) + + dumped = original.model_dump() + parsed = ExternalIdentity(**dumped) + + assert parsed == original + + def test_vocabulary_context_roundtrip(self): + """VocabularyContext round-trips via .model_dump() -> re-parse.""" + original = VocabularyContext( + top_modules=[("com.example.service", 50), ("com.example.util", 30)], + top_microservices=[("auth-service", 80), ("user-service", 45)], + roles_present=[("Controller", 25), ("Service", 60)], + frequent_name_tokens=["user", "auth", "service"] + ) + + dumped = original.model_dump() + parsed = VocabularyContext(**dumped) + + assert parsed == original + + def test_filter_relaxation_roundtrip(self): + """FilterRelaxation round-trips via .model_dump() -> re-parse.""" + original = FilterRelaxation( + per_dimension=[ + FilterRelaxationDim( + dimension="microservice", + constrained_value="auth-service", + matches_under_relaxation=5, + suggested_value=None + ) + ] + ) + + dumped = original.model_dump() + parsed = FilterRelaxation(**dumped) + + assert parsed == original + + +class TestAbsenceFieldOnOutputModels: + """Tests that the 5 MCP output models accept the optional absence field.""" + + def test_search_output_accepts_absence_field(self): + """SearchOutput accepts absence=None (default) and an AbsenceDiagnosis instance.""" + # Test default (None) + output_default = SearchOutput( + success=True, + message="test", + results=[], + total=0 + ) + assert output_default.absence is None + + # Test with AbsenceDiagnosis + diagnosis = AbsenceDiagnosis( + verdict="not_in_project", + cause="identifier_miss", + message="Symbol not found" + ) + output_with_absence = SearchOutput( + success=True, + message="test", + results=[], + total=0, + absence=diagnosis + ) + assert output_with_absence.absence == diagnosis + assert output_with_absence.model_dump()["absence"] is not None + + def test_find_output_accepts_absence_field(self): + """FindOutput accepts absence=None (default) and an AbsenceDiagnosis instance.""" + # Test default (None) + output_default = FindOutput( + success=True, + message="test", + results=[] + ) + assert output_default.absence is None + + # Test with AbsenceDiagnosis + diagnosis = AbsenceDiagnosis( + verdict="refine_query", + cause="nl_miss", + message="No matches found" + ) + output_with_absence = FindOutput( + success=True, + message="test", + results=[], + absence=diagnosis + ) + assert output_with_absence.absence == diagnosis + assert output_with_absence.model_dump()["absence"] is not None + + def test_describe_output_accepts_absence_field(self): + """DescribeOutput accepts absence=None (default) and an AbsenceDiagnosis instance.""" + # Test default (None) + output_default = DescribeOutput( + success=True, + message="test", + node="test-id" + ) + assert output_default.absence is None + + # Test with AbsenceDiagnosis + diagnosis = AbsenceDiagnosis( + verdict="correct_empty", + cause="meaningful_empty", + message="Empty result is expected" + ) + output_with_absence = DescribeOutput( + success=True, + message="test", + node="test-id", + absence=diagnosis + ) + assert output_with_absence.absence == diagnosis + assert output_with_absence.model_dump()["absence"] is not None + + def test_neighbors_output_accepts_absence_field(self): + """NeighborsOutput accepts absence=None (default) and an AbsenceDiagnosis instance.""" + # Test default (None) + output_default = NeighborsOutput( + success=True, + message="test", + neighbors=[] + ) + assert output_default.absence is None + + # Test with AbsenceDiagnosis + diagnosis = AbsenceDiagnosis( + verdict="external_dependency", + cause="external", + message="External symbol" + ) + output_with_absence = NeighborsOutput( + success=True, + message="test", + neighbors=[], + absence=diagnosis + ) + assert output_with_absence.absence == diagnosis + assert output_with_absence.model_dump()["absence"] is not None + + def test_resolve_output_accepts_absence_field_without_extra_forbid_error(self): + """ResolveOutput.model_dump() with an absence set does not raise under extra='forbid'.""" + # Test default (None) + output_default = ResolveOutput( + success=True, + status="one", + resolved_identifier="Test", + node=None, + candidates=[] + ) + assert output_default.absence is None + + # Test with AbsenceDiagnosis - should not raise due to extra='forbid' + diagnosis = AbsenceDiagnosis( + verdict="refine_query", + cause="filter_miss", + message="Filter too restrictive" + ) + output_with_absence = ResolveOutput( + success=True, + status="none", + resolved_identifier="Test", + node=None, + candidates=[], + absence=diagnosis + ) + assert output_with_absence.absence == diagnosis + + # This should not raise ValidationError due to extra='forbid' + dumped = output_with_absence.model_dump() + assert dumped["absence"] is not None diff --git a/tests/test_config.py b/tests/test_config.py index 8bb33037..51437af7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -703,3 +703,79 @@ def test_write_pointer_creates_index_dir_writes_absolute_path(self, tmp_path): assert written == yaml.resolve() # Atomic write left no .tmp behind. assert not (idx / (CONFIG_SOURCE_FILENAME + ".tmp")).exists() + + +class TestAbsenceConfigKnobs: + """Tests for absence diagnosis configuration knobs.""" + + def test_absence_fields_have_defaults_when_no_config(self, tmp_path, monkeypatch): + """resolve_operator_config returns the 5 absence fields with defaults when no env/YAML is set.""" + monkeypatch.delenv("JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_ABSENCE_ABSENT_FLOOR", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_ABSENCE_CANDIDATE_COUNT", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", raising=False) + monkeypatch.delenv("JAVA_CODEBASE_RAG_ABSENCE_DIAG_ENABLED", raising=False) + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_close_threshold == 0.85 + assert cfg.absence_absent_floor == 0.40 + assert cfg.absence_candidate_count == 5 + assert cfg.absence_ngram_q == 3 + assert cfg.absence_diag_enabled is True + + def test_absence_close_threshold_from_env(self, tmp_path, monkeypatch): + """Setting JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD=0.9 yields absence_close_threshold == 0.9.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD", "0.9") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_close_threshold == 0.9 + + def test_absence_close_threshold_invalid_falls_back_to_default(self, tmp_path, monkeypatch): + """A non-numeric value falls back to the default (0.85) without raising.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_CLOSE_THRESHOLD", "not-a-number") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_close_threshold == 0.85 + + def test_absence_absent_floor_from_env(self, tmp_path, monkeypatch): + """Setting JAVA_CODEBASE_RAG_ABSENCE_ABSENT_FLOOR yields the correct value.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_ABSENT_FLOOR", "0.35") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_absent_floor == 0.35 + + def test_absence_candidate_count_from_env(self, tmp_path, monkeypatch): + """Setting JAVA_CODEBASE_RAG_ABSENCE_CANDIDATE_COUNT yields the correct value.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_CANDIDATE_COUNT", "10") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_candidate_count == 10 + + def test_absence_candidate_count_invalid_falls_back_to_default(self, tmp_path, monkeypatch): + """A non-integer value falls back to the default (5) without raising.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_CANDIDATE_COUNT", "not-an-int") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_candidate_count == 5 + + def test_absence_ngram_q_from_env(self, tmp_path, monkeypatch): + """Setting JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q yields the correct value.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", "4") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_ngram_q == 4 + + def test_absence_diag_enabled_from_env(self, tmp_path, monkeypatch): + """Setting JAVA_CODEBASE_RAG_ABSENCE_DIAG_ENABLED yields the correct value.""" + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_DIAG_ENABLED", "false") + + cfg = resolve_operator_config(source_root=tmp_path) + + assert cfg.absence_diag_enabled is False From 28a4d817a87b478b4b611f158137ca70168ce5b2 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 00:44:12 +0300 Subject: [PATCH 04/17] fix(absence): correct DescribeOutput test field + pyproject module order --- pyproject.toml | 2 +- tests/test_absence_types.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d13de329..ee508e74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,9 +70,9 @@ jrag = "java_codebase_rag.jrag:_console_script_main" [tool.setuptools] packages = ["java_codebase_rag"] py-modules = [ - "absence_diagnosis", "absence_types", "absence_vocab", + "absence_diagnosis", "ast_java", "brownfield_events", "build_ast_graph", diff --git a/tests/test_absence_types.py b/tests/test_absence_types.py index 42934ae9..05291d09 100644 --- a/tests/test_absence_types.py +++ b/tests/test_absence_types.py @@ -163,7 +163,7 @@ def test_describe_output_accepts_absence_field(self): output_default = DescribeOutput( success=True, message="test", - node="test-id" + record=None ) assert output_default.absence is None @@ -176,7 +176,7 @@ def test_describe_output_accepts_absence_field(self): output_with_absence = DescribeOutput( success=True, message="test", - node="test-id", + record=None, absence=diagnosis ) assert output_with_absence.absence == diagnosis From f4fb2530622ca9ece6ec0e3e2f79bf13f9612ee1 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 00:56:19 +0300 Subject: [PATCH 05/17] feat(absence): precomputed vocabulary index asset + build hook - Add VocabularyIndex class with build/save/load/lookup/is_external methods - Build n-gram inverted index from LadybugGraph Symbol nodes - Persist as versioned JSON sidecar (vocab_index.json) - Hook build into write_ladybug after _write_meta, failure-isolated - Add jrag vocab-index subcommand for standalone rebuild - Add get_vocabulary_index() lazy-load helper with caching - Add test_absence_vocab.py with comprehensive coverage Co-Authored-By: Claude --- absence_vocab.py | 415 ++++++++++++++++++++++++++++++++++++ build_ast_graph.py | 42 ++++ java_codebase_rag/jrag.py | 48 +++++ tests/test_absence_vocab.py | 296 +++++++++++++++++++++++++ 4 files changed, 801 insertions(+) create mode 100644 absence_vocab.py create mode 100644 tests/test_absence_vocab.py diff --git a/absence_vocab.py b/absence_vocab.py new file mode 100644 index 00000000..093f89c7 --- /dev/null +++ b/absence_vocab.py @@ -0,0 +1,415 @@ +"""Vocabulary index for absence diagnosis (PR-ABS-1). + +A VocabularyIndex builds a search-optimized projection from a LadybugGraph's +Symbol nodes, persisting as a versioned JSON sidecar. It provides bounded-time +lookup for did-you-mean candidates and external membership checks. + +Consumed by PR-ABS-2 (diagnosis ranking) and PR-ABS-3 (MCP tools). +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + +__all__ = [ + "SymbolRecord", + "VocabularyIndex", + "VocabIndexStale", + "get_vocabulary_index", + "reset_cache", + "VOCAB_INDEX_FILENAME", +] + +VOCAB_INDEX_FILENAME = "vocab_index.json" + + +class VocabIndexStale(Exception): + """Raised when loading a vocab index with stale ontology_version.""" + + pass + + +@dataclass +class SymbolRecord: + """A single symbol record from the graph. + + Attributes: + node_id: Ladybug node ID + fqn: Fully qualified name + simple_name: Simple name (last segment) + normalized_name: Lowercased simple name with signatures stripped + kind: Symbol kind (class, method, field, etc.) + module: Maven module (if available) + microservice: Microservice label (if available) + role: Symbol role (Controller, Service, Repository, etc.) + resolved: Whether the symbol resolved to a source location + """ + node_id: str + fqn: str + simple_name: str + normalized_name: str + kind: str + module: str | None + microservice: str | None + role: str | None + resolved: bool + + +class VocabularyIndex: + """Search-optimized vocabulary index built from LadybugGraph Symbol nodes. + + The index stores a flat list of SymbolRecords and an n-gram inverted index + mapping q-grams to record indexes. This allows bounded-time lookup for + did-you-mean candidates without scanning the entire vocabulary. + + Built at the end of graph build; persisted as a sidecar JSON; lazily rebuilt + if missing or stale (ontology_version mismatch). + """ + + def __init__( + self, + records: list[SymbolRecord], + ngram_index: dict[str, list[int]], + q: int, + ) -> None: + self.records = records + self.ngram_index = ngram_index + self.q = q + + @property + def symbol_count(self) -> int: + return len(self.records) + + @classmethod + def build(cls, graph: Any, *, q: int) -> "VocabularyIndex": + """Build a vocabulary index from a LadybugGraph. + + Enumerates all Symbol nodes, builds SymbolRecords with normalized names, + and constructs a q-gram inverted index for candidate lookup. + + Args: + graph: LadybugGraph instance + q: N-gram length (typically 3) + + Returns: + VocabularyIndex ready for queries + """ + # Query all Symbol nodes with proper column aliases + query = """ + MATCH (s:Symbol) + RETURN s.id AS id, s.kind AS kind, s.name AS name, s.fqn AS fqn, + s.package AS package, s.module AS module, s.microservice AS microservice, + s.filename AS filename, s.start_line AS start_line, s.end_line AS end_line, + s.start_byte AS start_byte, s.end_byte AS end_byte, s.modifiers AS modifiers, + s.annotations AS annotations, s.capabilities AS capabilities, s.role AS role, + s.signature AS signature, s.parent_id AS parent_id, s.resolved AS resolved + """ + rows = graph._rows(query, {}) + + records: list[SymbolRecord] = [] + for row in rows: + record = _row_to_symbol_record(row) + records.append(record) + + # Build n-gram index from normalized names + ngram_index: dict[str, list[int]] = {} + for idx, record in enumerate(records): + grams = _qgrams(record.normalized_name, q) + for gram in grams: + if gram not in ngram_index: + ngram_index[gram] = [] + ngram_index[gram].append(idx) + + return cls(records=records, ngram_index=ngram_index, q=q) + + def save(self, path: Path, *, ontology_version: int) -> None: + """Save the vocabulary index to a JSON sidecar. + + Args: + path: Destination path for the sidecar + ontology_version: Current graph ontology version (for staleness detection) + """ + import time + + data = { + "format_version": 1, + "ontology_version": ontology_version, + "built_at": int(time.time()), + "symbol_count": self.symbol_count, + "q": self.q, + "records": [ + { + "node_id": r.node_id, + "fqn": r.fqn, + "simple_name": r.simple_name, + "normalized_name": r.normalized_name, + "kind": r.kind, + "module": r.module, + "microservice": r.microservice, + "role": r.role, + "resolved": r.resolved, + } + for r in self.records + ], + "ngrams": self.ngram_index, + } + + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f) + + log.debug(f"VocabularyIndex saved to {path} ({self.symbol_count} symbols)") + + @classmethod + def load(cls, path: Path) -> "VocabularyIndex": + """Load a vocabulary index from a JSON sidecar. + + Args: + path: Path to the sidecar file + + Returns: + VocabularyIndex + + Raises: + VocabIndexStale: If sidecar ontology_version doesn't match expected + """ + from ast_java import ONTOLOGY_VERSION + + with open(path) as f: + data = json.load(f) + + # Check ontology version + if data.get("ontology_version") != ONTOLOGY_VERSION: + raise VocabIndexStale( + f"Vocab index ontology version {data.get('ontology_version')} " + f"does not match expected {ONTOLOGY_VERSION}" + ) + + records = [ + SymbolRecord( + node_id=r["node_id"], + fqn=r["fqn"], + simple_name=r["simple_name"], + normalized_name=r["normalized_name"], + kind=r["kind"], + module=r.get("module"), + microservice=r.get("microservice"), + role=r.get("role"), + resolved=r["resolved"], + ) + for r in data["records"] + ] + + return cls( + records=records, + ngram_index=data["ngrams"], + q=data["q"], + ) + + def lookup(self, name: str, *, limit: int) -> list[SymbolRecord]: + """Lookup candidate records by name using n-gram overlap. + + This returns candidate records ONLY; ranking by similarity is done + in PR-ABS-2 (absence_diagnosis module) to avoid circular imports. + + Args: + name: Query name (can be typoed) + limit: Maximum number of candidates to return + + Returns: + List of candidate SymbolRecord (up to limit), ordered by n-gram overlap count + """ + # First, check for exact match on simple_name (fast path) + for record in self.records: + if record.simple_name == name: + log.debug(f"lookup({name}): exact match found") + return [record] + + # No exact match, use n-gram overlap + # Normalize query name + normalized = _normalize_name(name) + + # Extract q-grams from query + grams = _qgrams(normalized, self.q) + + # Count n-gram matches per record index + match_counts: dict[int, int] = {} + for gram in grams: + if gram in self.ngram_index: + for idx in self.ngram_index[gram]: + match_counts[idx] = match_counts.get(idx, 0) + 1 + + # Sort by match count (descending) to get candidates with most overlap first + sorted_idxs = sorted(match_counts.keys(), key=lambda idx: match_counts[idx], reverse=True) + + # Debug logging + log.debug(f"lookup({name}): normalized={normalized}, grams={grams[:5]}, candidates={len(sorted_idxs)}") + + # Return top candidates + candidates = [self.records[idx] for idx in sorted_idxs[:limit]] + return candidates + + def is_external(self, name: str) -> tuple[bool, str | None]: + """Check if a name refers to an external symbol. + + Returns (is_external, reason) where reason is one of: + - "prefix": FQN matches an external library prefix (java.*, javax.*, etc.) + - "phantom": Symbol exists in graph but is unresolved (phantom) + - None: Symbol is a real project symbol + + Args: + name: Simple name or FQN to check + + Returns: + (is_external, reason) tuple + """ + from ladybug_queries import _is_external_fqn, _EXTERNAL_PREFIXES + + # First, check if it's an external prefix (highest priority) + if _is_external_fqn(name): + return (True, "prefix") + + # Also check simple name against external prefixes + for prefix in _EXTERNAL_PREFIXES: + if name.startswith(prefix): + return (True, "prefix") + + # Check if name matches any record in our vocabulary + matching_record = None + for record in self.records: + if record.simple_name == name or record.fqn == name: + matching_record = record + break + + if matching_record: + # If the symbol is unresolved, it's a phantom + if not matching_record.resolved: + return (True, "phantom") + # Otherwise it's a real project symbol + return (False, None) + + # Not found and doesn't look external + return (False, None) + + +# Module-level cache for get_vocabulary_index +_vocab_cache: dict[str, VocabularyIndex] = {} + + +def get_vocabulary_index(graph: Any, cfg: Any) -> VocabularyIndex: + """Get or build a vocabulary index for the given graph. + + This is the primary entry point for the diagnosis layer (PR-ABS-2) and + tools (PR-ABS-3). It implements lazy backfill: tries to load from sidecar, + builds from graph on miss/stale, and caches the result. + + Args: + graph: LadybugGraph instance + cfg: ResolvedOperatorConfig instance + + Returns: + VocabularyIndex (cached or newly built) + """ + from ast_java import ONTOLOGY_VERSION + + # Determine graph db path for cache key + db_path = graph.db_path if hasattr(graph, 'db_path') else str(cfg.ladybug_path) + sidecar_path = Path(db_path).parent / VOCAB_INDEX_FILENAME + + # Check cache + if db_path in _vocab_cache: + return _vocab_cache[db_path] + + # Try loading from sidecar + try: + index = VocabularyIndex.load(sidecar_path) + _vocab_cache[db_path] = index + log.debug(f"Loaded vocabulary index from {sidecar_path}") + return index + except (VocabIndexStale, FileNotFoundError, Exception) as e: + # Stale, missing, or corrupt - rebuild from graph + log.debug(f"Vocab index missing/stale ({e}), rebuilding from graph") + + # Build from graph + index = VocabularyIndex.build(graph, q=cfg.absence_ngram_q) + + # Save to sidecar (best-effort) + try: + index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + except Exception as save_err: + log.warning(f"Failed to save vocab index to {sidecar_path}: {save_err}") + + # Cache and return + _vocab_cache[db_path] = index + return index + + +def reset_cache() -> None: + """Reset the module-level vocabulary index cache. + + Exposed for tests that need to simulate a fresh start or different graph paths. + """ + global _vocab_cache + _vocab_cache = {} + + +# ---- Helper functions ---- + + +def _row_to_symbol_record(row: dict[str, Any]) -> SymbolRecord: + """Convert a Ladybug row to a SymbolRecord.""" + from ladybug_queries import _type_part_fqn + + fqn = row.get("fqn") or "" + name = row.get("name") or "" + + return SymbolRecord( + node_id=row.get("id") or "", + fqn=fqn, + simple_name=name, + normalized_name=_normalize_name(name), + kind=row.get("kind") or "", + module=row.get("module"), + microservice=row.get("microservice"), + role=row.get("role"), + resolved=bool(row.get("resolved", True)), + ) + + +def _normalize_name(name: str) -> str: + """Normalize a symbol name for n-gram indexing. + + Strips: + - Generic signatures (e.g., List → List) + - Method signatures (e.g., method(Param) → method) + - Parentheses and angle brackets + + Returns lowercase result. + """ + # Remove generic signatures + normalized = name.split("<")[0] + # Remove method signatures + normalized = normalized.split("(")[0] + # Remove hash suffix (e.g., method#signature) + normalized = normalized.split("#")[0] + return normalized.lower() + + +def _qgrams(text: str, q: int) -> list[str]: + """Extract q-grams from text. + + Args: + text: Input string + q: Gram length + + Returns: + List of q-grams (substrings of length q) + """ + if len(text) < q: + return [text] if text else [] + return [text[i:i + q] for i in range(len(text) - q + 1)] diff --git a/build_ast_graph.py b/build_ast_graph.py index 4576f659..04920cde 100644 --- a/build_ast_graph.py +++ b/build_ast_graph.py @@ -4208,6 +4208,48 @@ def write_ladybug( _write_meta(conn, tables, source_root) conn.close() db.close() + + # Build vocabulary index (best-effort, failure doesn't fail the graph build) + _try_build_vocabulary_index(db_path, source_root, verbose) + + +def _try_build_vocabulary_index(db_path: Path, source_root: Path, verbose: bool) -> None: + """Build and save the vocabulary index as a sidecar (best-effort). + + This is called after write_ladybug() completes. A build failure must not + fail the graph build, so this is wrapped in try/except and logged. + + Args: + db_path: Path to the LadybugDB database file + source_root: Source repository root + verbose: Whether to emit verbose progress + """ + try: + from absence_vocab import VocabularyIndex, VOCAB_INDEX_FILENAME + from ladybug_queries import LadybugGraph + + t0 = time.time() + if verbose: + _verbose_stderr_line("[vocab] building vocabulary index") + + # Open graph for reading + graph = LadybugGraph.get(str(db_path)) + + # Build index with default q=3 (config not available in this subprocess) + index = VocabularyIndex.build(graph, q=3) + + # Save to sidecar next to the graph db + sidecar_path = Path(db_path).parent / VOCAB_INDEX_FILENAME + index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + + if verbose: + _verbose_stderr_line(f"[vocab] index built with {index.symbol_count} symbols in {time.time() - t0:.2f}s") + + except Exception as e: + # Log but don't fail - graph build is the primary concern + log.warning(f"Vocabulary index build failed (non-critical): {e}") + if verbose: + _verbose_stderr_line(f"[vocab] build failed (graph still written): {e}") _init_hash_tracker(source_root, db_path) diff --git a/java_codebase_rag/jrag.py b/java_codebase_rag/jrag.py index e5e05e88..ceba0aa9 100644 --- a/java_codebase_rag/jrag.py +++ b/java_codebase_rag/jrag.py @@ -1163,6 +1163,20 @@ def _core_parser() -> argparse.ArgumentParser: ) search.set_defaults(handler=_cmd_search, auto_scope=True) + # ---- vocab-index subparser (PR-ABS-1) ---- + vocab_index = subparsers.add_parser( + "vocab-index", + help="Rebuild the vocabulary index (absence diagnosis).", + parents=[_core_parser()], + description=( + "Rebuild the vocabulary index sidecar from the current Ladybug graph. " + "The index is a search-optimized projection of Symbol nodes used for " + "did-you-mean suggestions and external membership checks in absence " + "diagnosis. Printed on success: symbol count and sidecar path." + ), + ) + vocab_index.set_defaults(handler=_cmd_vocab_index, detail="full") + return parser @@ -1217,6 +1231,40 @@ def _load_graph(cfg): # type: ignore[no-untyped-def] raise _IndexStale(str(exc)) from exc +def _cmd_vocab_index(args: argparse.Namespace) -> int: + """Rebuild the vocabulary index sidecar from the Ladybug graph.""" + from ast_java import ONTOLOGY_VERSION + from absence_vocab import VocabularyIndex, VOCAB_INDEX_FILENAME + + cfg = _resolve_cfg(args) + try: + graph = _load_graph(cfg) + except (_IndexNotFound, _IndexStale) as exc: + print(f"[error] {exc}", file=sys.stderr) + return 2 + + # Build vocabulary index + try: + index = VocabularyIndex.build(graph, q=cfg.absence_ngram_q) + except Exception as e: + print(f"[error] Vocabulary index build failed: {e}", file=sys.stderr) + return 1 + + # Save to sidecar + sidecar_path = cfg.ladybug_path.parent / VOCAB_INDEX_FILENAME + try: + index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + except Exception as e: + print(f"[error] Failed to save vocabulary index: {e}", file=sys.stderr) + return 1 + + # Print success message (simple format for admin command) + print(f"Vocabulary index rebuilt successfully:") + print(f" Symbol count: {index.symbol_count}") + print(f" Sidecar path: {sidecar_path}") + return 0 + + def _cmd_status(args: argparse.Namespace) -> int: from java_codebase_rag.jrag_envelope import Envelope from java_codebase_rag.jrag_render import render diff --git a/tests/test_absence_vocab.py b/tests/test_absence_vocab.py new file mode 100644 index 00000000..57e09f7b --- /dev/null +++ b/tests/test_absence_vocab.py @@ -0,0 +1,296 @@ +"""Tests for absence_vocab.py VocabularyIndex and lazy-load helper.""" + +import json +from pathlib import Path +from unittest import mock + +import pytest + +# These imports will fail until absence_vocab.py is created +from absence_vocab import ( + SymbolRecord, + VocabularyIndex, + VocabIndexStale, + get_vocabulary_index, + reset_cache, + VOCAB_INDEX_FILENAME, +) + + +class TestVocabularyIndexBuild: + """Tests for VocabularyIndex.build() from a LadybugGraph.""" + + def test_build_returns_index_with_symbol_count(self, ladybug_graph): + """VocabularyIndex.build(graph, q=3) returns an index whose symbol_count >= 1.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + assert index.symbol_count >= 1 + # Should be at least the number of class symbols in bank-chat-system + assert index.symbol_count >= 20 # bank-chat has at least 20 classes + + def test_build_records_contain_expected_fields(self, ladybug_graph): + """Built index records contain all SymbolRecord fields.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + assert len(index.records) > 0 + + record = index.records[0] + assert isinstance(record, SymbolRecord) + assert hasattr(record, "node_id") + assert hasattr(record, "fqn") + assert hasattr(record, "simple_name") + assert hasattr(record, "normalized_name") + assert hasattr(record, "kind") + assert hasattr(record, "module") + assert hasattr(record, "microservice") + assert hasattr(record, "role") + assert hasattr(record, "resolved") + + def test_build_ngram_index_structure(self, ladybug_graph): + """Built index has a valid ngram index mapping grams to record lists.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + assert isinstance(index.ngram_index, dict) + assert len(index.ngram_index) > 0 + + # Each gram should map to a list of integers (record indexes) + for gram, record_idxs in list(index.ngram_index.items())[:10]: # Check first 10 + assert isinstance(gram, str) + assert len(gram) <= 3 # q=3, but can be shorter for short names + assert isinstance(record_idxs, list) + assert all(isinstance(idx, int) for idx in record_idxs) + + +class TestVocabularyIndexPersistence: + """Tests for VocabularyIndex.save() and load() round-trip.""" + + def test_save_creates_json_sidecar(self, ladybug_graph, tmp_path): + """save() creates a JSON sidecar with expected structure.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + sidecar_path = tmp_path / VOCAB_INDEX_FILENAME + + from ast_java import ONTOLOGY_VERSION + index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + + assert sidecar_path.exists() + with open(sidecar_path) as f: + data = json.load(f) + + assert "format_version" in data + assert "ontology_version" in data + assert "built_at" in data + assert "symbol_count" in data + assert "q" in data + assert "records" in data + assert "ngrams" in data + + def test_save_load_roundtrip(self, ladybug_graph, tmp_path): + """save() then load() round-trips: symbol_count equal; a known symbol present.""" + from ast_java import ONTOLOGY_VERSION + + original = VocabularyIndex.build(ladybug_graph, q=3) + sidecar_path = tmp_path / VOCAB_INDEX_FILENAME + original.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + + loaded = VocabularyIndex.load(sidecar_path) + + assert loaded.symbol_count == original.symbol_count + assert len(loaded.records) == len(original.records) + + # Find a known symbol from bank-chat-system (ChatAssignApplication exists in the corpus) + found_names = {r.simple_name for r in loaded.records} + # At least one expected symbol should be present + assert any(name in found_names for name in ["ChatAssignApplication", "ChatManagementController", "OperatorManagementController"]) + + def test_load_stale_ontology_version_raises(self, ladybug_graph, tmp_path): + """load() on a sidecar with stale ontology_version raises VocabIndexStale.""" + from ast_java import ONTOLOGY_VERSION + + index = VocabularyIndex.build(ladybug_graph, q=3) + sidecar_path = tmp_path / VOCAB_INDEX_FILENAME + index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION) + + # Manually corrupt the ontology_version in the sidecar + with open(sidecar_path) as f: + data = json.load(f) + data["ontology_version"] = 999 # Wrong version + with open(sidecar_path, "w") as f: + json.dump(data, f) + + with pytest.raises(VocabIndexStale): + VocabularyIndex.load(sidecar_path) + + +class TestVocabularyIndexLookup: + """Tests for VocabularyIndex.lookup() candidate retrieval.""" + + def test_lookup_exact_name_returns_record(self, ladybug_graph): + """lookup("ChatAssignApplication", limit=5) returns that record among candidates.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + candidates = index.lookup("ChatAssignApplication", limit=5) + + assert len(candidates) > 0 + # Should find ChatAssignApplication + assert any(r.simple_name == "ChatAssignApplication" for r in candidates) + + def test_lookup_typo_name_returns_closest_match(self, ladybug_graph): + """lookup("ChatAssignApp") for typoed name still returns ChatAssignApplication among candidates.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + candidates = index.lookup("ChatAssignApp", limit=50) + + assert len(candidates) > 0 + # Should still find ChatAssignApplication via n-gram overlap + assert any(r.simple_name == "ChatAssignApplication" for r in candidates) + + def test_lookup_respects_limit(self, ladybug_graph): + """lookup() with limit=2 returns at most 2 candidates.""" + index = VocabularyIndex.build(ladybug_graph, q=3) + candidates = index.lookup("Service", limit=2) + + assert len(candidates) <= 2 + + +class TestVocabularyIndexIsExternal: + """Tests for VocabularyIndex.is_external().""" + + def test_is_external_java_util_list_returns_prefix(self, ladybug_graph): + """is_external("java.util.List") → (True, "prefix").""" + index = VocabularyIndex.build(ladybug_graph, q=3) + is_ext, reason = index.is_external("java.util.List") + + assert is_ext is True + assert reason == "prefix" + + def test_is_external_phantom_symbol_returns_phantom(self, ladybug_graph): + """is_external() on a phantom present in the corpus → (True, "phantom").""" + index = VocabularyIndex.build(ladybug_graph, q=3) + # Find a phantom symbol (resolved=False) if any exist in bank-chat + phantom = next((r for r in index.records if not r.resolved), None) + if phantom: + is_ext, reason = index.is_external(phantom.simple_name) + assert is_ext is True + assert reason == "phantom" + + def test_is_external_project_symbol_returns_false(self, ladybug_graph): + """is_external() on a real project symbol → (False, None).""" + index = VocabularyIndex.build(ladybug_graph, q=3) + # ChatAssignApplication is a real project symbol + is_ext, reason = index.is_external("ChatAssignApplication") + + assert is_ext is False + assert reason is None + + +class TestGetVocabularyIndex: + """Tests for get_vocabulary_index() lazy-load helper.""" + + def test_first_call_builds_and_saves(self, ladybug_graph, tmp_path, monkeypatch): + """First call with no sidecar builds + saves the index.""" + from ast_java import ONTOLOGY_VERSION + from java_codebase_rag.config import resolve_operator_config + import os + + # Use tmp_path as the index dir via environment variable + monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", str(tmp_path)) + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(Path.cwd())) + + cfg = resolve_operator_config(source_root=Path.cwd()) + + # Sidecar doesn't exist yet (check at ladybug_graph.db_path location) + sidecar_path = Path(ladybug_graph.db_path).parent / VOCAB_INDEX_FILENAME + # Delete sidecar if it exists from previous test runs + if sidecar_path.exists(): + sidecar_path.unlink() + + index = get_vocabulary_index(ladybug_graph, cfg) + + assert index.symbol_count > 0 + assert sidecar_path.exists() # Should have been saved + + def test_second_call_loads_from_cache(self, ladybug_graph, tmp_path, monkeypatch): + """Second call loads from file (build runs once).""" + from java_codebase_rag.config import resolve_operator_config + from unittest.mock import patch + + # Use tmp_path as the index dir + monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", str(tmp_path)) + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(Path.cwd())) + + cfg = resolve_operator_config(source_root=Path.cwd()) + + # First call - builds + get_vocabulary_index(ladybug_graph, cfg) + + # Spy on VocabularyIndex.build + with patch.object(VocabularyIndex, 'build') as mock_build: + # Second call - should load, not build + get_vocabulary_index(ladybug_graph, cfg) + mock_build.assert_not_called() + + def test_get_vocabulary_index_cache_can_be_reset(self, ladybug_graph, tmp_path, monkeypatch): + """Cache can be reset via reset_cache() for test isolation.""" + from java_codebase_rag.config import resolve_operator_config + + # Use tmp_path as the index dir + monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", str(tmp_path)) + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", str(Path.cwd())) + + cfg = resolve_operator_config(source_root=Path.cwd()) + + # First call - builds and caches + index1 = get_vocabulary_index(ladybug_graph, cfg) + assert index1.symbol_count > 0 + + # Verify it's cached by calling again (should return same instance) + index2 = get_vocabulary_index(ladybug_graph, cfg) + assert index1 is index2 # Same instance from cache + + # Reset cache + reset_cache() + + # After reset, new call returns a new instance (loaded from sidecar) + index3 = get_vocabulary_index(ladybug_graph, cfg) + assert index3 is not index1 # Different instance after cache reset + assert index3.symbol_count == index1.symbol_count # Same data though + + +class TestBuildFailureResilience: + """Tests that build failures don't break the graph build.""" + + def test_write_ladybug_succeeds_when_vocab_build_fails(self, corpus_root, tmp_path): + """Monkeypatch build to raise → write_ladybug still succeeds (graph written; sidecar absent).""" + from build_ast_graph import write_ladybug, GraphTables + + db_path = tmp_path / "code_graph.lbug" + tables = GraphTables() # Empty tables for this test + + # Mock VocabularyIndex.build to raise + with mock.patch("absence_vocab.VocabularyIndex.build", side_effect=RuntimeError("Build failed")): + # write_ladybug should not raise + write_ladybug( + db_path=db_path, + tables=tables, + source_root=corpus_root, + verbose=False, + ) + + # Graph should still be written + assert db_path.exists() + + # Sidecar should not exist (build failed) + sidecar_path = tmp_path / VOCAB_INDEX_FILENAME + assert not sidecar_path.exists() + + def test_try_build_vocabulary_index_failure_is_logged(self, corpus_root, tmp_path, caplog): + """_try_build_vocabulary_index logs on failure but doesn't raise.""" + from build_ast_graph import _try_build_vocabulary_index + + db_path = tmp_path / "code_graph.lbug" + # Create a dummy db file for testing + db_path.parent.mkdir(parents=True, exist_ok=True) + db_path.touch() + + # Mock VocabularyIndex.build to raise + with mock.patch("absence_vocab.VocabularyIndex.build", side_effect=RuntimeError("Build failed")): + # Should not raise + _try_build_vocabulary_index(db_path, corpus_root, verbose=False) + + # Should have logged a warning + assert any("Vocabulary index build failed" in record.message for record in caplog.records) From 22ad723214921e3c513d3d6562ffdc4a341babdd Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:01:43 +0300 Subject: [PATCH 06/17] fix(absence): honor configured absence_ngram_q in the build subprocess --- build_ast_graph.py | 10 ++++-- java_codebase_rag/config.py | 4 +++ tests/test_absence_vocab.py | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/build_ast_graph.py b/build_ast_graph.py index 04920cde..63c3993e 100644 --- a/build_ast_graph.py +++ b/build_ast_graph.py @@ -4235,8 +4235,14 @@ def _try_build_vocabulary_index(db_path: Path, source_root: Path, verbose: bool) # Open graph for reading graph = LadybugGraph.get(str(db_path)) - # Build index with default q=3 (config not available in this subprocess) - index = VocabularyIndex.build(graph, q=3) + # Read q from env var set by ResolvedOperatorConfig.subprocess_env() + raw_q = os.environ.get("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", "3").strip() + try: + q = int(raw_q) if raw_q else 3 + except ValueError: + q = 3 # Invalid env value falls back to default + # Build index with configured q (or default 3) + index = VocabularyIndex.build(graph, q=q) # Save to sidecar next to the graph db sidecar_path = Path(db_path).parent / VOCAB_INDEX_FILENAME diff --git a/java_codebase_rag/config.py b/java_codebase_rag/config.py index b9e07766..eba44534 100644 --- a/java_codebase_rag/config.py +++ b/java_codebase_rag/config.py @@ -391,6 +391,8 @@ def apply_to_os_environ(self) -> None: os.environ["SBERT_MODEL"] = self.embedding_model if self.embedding_device is not None: os.environ["SBERT_DEVICE"] = self.embedding_device + # Publish absence diagnosis knobs for subprocess builds (PR-ABS-1) + os.environ["JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q"] = str(self.absence_ngram_q) def subprocess_env(self, base: dict[str, str] | None = None) -> dict[str, str]: out = dict(base or os.environ) @@ -401,6 +403,8 @@ def subprocess_env(self, base: dict[str, str] | None = None) -> dict[str, str]: out["SBERT_DEVICE"] = self.embedding_device else: out.pop("SBERT_DEVICE", None) + # Publish absence diagnosis knobs for subprocess builds (PR-ABS-1) + out["JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q"] = str(self.absence_ngram_q) return out diff --git a/tests/test_absence_vocab.py b/tests/test_absence_vocab.py index 57e09f7b..57664cbb 100644 --- a/tests/test_absence_vocab.py +++ b/tests/test_absence_vocab.py @@ -294,3 +294,69 @@ def test_try_build_vocabulary_index_failure_is_logged(self, corpus_root, tmp_pat # Should have logged a warning assert any("Vocabulary index build failed" in record.message for record in caplog.records) + + +class TestEnvVarWiring: + """Tests that the build hook reads the env var and config publishes it (PR-ABS-1 fix).""" + + def test_build_hook_reads_env_var(self, ladybug_db_path, corpus_root, monkeypatch): + """_try_build_vocabulary_index reads JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q from env.""" + from build_ast_graph import _try_build_vocabulary_index + + # Set env var to q=2 + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", "2") + + # Build the vocab index (uses Path objects, not strings) + _try_build_vocabulary_index(ladybug_db_path, corpus_root, verbose=False) + + # Load the saved index and verify q=2 + sidecar_path = ladybug_db_path.parent / VOCAB_INDEX_FILENAME + assert sidecar_path.exists(), "Sidecar should be created" + + loaded = VocabularyIndex.load(sidecar_path) + assert loaded.q == 2, f"Expected q=2, got q={loaded.q}" + + # Clean up + sidecar_path.unlink() + + def test_build_hook_default_q_when_env_var_invalid(self, ladybug_db_path, corpus_root, monkeypatch): + """Build hook falls back to q=3 when env var is invalid.""" + from build_ast_graph import _try_build_vocabulary_index + + # Set env var to invalid value + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", "not_a_number") + + # Build the vocab index + _try_build_vocabulary_index(ladybug_db_path, corpus_root, verbose=False) + + # Load the saved index and verify default q=3 + sidecar_path = ladybug_db_path.parent / VOCAB_INDEX_FILENAME + assert sidecar_path.exists(), "Sidecar should be created" + + loaded = VocabularyIndex.load(sidecar_path) + assert loaded.q == 3, f"Expected default q=3, got q={loaded.q}" + + # Clean up + sidecar_path.unlink() + + def test_config_publishes_absence_ngram_q(self, monkeypatch): + """ResolvedOperatorConfig.subprocess_env() includes JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q.""" + from java_codebase_rag.config import resolve_operator_config + + # Set up a config with absence_ngram_q=5 via env + monkeypatch.setenv("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q", "5") + monkeypatch.setenv("JAVA_CODEBASE_RAG_INDEX_DIR", "/tmp/test_index") + monkeypatch.setenv("JAVA_CODEBASE_RAG_SOURCE_ROOT", "/tmp/test_root") + + cfg = resolve_operator_config(source_root=Path("/tmp/test_root")) + assert cfg.absence_ngram_q == 5 + + # Verify subprocess_env() publishes it + env = cfg.subprocess_env() + assert "JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q" in env + assert env["JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q"] == "5" + + # Verify apply_to_os_environ() publishes it + cfg.apply_to_os_environ() + import os + assert os.environ.get("JAVA_CODEBASE_RAG_ABSENCE_NGRAM_Q") == "5" From 6e8b192fd46c595ee3820bd2fffdfb2b33bfca7f Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:20:03 +0300 Subject: [PATCH 07/17] =?UTF-8?q?feat(absence):=20diagnosis=20module=20?= =?UTF-8?q?=E2=80=94=20classifier=20+=20per-cause=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-ABS-2: the stateless diagnose(...) classifier (Layer 2, the feature's core). Classifies an empty MCP result by cause and emits cause-specific help; pure function of its inputs incl. the VocabularyIndex. - absence_diagnosis.py: diagnose() with the exact kw-only signature; decision procedure: external-wins → neighbors → describe-by-node_id → find → search/resolve. Conservative two-band threshold policy defaults the middle band to refine_query; not_in_project only when best similarity < absence_absent_floor AND identifier-shaped. - did-you-mean uses difflib.SequenceMatcher (stdlib) on normalized_name (distance = 1 - similarity); no new deps (resolution 1). - False-absent guard: _exact_symbol_exists override forces refine_query when the query resolves to a real symbol — covers the FQN gap where vocab.lookup's simple_name match leaves a long FQN below the floor. closest_symbols/distances always populated regardless of verdict. - Filter relaxation ports _zero_result_guidance's tally-and-suggest logic into the structured FilterRelaxation payload (graph-backed, no argparse/mcp_v2 coupling). - Master toggle + exception guard: any failure degrades to a minimal refine_query (or None); diagnosis never fails the tool. - tests/test_absence_diagnosis.py: full unit matrix (16 tests) incl. the false-absent guard (middle-band + explicit exact + FQN), external prefix/phantom, neighbors correct_empty/refine_query, nl_miss, filter_miss (identifier + broad), master toggle, exception guard. Co-Authored-By: Claude --- absence_diagnosis.py | 663 ++++++++++++++++++++++++++++++++ tests/test_absence_diagnosis.py | 430 +++++++++++++++++++++ 2 files changed, 1093 insertions(+) create mode 100644 absence_diagnosis.py create mode 100644 tests/test_absence_diagnosis.py diff --git a/absence_diagnosis.py b/absence_diagnosis.py new file mode 100644 index 00000000..73c43f13 --- /dev/null +++ b/absence_diagnosis.py @@ -0,0 +1,663 @@ +"""Stateless absence diagnosis (PR-ABS-2) — the feature's core. + +``diagnose(...)`` is the single place empty-MCP-result logic lives. It classifies +an empty exploration result by cause and emits cause-specific help. Pure function +of its inputs (incl. the :class:`VocabularyIndex`); no I/O, no mutation. Consumed +by PR-ABS-3 (MCP wiring) and PR-ABS-4 (CLI). + +Similarity metric +----------------- +Identifier did-you-mean uses ``difflib.SequenceMatcher(None, a, b).ratio()`` +(stdlib, ∈ [0,1]) on the query's normalized name vs each candidate's +``normalized_name``; ``distance = 1.0 - similarity``. ``difflib`` is stdlib and +adequate for identifier typo/misremember detection — Jaro-Winkler would be +marginally better but is not stdlib and not worth a dependency. See the task +brief's resolution 1. + +Conservative absence +-------------------- +False-absent (declaring a real symbol absent) is the catastrophic failure mode. +The two-band threshold policy defaults the middle band to ``refine_query`` and +commits to ``not_in_project`` only when best similarity < ``absence_absent_floor`` +AND the query is identifier-shaped. ``closest_symbols``/``distances`` are ALWAYS +populated regardless of verdict. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from difflib import SequenceMatcher +from typing import Any, Literal + +from absence_types import ( + AbsenceDiagnosis, + AbsenceProof, + ExternalIdentity, + FilterRelaxation, + FilterRelaxationDim, + VocabularyContext, +) +from absence_vocab import SymbolRecord, VocabularyIndex, _normalize_name +from graph_types import NodeRef +from mcp_hints import _IDENTIFIER_FILTER_FIELDS, _find_has_identifier_shaped_filter + +log = logging.getLogger(__name__) + +__all__ = ["diagnose"] + +# Dimensions whose relaxation _filter_relaxation can probe on the graph. These +# mirror the single-dim filters handled by ``_zero_result_guidance`` (jrag.py). +_RELAXABLE_DIMS: tuple[str, ...] = ("role", "microservice", "module") + +# Node-id prefixes (graph_types._node_kind_from_id). Used to tell a describe +# node_id miss apart from an FQN lookup. +_NODE_ID_PREFIXES: tuple[str, ...] = ( + "ucs:", "sym:", "route:", "r:", "client:", "c:", "producer:", "p:", +) + +# Small English stopword set; a single stopword is treated as NL, not identifier. +_STOPWORDS: frozenset[str] = frozenset({ + "the", "a", "an", "and", "or", "of", "in", "to", "for", "with", "on", "at", + "by", "is", "are", "be", "was", "were", "how", "does", "do", "what", "where", + "which", "who", "why", "when", "find", "show", "get", "list", "all", "any", + "this", "that", "these", "those", "from", "into", "use", "using", "used", +}) + + +# --------------------------------------------------------------------------- # +# Public entry point # +# --------------------------------------------------------------------------- # + + +def diagnose( + *, + tool: Literal["search", "find", "neighbors", "describe", "resolve"], + query: str | None, + filt: dict | None, # find's model_dump'd filter + filter_kind: str | None, # find's kind, for identifier-shape test + root_node: NodeRef | None, # neighbors/describe subject + scope: dict[str, str], # {"microservice":..,"module":..} + vocab: VocabularyIndex, + graph: Any, # LadybugGraph + cfg: Any, # ResolvedOperatorConfig (thresholds) +) -> AbsenceDiagnosis | None: + """Classify an empty result and emit cause-specific help. + + Returns ``None`` when the master toggle is off or on unrecoverable error. + Never raises: any exception is logged and degrades to a minimal + ``refine_query`` (or ``None`` if even that cannot be built). + """ + try: + if not getattr(cfg, "absence_diag_enabled", True): + return None + return _diagnose_inner( + tool=tool, + query=query, + filt=filt, + filter_kind=filter_kind, + root_node=root_node, + scope=scope, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + except Exception: # noqa: BLE001 — diagnosis must never fail the tool + log.exception("absence diagnosis failed; degrading to refine_query") + return _fallback_refine() + + +# --------------------------------------------------------------------------- # +# Decision procedure # +# --------------------------------------------------------------------------- # + + +def _diagnose_inner( + *, + tool: str, + query: str | None, + filt: dict | None, + filter_kind: str | None, + root_node: NodeRef | None, + scope: dict[str, str], + vocab: VocabularyIndex, + graph: Any, + cfg: Any, +) -> AbsenceDiagnosis | None: + # --- External-wins: emit external_dependency first for any external target. + ext = _detect_external(query, filt, filter_kind, root_node, vocab) + if ext is not None: + return AbsenceDiagnosis( + verdict="external_dependency", + cause="external", + message=( + f"`{ext.fqn}` is referenced by this project but not defined in it " + f"({ext.reason}). It is an external dependency." + ), + external_identity=ext, + ) + + # --- neighbors/describe subject present. + if root_node is not None: + return _diagnose_neighbors(root_node, graph) + + # --- describe by node_id (not fqn): an unknown id, not a misspelled name. + if tool == "describe" and query and _looks_like_node_id(query): + return AbsenceDiagnosis( + verdict="refine_query", + cause="identifier_miss", + message=( + f"No node with id `{query}`. Run `resolve` to map a name/FQN to an id, " + "or `search` to discover symbols." + ), + ) + + # --- find (filter) path. + if filt is not None and filter_kind is not None: + return _diagnose_find(filt, filter_kind, scope, vocab, graph, cfg) + + # --- search/resolve/describe-by-fqn (query) path. + if query: + return _diagnose_query(query, vocab, graph, cfg) + + # Nothing to classify on (no query, no filt, no root_node). Be conservative. + return _fallback_refine() + + +def _diagnose_query( + query: str, vocab: VocabularyIndex, graph: Any, cfg: Any, +) -> AbsenceDiagnosis: + if _is_identifier_shaped(query): + closest, distances, best_sim = _did_you_mean(query, vocab, cfg) + verdict, cause, proof = _threshold_verdict(best_sim, cfg, identifier=True) + # False-absent guard: if the query exactly resolves to a real project + # symbol (simple name OR FQN, case-insensitive), never declare not_in_project. + if verdict == "not_in_project" and _exact_symbol_exists(query, vocab): + verdict, proof = "refine_query", None + if proof is not None: + proof.symbol_count_scanned = vocab.symbol_count + message = _identifier_message(query, verdict, closest) + return AbsenceDiagnosis( + verdict=verdict, + cause=cause, + message=message, + closest_symbols=closest, + distances=distances, + proof=proof, + ) + # Natural language → assemble vocabulary context, no did-you-mean. + ctx = _build_vocabulary_context(graph, vocab) + return AbsenceDiagnosis( + verdict="refine_query", + cause="nl_miss", + message=( + f"No symbol matches `{query}`. Refine the query — try an identifier " + "(class/method/FQN) or browse the project vocabulary below." + ), + vocabulary_context=ctx, + ) + + +def _diagnose_find( + filt: dict, + filter_kind: str, + scope: dict[str, str], + vocab: VocabularyIndex, + graph: Any, + cfg: Any, +) -> AbsenceDiagnosis: + identifier = _extract_identifier(filt, filter_kind) + + if identifier is not None: + # Identifier-shaped filter: run did-you-mean on the identifier value. + closest, distances, best_sim = _did_you_mean(identifier, vocab, cfg) + if best_sim >= cfg.absence_close_threshold: + # Close hit exists → the filter (or scope) excluded it. Show where it lives. + relax = _filter_relaxation(filt, filter_kind, scope, graph, identifier) + return AbsenceDiagnosis( + verdict="refine_query", + cause="filter_miss", + message=( + f"No results for `{identifier}` under the current filter. " + "Close matches exist — try relaxing a dimension (see filter_relaxation)." + ), + closest_symbols=closest, + distances=distances, + filter_relaxation=relax, + ) + # No close hit → identifier miss; apply the conservative threshold. + verdict, cause, proof = _threshold_verdict(best_sim, cfg, identifier=True) + if verdict == "not_in_project" and _exact_symbol_exists(identifier, vocab): + verdict, proof = "refine_query", None + if proof is not None: + proof.symbol_count_scanned = vocab.symbol_count + return AbsenceDiagnosis( + verdict=verdict, + cause=cause, + message=_identifier_message(identifier, verdict, closest), + closest_symbols=closest, + distances=distances, + proof=proof, + ) + + # Broad / non-identifier filter → filter_miss with relaxation suggestions. + relax = _filter_relaxation(filt, filter_kind, scope, graph, None) + return AbsenceDiagnosis( + verdict="refine_query", + cause="filter_miss", + message=( + "No results under the current filter. Matches exist under other values " + "(see filter_relaxation)." + ), + filter_relaxation=relax, + ) + + +def _diagnose_neighbors(root_node: NodeRef, graph: Any) -> AbsenceDiagnosis: + if _neighbors_meaningful_empty(root_node, graph): + return AbsenceDiagnosis( + verdict="correct_empty", + cause="meaningful_empty", + message=( + f"`{root_node.fqn or root_node.id}` has no neighbors of the requested " + "type here — this is a genuine leaf / external entrypoint, not an error." + ), + ) + return AbsenceDiagnosis( + verdict="refine_query", + cause="identifier_miss", + message=( + f"No neighbors for `{root_node.fqn or root_node.id}` with the requested " + "edge type/direction. Run `describe` and inspect `edge_summary` for the " + "edge types this node actually participates in." + ), + ) + + +# --------------------------------------------------------------------------- # +# Did-you-mean + thresholds # +# --------------------------------------------------------------------------- # + + +def _did_you_mean( + identifier: str, vocab: VocabularyIndex, cfg: Any, +) -> tuple[list[NodeRef], list[float], float]: + """Rank vocabulary candidates by SequenceMatcher similarity to ``identifier``. + + Returns (closest_symbols, distances, best_similarity). When n-gram lookup + yields no candidates (no q-gram overlap at all), falls back to a bounded + linear scan over all records so ``closest_symbols`` is still populated — + this backs the "nearest-by-name" guarantee on the ``not_in_project`` path. + """ + limit = int(getattr(cfg, "absence_candidate_count", 5)) + query_norm = _normalize_name(identifier) + + candidates = vocab.lookup(identifier, limit=limit) + if not candidates and vocab.records: + # Rare: totally novel token with zero q-gram overlap. Bounded scan for + # the nearest-by-name records so not_in_project still shows nearest names. + candidates = vocab.records + + scored: list[tuple[SymbolRecord, float]] = [ + (rec, _similarity(query_norm, rec.normalized_name)) for rec in candidates + ] + scored.sort(key=lambda pair: pair[1], reverse=True) + + top = scored[:limit] + closest = [_build_node_ref(rec) for rec, _ in top] + distances = [round(1.0 - sim, 4) for _, sim in top] + best_sim = top[0][1] if top else 0.0 + return closest, distances, best_sim + + +def _threshold_verdict( + best_sim: float, cfg: Any, *, identifier: bool, +) -> tuple[str, str, AbsenceProof | None]: + """Conservative two-band threshold policy. + + Returns (verdict, cause, proof). Middle band defaults to ``refine_query``; + ``not_in_project`` only when best similarity < ``absence_absent_floor`` AND + the query is identifier-shaped. + """ + close = float(getattr(cfg, "absence_close_threshold", 0.85)) + floor = float(getattr(cfg, "absence_absent_floor", 0.40)) + if best_sim < floor and identifier: + proof = AbsenceProof( + nearest_distance=round(1.0 - best_sim, 4), + symbol_count_scanned=0, # filled by caller via vocab + thresholds_applied={"absence_close_threshold": close, "absence_absent_floor": floor}, + query_shape="identifier", + ) + return "not_in_project", "identifier_miss", proof + # close band OR middle band → refine_query (conservative; never false-absent). + return "refine_query", "identifier_miss", None + + +def _identifier_message( + query: str, verdict: str, closest: list[NodeRef], +) -> str: + if verdict == "not_in_project": + return ( + f"No symbol matching `{query}` was found in the project vocabulary. " + "It does not appear to be defined here." + ) + if closest: + names = ", ".join(s.name or s.fqn for s in closest[:3]) + return ( + f"No exact match for `{query}`. Closest symbols: {names}. " + "Refine the query (typo? scope?) and retry." + ) + return f"No match for `{query}`. Refine the query and retry." + + +# --------------------------------------------------------------------------- # +# External detection # +# --------------------------------------------------------------------------- # + + +def _detect_external( + query: str | None, + filt: dict | None, + filter_kind: str | None, + root_node: NodeRef | None, + vocab: VocabularyIndex, +) -> ExternalIdentity | None: + """External-wins: if the target is external/phantom, emit its identity first.""" + # root_node (neighbors/describe subject). + if root_node is not None: + if root_node.kind == "unresolved_call_site": + return ExternalIdentity(fqn=root_node.fqn, reason="unresolved-call") + if root_node.fqn: + ext = _external_identity_for(root_node.fqn, vocab) + if ext is not None: + return ext + + # free-text query (search/resolve/describe-by-fqn). + if query: + ext = _external_identity_for(query, vocab) + if ext is not None: + return ext + + # find identifier filter value. + identifier = _extract_identifier(filt, filter_kind) if filt is not None else None + if identifier: + ext = _external_identity_for(identifier, vocab) + if ext is not None: + return ext + + return None + + +def _external_identity_for(name: str, vocab: VocabularyIndex) -> ExternalIdentity | None: + is_ext, reason = vocab.is_external(name) + if is_ext and reason in ("prefix", "phantom"): + # Prefer the corpus FQN when we can resolve it (richer than the bare query). + fqn = name + for rec in vocab.records: + if rec.simple_name == name or rec.fqn == name: + fqn = rec.fqn or name + break + return ExternalIdentity(fqn=fqn, reason=reason) + return None + + +# --------------------------------------------------------------------------- # +# Neighbors meaningful-empty detection # +# --------------------------------------------------------------------------- # + + +def _neighbors_meaningful_empty(root_node: NodeRef, graph: Any) -> bool: + """A genuine leaf or external entrypoint → ``correct_empty``. + + Reuses the conditions behind ``is_external_entrypoint`` (jrag.py:2452): an + HTTP ``http_endpoint`` route with inbound handlers is an external entrypoint, + so zero callers is meaningful. A symbol with no edges at all is an isolated + leaf. Everything else (has edges, just not the requested type) → refine. + """ + try: + if root_node.kind == "route": + handlers = graph.find_route_handlers(route_id=root_node.id) + if handlers: + return True + # A route with no handlers: still an external surface → meaningful. + return True + # Symbol/other: meaningful empty only if it has zero edges (isolated leaf). + rows = graph._rows( # noqa: SLF001 - same pattern as graph_types helpers + "MATCH (n)--(m) WHERE n.id = $id RETURN count(*) AS c", + {"id": root_node.id}, + ) + if rows: + return int(rows[0].get("c") or 0) == 0 + except Exception: # noqa: BLE001 - degrade to refine_query on graph error + log.debug("neighbors meaningful-empty probe failed", exc_info=True) + return False + + +# --------------------------------------------------------------------------- # +# Filter relaxation (ported from _zero_result_guidance, jrag.py:4187) # +# --------------------------------------------------------------------------- # + + +def _filter_relaxation( + filt: dict | None, + filter_kind: str | None, + scope: dict[str, str], + graph: Any, + identifier: str | None, +) -> FilterRelaxation: + """For each constrained scope dim, tally where identifier/all matches live. + + Ports ``_zero_result_guidance``'s tally-and-suggest-most-common logic into the + structured ``FilterRelaxation`` payload, parameterized on a filter-dims dict + + graph (NOT ``argparse.Namespace``). + """ + constrained: dict[str, str] = {} + for source in (filt or {}, scope or {}): + for dim in _RELAXABLE_DIMS: + val = source.get(dim) if isinstance(source, dict) else None + if isinstance(val, str) and val.strip() and dim not in constrained: + constrained[dim] = val.strip() + + per_dimension: list[FilterRelaxationDim] = [] + for dim, val in constrained.items(): + try: + total, suggested = _tally_dim(graph, dim, identifier) + except Exception: # noqa: BLE001 - relaxation is best-effort + log.debug("filter relaxation tally failed for dim=%s", dim, exc_info=True) + total, suggested = 0, None + per_dimension.append( + FilterRelaxationDim( + dimension=dim, + constrained_value=val, + matches_under_relaxation=total, + suggested_value=suggested, + ) + ) + return FilterRelaxation(per_dimension=per_dimension) + + +def _tally_dim( + graph: Any, dim: str, identifier: str | None, +) -> tuple[int, str | None]: + """Count symbols (optionally matching ``identifier``) grouped by ``dim``. + + Returns (total_matches, most_common_bucket). Mirrors the probe+tally+top-3 + shape of ``_zero_result_guidance`` but reads the graph directly (no mcp_v2 + import) and returns structured values instead of a human string. + """ + params: dict[str, Any] = {} + where = ["s.module IS NOT NULL"] if dim == "module" else [] + # module_counts/microservice_counts count resolved type-symbols; mirror that + # by restricting to resolved symbols for scope dims so suggestions are stable. + if dim in ("module", "microservice"): + where.append("s.resolved = true") + if identifier: + where.append( + "(toLower(s.name) CONTAINS toLower($needle) " + "OR toLower(s.fqn) CONTAINS toLower($needle))" + ) + params["needle"] = identifier + where_clause = ("WHERE " + " AND ".join(where)) if where else "" + query = ( + f"MATCH (s:Symbol) {where_clause} " + f"RETURN s.{dim} AS bucket, count(*) AS n ORDER BY n DESC LIMIT 10" + ) + rows = graph._rows(query, params) # noqa: SLF001 + buckets = [ + (str(r.get("bucket") or ""), int(r.get("n") or 0)) + for r in rows + if r.get("bucket") + ] + total = sum(n for _, n in buckets) + suggested = buckets[0][0] if buckets else None + return total, suggested + + +# --------------------------------------------------------------------------- # +# Vocabulary context (nl_miss) # +# --------------------------------------------------------------------------- # + + +def _build_vocabulary_context(graph: Any, vocab: VocabularyIndex) -> VocabularyContext: + """Assemble project vocabulary stats to inform query refinement.""" + top_modules = sorted(graph.module_counts().items(), key=lambda kv: -kv[1])[:5] + top_microservices = sorted(graph.microservice_counts().items(), key=lambda kv: -kv[1])[:5] + + role_counts: Counter = Counter() + token_counts: Counter = Counter() + for rec in vocab.records: + if rec.role: + role_counts[rec.role] += 1 + for tok in _camel_tokens(rec.simple_name): + token_counts[tok] += 1 + + roles = sorted(role_counts.items(), key=lambda kv: -kv[1])[:5] + tokens = [tok for tok, _ in token_counts.most_common(10)] + return VocabularyContext( + top_modules=[(k, int(v)) for k, v in top_modules], + top_microservices=[(k, int(v)) for k, v in top_microservices], + roles_present=[(k, int(v)) for k, v in roles], + frequent_name_tokens=tokens, + ) + + +# --------------------------------------------------------------------------- # +# Small helpers # +# --------------------------------------------------------------------------- # + + +def _similarity(a: str, b: str) -> float: + """difflib SequenceMatcher ratio on normalized names (∈ [0,1]).""" + return SequenceMatcher(None, a, b).ratio() + + +def _is_identifier_shaped(query: str) -> bool: + """Predicate: does ``query`` look like an identifier (not natural language)? + + Identifier-shaped = a CamelCase token, dotted FQN, or ``Cls#member`` with no + spaces, no NL punctuation, at least one alphanumeric, and not a lone stopword. + Extends the spirit of ``_find_has_identifier_shaped_filter`` to free text. + """ + q = query.strip() + if not q or " " in q: + return False + if any(ch in q for ch in "?,;:!'\""): + return False + if not any(ch.isalnum() for ch in q): + return False + if q.lower() in _STOPWORDS: + return False + return True + + +def _looks_like_node_id(s: str) -> bool: + """True if ``s`` carries a Ladybug node-id prefix (sym:/route:/ucs:/...).""" + return any(s.startswith(pfx) for pfx in _NODE_ID_PREFIXES) + + +def _extract_identifier(filt: dict | None, filter_kind: str | None) -> str | None: + """Pull the identifier filter value (fqn_contains/path_contains/...) out of filt.""" + if not filt or not filter_kind: + return None + for fname in _IDENTIFIER_FILTER_FIELDS.get(filter_kind, ()): + val = filt.get(fname) + if isinstance(val, str) and val.strip(): + return val.strip() + return None + + +def _exact_symbol_exists(query: str, vocab: VocabularyIndex) -> bool: + """True if ``query`` exactly resolves to a real (resolved) project symbol. + + Conservative false-absent guard: compares the query verbatim AND normalized + against every record's simple_name and fqn. Only invoked on the + ``not_in_project`` path (rare), so the O(n) scan is acceptable; it makes + declaring a real symbol absent impossible. Resolved-only because a phantom + match is handled as ``external`` upstream. + """ + q = query.strip() + if not q: + return False + q_norm = _normalize_name(q) + for rec in vocab.records: + if not rec.resolved: + continue + if rec.simple_name == q or rec.fqn == q: + return True + if _normalize_name(rec.simple_name) == q_norm or _normalize_name(rec.fqn) == q_norm: + return True + return False + + +def _build_node_ref(rec: SymbolRecord) -> NodeRef: + """Build a NodeRef from a SymbolRecord (per the brief's field mapping).""" + return NodeRef( + id=rec.node_id, + kind="symbol", + fqn=rec.fqn, + name=rec.simple_name, + symbol_kind=rec.kind or None, + module=rec.module, + microservice=rec.microservice, + role=rec.role, + ) + + +def _camel_tokens(name: str) -> list[str]: + """Split a CamelCase identifier into tokens for vocabulary statistics.""" + if not name: + return [] + tokens: list[str] = [] + cur = "" + prev_lower = False + for ch in name: + if ch.isupper(): + if prev_lower and cur: + tokens.append(cur.lower()) + cur = ch + else: + cur += ch + prev_lower = False + elif ch.isalnum(): + cur += ch + prev_lower = ch.islower() + else: + if cur: + tokens.append(cur.lower()) + cur = "" + prev_lower = False + if cur: + tokens.append(cur.lower()) + return [t for t in tokens if len(t) > 1] + + +def _fallback_refine() -> AbsenceDiagnosis | None: + """Minimal refine_query when diagnosis cannot complete (never raises).""" + try: + return AbsenceDiagnosis( + verdict="refine_query", + cause="identifier_miss", + message="Unable to diagnose the empty result; refine the query and retry.", + ) + except Exception: # noqa: BLE001 + return None diff --git a/tests/test_absence_diagnosis.py b/tests/test_absence_diagnosis.py new file mode 100644 index 00000000..b4f1cbf4 --- /dev/null +++ b/tests/test_absence_diagnosis.py @@ -0,0 +1,430 @@ +"""Tests for absence_diagnosis.py — the stateless diagnose() classifier (PR-ABS-2). + +Unit matrix (cause × verdict) per the task brief. Mirrors test_absence_vocab.py's +style: build a real VocabularyIndex from the session ladybug_graph fixture and +call diagnose(...) with synthetic inputs (graph-backed only where did-you-mean / +external / vocabulary_context need real data). + +Conservative-absence guard (false-absent): a symbol that exists must NEVER yield +``not_in_project``. Two tests pin this: the middle-band case and the explicit +exact-existing-symbol case. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +# These imports will fail until absence_diagnosis.py is created (RED). +from absence_diagnosis import diagnose +from absence_types import AbsenceDiagnosis +from absence_vocab import VocabularyIndex +from graph_types import NodeRef + + +# ---- helpers ----------------------------------------------------------------- + + +class _StubConfig: + """Minimal config stand-in exposing only the absence knobs diagnose reads.""" + + absence_close_threshold: float = 0.85 + absence_absent_floor: float = 0.40 + absence_candidate_count: int = 5 + absence_ngram_q: int = 3 + absence_diag_enabled: bool = True + + +@pytest.fixture(scope="session") +def vocab(ladybug_graph) -> VocabularyIndex: + """A VocabularyIndex built from the session bank-chat graph.""" + return VocabularyIndex.build(ladybug_graph, q=3) + + +@pytest.fixture(scope="session") +def graph(ladybug_graph): + return ladybug_graph + + +@pytest.fixture +def cfg() -> _StubConfig: + return _StubConfig() + + +def _diagnose(**overrides: Any) -> AbsenceDiagnosis | None: + """Call diagnose with defaults; override per-call kwargs.""" + base = dict( + tool="search", + query=None, + filt=None, + filter_kind=None, + root_node=None, + scope={}, + vocab=None, + graph=None, + cfg=None, + ) + base.update(overrides) + return diagnose(**base) + + +# ---- identifier path: did-you-mean + thresholds ------------------------------ + + +class TestIdentifierDidYouMean: + """search/resolve: identifier-shaped query → did-you-mean + threshold verdict.""" + + def test_typo_close_hit_refine_query(self, graph, vocab, cfg): + """A known typo (ChatManagementServic) near ChatManagementService → refine_query. + + closest_symbols non-empty, ChatManagementService present, best distance small. + """ + diag = _diagnose( + tool="search", + query="ChatManagementServic", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "refine_query" + assert diag.cause == "identifier_miss" + assert len(diag.closest_symbols) > 0 + names = {s.name for s in diag.closest_symbols} + assert "ChatManagementService" in names + # best distance small (similarity was ~0.97 → distance <= 0.10) + assert diag.distances + assert min(diag.distances) <= 0.10 + + def test_nothing_close_yields_not_in_project(self, graph, vocab, cfg): + """A name with no close match (foobarbaznope) → not_in_project with proof. + + closest_symbols still returned (nearest-by-name); best distance >= absent_floor. + """ + diag = _diagnose( + tool="search", + query="foobarbaznope", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "not_in_project" + assert diag.proof is not None + assert diag.proof.query_shape == "identifier" + assert diag.proof.symbol_count_scanned == vocab.symbol_count + assert diag.proof.nearest_distance >= cfg.absence_absent_floor + # closest_symbols still populated even on not_in_project + assert len(diag.closest_symbols) > 0 + assert diag.distances + assert min(diag.distances) >= cfg.absence_absent_floor + + def test_middle_band_refine_query_not_not_in_project(self, graph, vocab, cfg): + """A plausible-but-absent identifier (ChatManagement, sim ~0.80) → refine_query. + + This is the middle band: between absent_floor and close_threshold. Must NOT + be ``not_in_project`` — the conservative false-absent guard. + """ + diag = _diagnose( + tool="search", + query="ChatManagement", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "refine_query" + assert diag.verdict != "not_in_project" + assert diag.closest_symbols # populated regardless + + +# ---- false-absent guard (explicit) ------------------------------------------- + + +class TestFalseAbsentGuard: + """A symbol that EXISTS must never yield not_in_project when queried exactly.""" + + def test_existing_symbol_exact_never_not_in_project(self, graph, vocab, cfg): + """Querying an existing symbol (ChatManagementService) exactly → refine_query. + + The catastrophic failure mode is declaring a real symbol absent. Pin that + an exact existing name never reaches not_in_project. + """ + diag = _diagnose( + tool="search", + query="ChatManagementService", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict != "not_in_project" + assert diag.verdict == "refine_query" + # exact match → distance 0 + assert diag.distances + assert min(diag.distances) == pytest.approx(0.0, abs=1e-6) + + def test_existing_symbol_under_floor_threshold_still_safe(self, graph, vocab, cfg): + """Even with an absurdly high absent_floor, an existing symbol stays safe. + + Pins that the not_in_project branch is gated on identifier-shape AND best + similarity, and that an exact existing hit (similarity 1.0) clears any + plausible floor. + """ + cfg.absence_absent_floor = 0.95 # deliberately harsh + diag = _diagnose( + tool="search", + query="ChatManagementService", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "refine_query" + + def test_existing_symbol_by_fqn_never_not_in_project(self, graph, vocab, cfg): + """Querying an existing symbol by its full FQN → refine_query. + + Guards the FQN false-absent gap: ``vocab.lookup`` matches on simple_name, + so an FQN query's similarity to the simple-name-only normalized_name could + in principle dip below the floor for a deeply-nested name. The exact-match + guard must force refine_query regardless. + """ + # Find a real resolved symbol and query its full FQN. + rec = next( + r for r in vocab.records + if r.resolved and r.fqn.count(".") >= 3 and len(r.fqn) > 30 + ) + diag = _diagnose( + tool="search", + query=rec.fqn, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict != "not_in_project" + + +# ---- NL path ----------------------------------------------------------------- + + +class TestNlMiss: + """search: natural-language query → nl_miss with vocabulary_context.""" + + def test_nl_query_yields_nl_miss_with_context(self, graph, vocab, cfg): + diag = _diagnose( + tool="search", + query="how does chat routing work", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.cause == "nl_miss" + assert diag.verdict == "refine_query" + assert diag.vocabulary_context is not None + # top modules / microservices populated from the corpus + assert diag.vocabulary_context.top_modules + assert diag.vocabulary_context.top_microservices + # NO did-you-mean for NL + assert diag.closest_symbols == [] + + +# ---- find (filter) path ------------------------------------------------------ + + +class TestFindFilterMiss: + """find: identifier-shaped and broad filters → filter_miss (+ relaxation).""" + + def test_identifier_filter_excluded_by_scope_filter_miss(self, graph, vocab, cfg): + """find fqn_contains= excluded by a scope dim → filter_miss. + + The symbol exists (close hit) but the scope filter excludes it, so the + diagnosis points at the filter, not absence. filter_relaxation.per_dimension + non-empty (shows where matches live). + """ + diag = _diagnose( + tool="find", + filt={"fqn_contains": "ChatManagementService"}, + filter_kind="symbol", + scope={"microservice": "nonexistent-svc"}, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.cause == "filter_miss" + assert diag.verdict == "refine_query" + assert diag.filter_relaxation is not None + assert diag.filter_relaxation.per_dimension + # the relaxed dim should be the constrained one + dims = {d.dimension for d in diag.filter_relaxation.per_dimension} + assert "microservice" in dims + + def test_broad_filter_absent_role_filter_miss(self, graph, vocab, cfg): + """find role=REPOSITORY (no such role in corpus) → filter_miss.""" + diag = _diagnose( + tool="find", + filt={"role": "REPOSITORY"}, + filter_kind="symbol", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.cause == "filter_miss" + assert diag.filter_relaxation is not None + assert diag.filter_relaxation.per_dimension + dims = {d.dimension for d in diag.filter_relaxation.per_dimension} + assert "role" in dims + + +# ---- external-wins ----------------------------------------------------------- + + +class TestExternalDependency: + """external/phantom targets → external_dependency (external-wins precedence).""" + + def test_external_prefix_target(self, graph, vocab, cfg): + diag = _diagnose( + tool="search", + query="java.util.List", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "external_dependency" + assert diag.cause == "external" + assert diag.external_identity is not None + assert diag.external_identity.reason == "prefix" + + def test_phantom_target_in_corpus(self, graph, vocab, cfg): + diag = _diagnose( + tool="search", + query="RestTemplate", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "external_dependency" + assert diag.external_identity is not None + assert diag.external_identity.reason == "phantom" + + +# ---- neighbors path ---------------------------------------------------------- + + +def _find_http_route_with_handlers(graph) -> str | None: + """Find a real http_endpoint Route id that has inbound handlers.""" + rows = graph._rows( # noqa: SLF001 - test helper, same pattern as conftest + "MATCH (r:Route) WHERE r.kind = 'http_endpoint' RETURN r.id AS id LIMIT 10" + ) + for row in rows: + rid = str(row.get("id") or "") + if rid and graph.find_route_handlers(route_id=rid): + return rid + return None + + +def _find_symbol_with_edges(graph) -> NodeRef: + """Find a real Symbol node that has at least one edge.""" + rows = graph._rows( # noqa: SLF001 + "MATCH (s:Symbol)--() RETURN s.id AS id, s.name AS name, s.fqn AS fqn, " + "s.kind AS kind LIMIT 1" + ) + assert rows, "corpus has no symbol with edges" + row = rows[0] + return NodeRef( + id=str(row.get("id") or ""), + kind="symbol", + fqn=str(row.get("fqn") or ""), + name=str(row.get("name") or "") or None, + symbol_kind=str(row.get("kind") or "") or None, + ) + + +class TestNeighbors: + """neighbors: leaf/entrypoint → correct_empty; wrong edge type → refine_query.""" + + def test_route_entrypoint_correct_empty(self, graph, vocab, cfg): + rid = _find_http_route_with_handlers(graph) + assert rid, "corpus has no http route with handlers" + root = NodeRef(id=rid, kind="route", fqn="GET /x") + diag = _diagnose( + tool="neighbors", + root_node=root, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "correct_empty" + assert diag.cause == "meaningful_empty" + + def test_symbol_with_edges_refine_query(self, graph, vocab, cfg): + root = _find_symbol_with_edges(graph) + diag = _diagnose( + tool="neighbors", + root_node=root, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "refine_query" + + +# ---- describe path ----------------------------------------------------------- + + +class TestDescribe: + """describe: by node_id → refine_query with no did-you-mean.""" + + def test_describe_by_node_id_refine_query_no_did_you_mean(self, graph, vocab, cfg): + diag = _diagnose( + tool="describe", + query="sym:deadbeefNoSuchNodeId", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "refine_query" + # node_id miss → no did-you-mean + assert diag.closest_symbols == [] + + +# ---- master toggle + exception guard ----------------------------------------- + + +class TestRobustness: + """Master toggle and exception guard: diagnosis never fails the tool.""" + + def test_master_toggle_off_returns_none(self, graph, vocab): + cfg = _StubConfig() + cfg.absence_diag_enabled = False + diag = _diagnose( + tool="search", + query="ChatManagementService", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is None + + def test_exception_guard_returns_refine_query(self, graph, vocab, cfg, monkeypatch): + """If vocab.lookup raises, diagnose returns a refine_query (no escape).""" + monkeypatch.setattr(vocab, "lookup", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + diag = _diagnose( + tool="search", + query="ChatManagementServic", + vocab=vocab, + graph=graph, + cfg=cfg, + ) + # Must not raise; returns a minimal refine_query (or None). + assert diag is None or diag.verdict == "refine_query" From ca1d6dfa9d56cfe4540b3ef3e91ccecbfcd9af75 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:31:30 +0300 Subject: [PATCH 08/17] fix(absence): route correct_empty gates on http_endpoint; drop dead import; consistent role tally; unresolved-call test Co-Authored-By: Claude --- absence_diagnosis.py | 21 +++++++---- tests/test_absence_diagnosis.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/absence_diagnosis.py b/absence_diagnosis.py index 73c43f13..57867728 100644 --- a/absence_diagnosis.py +++ b/absence_diagnosis.py @@ -40,7 +40,7 @@ ) from absence_vocab import SymbolRecord, VocabularyIndex, _normalize_name from graph_types import NodeRef -from mcp_hints import _IDENTIFIER_FILTER_FIELDS, _find_has_identifier_shaped_filter +from mcp_hints import _IDENTIFIER_FILTER_FIELDS log = logging.getLogger(__name__) @@ -416,11 +416,18 @@ def _neighbors_meaningful_empty(root_node: NodeRef, graph: Any) -> bool: """ try: if root_node.kind == "route": - handlers = graph.find_route_handlers(route_id=root_node.id) - if handlers: - return True - # A route with no handlers: still an external surface → meaningful. - return True + # Fetch the route's kind property (http_endpoint vs kafka_topic). + kind_rows = graph._rows( + "MATCH (r:Route) WHERE r.id = $id RETURN r.kind AS k", + {"id": root_node.id}, + ) + route_kind = kind_rows[0].get("k") if kind_rows else "" + # Only http_endpoint routes with handlers are external entrypoints. + if route_kind == "http_endpoint": + handlers = graph.find_route_handlers(route_id=root_node.id) + if handlers: + return True + # kafka_topic routes and handler-less routes are NOT meaningful empty. # Symbol/other: meaningful empty only if it has zero edges (isolated leaf). rows = graph._rows( # noqa: SLF001 - same pattern as graph_types helpers "MATCH (n)--(m) WHERE n.id = $id RETURN count(*) AS c", @@ -489,7 +496,7 @@ def _tally_dim( where = ["s.module IS NOT NULL"] if dim == "module" else [] # module_counts/microservice_counts count resolved type-symbols; mirror that # by restricting to resolved symbols for scope dims so suggestions are stable. - if dim in ("module", "microservice"): + if dim in ("module", "microservice", "role"): where.append("s.resolved = true") if identifier: where.append( diff --git a/tests/test_absence_diagnosis.py b/tests/test_absence_diagnosis.py index b4f1cbf4..cce2a93b 100644 --- a/tests/test_absence_diagnosis.py +++ b/tests/test_absence_diagnosis.py @@ -314,6 +314,24 @@ def test_phantom_target_in_corpus(self, graph, vocab, cfg): assert diag.external_identity is not None assert diag.external_identity.reason == "phantom" + def test_unresolved_call_site_root_node_external_dependency(self, graph, vocab, cfg): + """unresolved_call_site root_node → external_dependency, reason=unresolved-call.""" + ucs = _find_unresolved_call_site(graph) + if not ucs: + pytest.skip("corpus has no unresolved_call_site node") + diag = _diagnose( + tool="neighbors", + root_node=ucs, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + assert diag.verdict == "external_dependency" + assert diag.cause == "external" + assert diag.external_identity is not None + assert diag.external_identity.reason == "unresolved-call" + # ---- neighbors path ---------------------------------------------------------- @@ -347,6 +365,35 @@ def _find_symbol_with_edges(graph) -> NodeRef: ) +def _find_kafka_topic_route(graph) -> str | None: + """Find a real kafka_topic Route id (NOT http_endpoint).""" + rows = graph._rows( # noqa: SLF001 - test helper, same pattern as conftest + "MATCH (r:Route) WHERE r.kind = 'kafka_topic' RETURN r.id AS id LIMIT 10" + ) + for row in rows: + rid = str(row.get("id") or "") + if rid: + return rid + return None + + +def _find_unresolved_call_site(graph) -> NodeRef | None: + """Find a real UnresolvedCallSite node.""" + rows = graph._rows( # noqa: SLF001 + "MATCH (ucs:UnresolvedCallSite) RETURN ucs.id AS id, ucs.callee_simple AS callee LIMIT 1" + ) + if not rows: + return None + row = rows[0] + return NodeRef( + id=str(row.get("id") or ""), + kind="unresolved_call_site", + fqn=str(row.get("callee") or ""), # Use callee_simple as fqn + name=str(row.get("callee") or "") or None, + symbol_kind=None, + ) + + class TestNeighbors: """neighbors: leaf/entrypoint → correct_empty; wrong edge type → refine_query.""" @@ -377,6 +424,24 @@ def test_symbol_with_edges_refine_query(self, graph, vocab, cfg): assert diag is not None assert diag.verdict == "refine_query" + def test_kafka_topic_route_refine_query_not_correct_empty(self, graph, vocab, cfg): + """kafka_topic route with zero neighbors → refine_query (not correct_empty).""" + rid = _find_kafka_topic_route(graph) + if not rid: + pytest.skip("corpus has no kafka_topic route") + root = NodeRef(id=rid, kind="route", fqn="kafka:topic") + diag = _diagnose( + tool="neighbors", + root_node=root, + vocab=vocab, + graph=graph, + cfg=cfg, + ) + assert diag is not None + # kafka_topic routes are NOT external entrypoints → refine_query, not correct_empty + assert diag.verdict == "refine_query" + assert diag.cause == "identifier_miss" + # ---- describe path ----------------------------------------------------------- From f142ef20ebe27d1cbcf434e7eb18e9a46b13a8f1 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:40:35 +0300 Subject: [PATCH 09/17] feat(absence): wire diagnosis into the 5 MCP empty paths - Add set_absence_config cfg holder in mcp_v2.py and resolve_service.py - Thread cfg from server.py to both modules - Wire search empty path: diagnose on hits=[] with tool=search, query - Wire find empty path: diagnose on refs=[] with tool=find, filt, filter_kind - Wire describe empty paths: diagnose on fqn miss (query=fqn) and node_id miss (query=None) - Wire neighbors empty path: diagnose on sliced=[] with tool=neighbors, root_node - Wire resolve none path: diagnose on status=none with tool=resolve, query, scope - Add integration tests for all 5 tools (13 tests, all passing) - All existing MCP v2, resolve, and hints tests still pass (180 passed total) PR-ABS-3 --- mcp_v2.py | 133 +++++++++++++++++- resolve_service.py | 69 ++++++++- server.py | 3 + tests/test_absence_mcp_integration.py | 195 ++++++++++++++++++++++++++ 4 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 tests/test_absence_mcp_integration.py diff --git a/mcp_v2.py b/mcp_v2.py index c6011740..89e3af76 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -32,6 +32,8 @@ from sentence_transformers import SentenceTransformer from absence_types import AbsenceDiagnosis +from absence_diagnosis import diagnose +from absence_vocab import get_vocabulary_index from graph_types import ( NodeRef, StructuredHint, @@ -75,6 +77,7 @@ "EdgeFilter", "StructuredHint", "set_hints_enabled", + "set_absence_config", ] DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"] @@ -148,6 +151,35 @@ _fail_loud_counts: dict[str, int] = {} _fail_loud_lock = threading.Lock() +# Module-level holder for absence diagnosis config (set by server.py) +_absence_config: Any = None + + +def set_absence_config(cfg: Any) -> None: + """Set the global absence diagnosis config for MCP tools. + + Mirrors set_hints_enabled: called from server.py to make cfg reachable + in tool functions without threading it through every signature. + """ + global _absence_config + _absence_config = cfg + + +def _get_absence_config() -> Any: + """Get the absence diagnosis config, with safe fallback. + + Returns the module-level config if set; otherwise falls back to a + default config (for direct tool calls in tests without server init). + """ + if _absence_config is not None: + return _absence_config + + # Fallback: resolve from cwd for direct test calls + from java_codebase_rag.config import resolve_operator_config + from pathlib import Path + + return resolve_operator_config(source_root=Path.cwd()) + def _log_fail_loud(category: str) -> None: """Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3). @@ -993,6 +1025,25 @@ def search_v2( if not _node_matches_filter(row_kind, row, nf): continue hits.append(_row_to_search_hit(row, explain=explain)) + + # Absence diagnosis for empty results + cfg = _get_absence_config() + diag: AbsenceDiagnosis | None = None + if not hits: + g = graph or LadybugGraph.get() + vocab = get_vocabulary_index(g, cfg) + diag = diagnose( + tool="search", + query=query, + filt=None, + filter_kind=None, + root_node=None, + scope={}, + vocab=vocab, + graph=g, + cfg=cfg, + ) + hint_payload = { "success": True, "results": [h.model_dump() for h in hits], @@ -1008,6 +1059,7 @@ def search_v2( advisories=advisories + raw_advisories, # Merge fallback + hints advisories hints_structured=_to_structured_hints(raw_struct), lexical_mode=lexical_mode, + absence=diag, ) except Exception as exc: return SearchOutput(success=False, message=str(exc), advisories=[], limit=None, offset=None) @@ -1079,6 +1131,24 @@ def find_v2( rows = rows[offset : offset + limit] refs = [_node_ref_from_row(kind, r) for r in rows] filter_dump = nf.model_dump(exclude_none=True) + + # Absence diagnosis for empty results + cfg = _get_absence_config() + diag: AbsenceDiagnosis | None = None + if not refs: + vocab = get_vocabulary_index(g, cfg) + diag = diagnose( + tool="find", + query=None, + filt=filter_dump, + filter_kind=kind, + root_node=None, + scope={}, + vocab=vocab, + graph=g, + cfg=cfg, + ) + hint_payload: dict[str, Any] = { "success": True, "kind": kind, @@ -1102,6 +1172,7 @@ def find_v2( has_more_results=has_more_results, advisories=raw_advisories, hints_structured=_to_structured_hints(raw_struct), + absence=diag, ) except Exception as exc: return FindOutput(success=False, message=str(exc), advisories=[], limit=None, offset=None) @@ -1138,7 +1209,25 @@ def describe_v2( {"fqn": fqn_val}, ) if not rows: - return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'") + # FQN not found: run diagnosis with query=fqn + cfg = _get_absence_config() + vocab = get_vocabulary_index(g, cfg) + diag = diagnose( + tool="describe", + query=fqn_val, + filt=None, + filter_kind=None, + root_node=None, + scope={}, + vocab=vocab, + graph=g, + cfg=cfg, + ) + return DescribeOutput( + success=False, + message=f"No Symbol found for fqn='{fqn_val}'", + absence=diag, + ) node_id = str(rows[0]["id"] or "") if len(rows) > 1: hint_message = ( @@ -1151,7 +1240,26 @@ def describe_v2( return DescribeOutput(success=False, message=_DESCRIBE_UCS_ID_MESSAGE, advisories=[]) row = _load_node_record(g, node_id, kind) if row is None: - return DescribeOutput(success=False, message=f"No node found for `{node_id}`", advisories=[]) + # Node ID not found: run diagnosis with query=None (minimal refine) + cfg = _get_absence_config() + vocab = get_vocabulary_index(g, cfg) + diag = diagnose( + tool="describe", + query=None, + filt=None, + filter_kind=None, + root_node=None, + scope={}, + vocab=vocab, + graph=g, + cfg=cfg, + ) + return DescribeOutput( + success=False, + message=f"No node found for `{node_id}`", + advisories=[], + absence=diag, + ) ref = _node_ref_from_row(kind, row) edge_summary = _edge_summary_for_node(g, node_id, kind=kind, row=row) data = dict(row) @@ -1662,6 +1770,26 @@ def neighbors_v2( first_origin = origins[0] origin_kind = _resolve_node_kind(g, first_origin) subject_record = _load_node_record(g, first_origin, origin_kind) + + # Absence diagnosis for empty results + cfg = _get_absence_config() + diag: AbsenceDiagnosis | None = None + if not sliced: + # Build root_node from first_origin + subject_record + root_node = _node_ref_from_row(origin_kind, subject_record) if subject_record else None + vocab = get_vocabulary_index(g, cfg) + diag = diagnose( + tool="neighbors", + query=None, + filt=None, + filter_kind=None, + root_node=root_node, + scope={}, + vocab=vocab, + graph=g, + cfg=cfg, + ) + neigh_payload = { "success": True, "results": [e.model_dump(exclude_none=True) for e in sliced], @@ -1687,6 +1815,7 @@ def neighbors_v2( has_more_results=neighbors_has_more, advisories=raw_advisories, hints_structured=_to_structured_hints(raw_struct), + absence=diag, ) except ValidationError: raise diff --git a/resolve_service.py b/resolve_service.py index 58081e7c..74bbfe2a 100644 --- a/resolve_service.py +++ b/resolve_service.py @@ -6,11 +6,13 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from absence_types import AbsenceDiagnosis +from absence_diagnosis import diagnose +from absence_vocab import get_vocabulary_index from graph_types import ( NodeRef, StructuredHint, @@ -29,6 +31,7 @@ "ResolveCandidate", "ResolveStatus", "set_hints_enabled", + "set_absence_config", ] @@ -36,6 +39,35 @@ _RESOLVE_CANDIDATE_CAP = 10 +# Module-level holder for absence diagnosis config (set by server.py) +_absence_config: Any = None + + +def set_absence_config(cfg: Any) -> None: + """Set the global absence diagnosis config for resolve service. + + Mirrors set_hints_enabled: called from server.py to make cfg reachable + in resolve functions without threading it through every signature. + """ + global _absence_config + _absence_config = cfg + + +def _get_absence_config() -> Any: + """Get the absence diagnosis config, with safe fallback. + + Returns the module-level config if set; otherwise falls back to a + default config (for direct tool calls in tests without server init). + """ + if _absence_config is not None: + return _absence_config + + # Fallback: resolve from cwd for direct test calls + from java_codebase_rag.config import resolve_operator_config + from pathlib import Path + + return resolve_operator_config(source_root=Path.cwd()) + _RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = { "exact_id": 0, "exact_fqn": 1, @@ -568,6 +600,9 @@ def _resolve_finalize_success( trimmed: str, hint_kind: Literal["symbol", "route", "client", "producer"] | None, matches: list[ResolveCandidate], + graph: LadybugGraph | None = None, + microservice: str = "", + module: str = "", ) -> ResolveOutput: if not matches: out = ResolveOutput( @@ -593,6 +628,28 @@ def _resolve_finalize_success( resolved_identifier=trimmed, ) + # Absence diagnosis for empty results (status="none") + cfg = _get_absence_config() + diag: AbsenceDiagnosis | None = None + if not matches and graph is not None: + g = graph + vocab = get_vocabulary_index(g, cfg) + # Map hint_kind to filter_kind (symbol -> "symbol", etc.) + filter_kind = hint_kind if hint_kind in ("symbol", "route", "client", "producer") else None + # Build scope from microservice/module params + scope = {"microservice": microservice or "", "module": module or ""} + diag = diagnose( + tool="resolve", + query=trimmed, + filt=None, + filter_kind=filter_kind, + root_node=None, + scope=scope, + vocab=vocab, + graph=g, + cfg=cfg, + ) + path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed) hint_payload = { "status": out.status, @@ -606,6 +663,7 @@ def _resolve_finalize_success( out = out.model_copy(update={ "advisories": raw_advisories, "hints_structured": _to_structured_hints(raw_struct), + "absence": diag, }) _resolve_assert_invariants(out) return out @@ -662,7 +720,14 @@ def resolve_v2( deduped = _resolve_dedupe_candidates(raw) ranked = _resolve_rank_candidates(deduped) capped = ranked[:_RESOLVE_CANDIDATE_CAP] - return _resolve_finalize_success(trimmed, hint_kind, capped) + return _resolve_finalize_success( + trimmed, + hint_kind, + capped, + graph=g, + microservice=microservice, + module=module, + ) except Exception as exc: out = ResolveOutput( success=False, diff --git a/server.py b/server.py index b7377a34..9d9a41f3 100644 --- a/server.py +++ b/server.py @@ -10,6 +10,7 @@ from typing import Literal import mcp_v2 +import resolve_service from index_common import SBERT_MODEL from java_codebase_rag.cli_progress import ( accumulate_and_relay_subprocess_streams, @@ -852,6 +853,8 @@ def main() -> None: cfg = resolve_operator_config(source_root=_source_root_for_operator_config()) cfg.apply_to_os_environ() mcp_v2.set_hints_enabled(cfg.hints_enabled) + mcp_v2.set_absence_config(cfg) + resolve_service.set_absence_config(cfg) # Initialize scope manager for automatic microservice detection global _scope_manager diff --git a/tests/test_absence_mcp_integration.py b/tests/test_absence_mcp_integration.py new file mode 100644 index 00000000..c019da0e --- /dev/null +++ b/tests/test_absence_mcp_integration.py @@ -0,0 +1,195 @@ +"""Integration tests for absence diagnosis wired into MCP tools (PR-ABS-3). + +These tests verify that diagnose() is called on empty paths and the result is +attached to the output's absence field. Non-empty results should have absence=None. +""" +from __future__ import annotations + +import pytest + +from mcp_v2 import describe_v2, find_v2, neighbors_v2, search_v2 +from resolve_service import resolve_v2 +from absence_types import AbsenceVerdict + + +def test_search_empty_result_has_absence_diagnosis(ladybug_graph, monkeypatch) -> None: + """Empty search result should have absence field populated with diagnosis.""" + # Monkeypatch run_search to return empty results + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: []) + + out = search_v2("zzzNoSuchClass123", graph=ladybug_graph) + assert out.success is True + assert out.results == [] + assert out.absence is not None, "absence should be populated on empty results" + assert out.absence.verdict in AbsenceVerdict.__args__ + assert out.absence.message + # Should be not_in_project for a made-up identifier + if out.absence.verdict == "not_in_project": + assert out.absence.proof is not None + assert out.absence.closest_symbols is not None + + +def test_search_typo_has_absence_diagnosis(ladybug_graph, monkeypatch) -> None: + """Search with a typo should have refine_query verdict with closest symbols.""" + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: []) + + out = search_v2("ChatServic", graph=ladybug_graph) # typo: missing 'e' + assert out.success is True + assert out.results == [] + assert out.absence is not None + assert out.absence.verdict == "refine_query" + assert out.absence.cause == "identifier_miss" + assert out.absence.closest_symbols # should have did-you-mean suggestions + + +def test_search_external_dependency_has_absence_diagnosis(ladybug_graph, monkeypatch) -> None: + """Search for an external dependency should have external_dependency verdict.""" + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: []) + + out = search_v2("java.util.List", graph=ladybug_graph) + assert out.success is True + assert out.results == [] + assert out.absence is not None + assert out.absence.verdict == "external_dependency" + assert out.absence.external_identity is not None + assert "java.util" in out.absence.external_identity.fqn or "java.util.List" in out.absence.external_identity.fqn + + +def test_search_non_empty_result_has_no_absence(ladybug_graph, monkeypatch) -> None: + """Non-empty search result should have absence=None.""" + # Mock search to return results + fake_rows = [ + { + "id": "chunk:1", + "symbol_id": "sym:1", + "primary_type_fqn": "com.example.ChatService", + "_rrf_score": 0.9, + "text": "ChatService sample", + "microservice": "chat-assign", + "module": "chat-assign", + "role": "SERVICE", + "filename": "chat-assign/src/main/java/com/example/ChatAssignService.java", + "start": {"byte_offset": 10}, + "end": {"byte_offset": 30}, + }, + ] + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: fake_rows) + + out = search_v2("ChatService", graph=ladybug_graph) + assert out.success is True + assert len(out.results) > 0 + assert out.absence is None, "absence should be None for non-empty results" + + +def test_find_empty_result_has_absence_diagnosis(ladybug_graph) -> None: + """Empty find result should have absence field populated.""" + out = find_v2("symbol", {"fqn_contains": "zzzNoMatch"}, graph=ladybug_graph) + assert out.success is True + assert out.results == [] + assert out.absence is not None + assert out.absence.verdict in AbsenceVerdict.__args__ + # Could be identifier_miss or filter_miss depending on the query shape + if out.absence.verdict == "refine_query": + assert out.absence.cause in ("identifier_miss", "filter_miss") + + +def test_find_non_empty_result_has_no_absence(ladybug_graph) -> None: + """Non-empty find result should have absence=None.""" + out = find_v2("symbol", {"role": "CONTROLLER"}, graph=ladybug_graph) + assert out.success is True + assert len(out.results) > 0 + assert out.absence is None + + +def test_describe_fqn_not_found_has_absence_diagnosis(ladybug_graph) -> None: + """Describe with non-existent FQN should have absence field populated.""" + out = describe_v2(fqn="com.no.such.Type", graph=ladybug_graph) + assert out.success is False + assert out.absence is not None + assert out.absence.verdict in ("not_in_project", "refine_query") + # Message should mention the FQN + assert "com.no.such.Type" in out.message or "No Symbol found" in out.message + + +def test_describe_node_id_not_found_has_absence_diagnosis(ladybug_graph) -> None: + """Describe with non-existent node_id should have absence field populated.""" + out = describe_v2(id="sym:doesnotexist12345", graph=ladybug_graph) + assert out.success is False + assert out.absence is not None + assert out.absence.verdict in ("refine_query", "not_in_project") + + +def test_describe_non_empty_result_has_no_absence(ladybug_graph) -> None: + """Non-empty describe result should have absence=None.""" + # First find a real symbol + find_out = find_v2("symbol", {"symbol_kind": "class"}, limit=1, graph=ladybug_graph) + assert find_out.success is True + assert len(find_out.results) > 0 + + # Then describe it + real_id = find_out.results[0].id + out = describe_v2(id=real_id, graph=ladybug_graph) + assert out.success is True + assert out.record is not None + assert out.absence is None + + +def test_neighbors_empty_result_has_absence_diagnosis(ladybug_graph) -> None: + """Empty neighbors result should have absence field populated.""" + # First find a leaf node (a method with no outgoing CALLS edges) + rows = ladybug_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol {kind: 'method'}) WHERE NOT (m)-[:CALLS]->() RETURN m.id AS id LIMIT 1" + ) + if not rows: + pytest.skip("No leaf methods found in test graph") + + leaf_id = rows[0]["id"] + out = neighbors_v2(leaf_id, edge_types=["CALLS"], direction="out", graph=ladybug_graph) + assert out.success is True + assert out.results == [] + assert out.absence is not None + # Leaf with no callers should be correct_empty + assert out.absence.verdict in ("correct_empty", "refine_query") + + +def test_neighbors_non_empty_result_has_no_absence(ladybug_graph) -> None: + """Non-empty neighbors result should have absence=None.""" + # Find a method with outgoing CALLS + rows = ladybug_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol {kind: 'method'})-[:CALLS]->() RETURN m.id AS id LIMIT 1" + ) + assert rows, "Test graph should have at least one method with CALLS" + + method_id = rows[0]["id"] + out = neighbors_v2(method_id, edge_types=["CALLS"], direction="out", graph=ladybug_graph) + assert out.success is True + assert len(out.results) > 0 + assert out.absence is None + + +def test_resolve_empty_result_has_absence_diagnosis(ladybug_graph) -> None: + """Empty resolve result should have absence field populated.""" + out = resolve_v2("zzzNoSuchSymbol", graph=ladybug_graph) + assert out.success is True + assert out.status == "none" + assert out.absence is not None + assert out.absence.verdict in ("not_in_project", "refine_query") + # Should have did-you-mean suggestions for identifier-shaped query + if out.absence.verdict == "refine_query": + assert out.absence.closest_symbols is not None + + +def test_resolve_non_empty_result_has_no_absence(ladybug_graph) -> None: + """Non-empty resolve result should have absence=None.""" + # Find a real symbol first + find_out = find_v2("symbol", {"symbol_kind": "class"}, limit=1, graph=ladybug_graph) + assert find_out.success is True + assert len(find_out.results) > 0 + + real_fqn = find_out.results[0].fqn + assert real_fqn + + out = resolve_v2(real_fqn, hint_kind="symbol", graph=ladybug_graph) + assert out.success is True + assert out.status in ("one", "many") + assert out.absence is None From 41974e83f90c0535cabef1153bb3705ab23779b3 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:43:54 +0300 Subject: [PATCH 10/17] fix(absence): drop redundant graph-None guard on resolve empty path --- resolve_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolve_service.py b/resolve_service.py index 74bbfe2a..332e4663 100644 --- a/resolve_service.py +++ b/resolve_service.py @@ -631,7 +631,7 @@ def _resolve_finalize_success( # Absence diagnosis for empty results (status="none") cfg = _get_absence_config() diag: AbsenceDiagnosis | None = None - if not matches and graph is not None: + if not matches: g = graph vocab = get_vocabulary_index(g, cfg) # Map hint_kind to filter_kind (symbol -> "symbol", etc.) From 43e5befd466a9a6e6f3650fc2a33941c7b433de7 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 01:56:30 +0300 Subject: [PATCH 11/17] feat(absence): CLI envelope + renderer alignment - Add Envelope.absence field with serialization (omit when None) - Thread out.absence from resolve-none into Envelope - Extend renderer: not_found shows verdict + did-you-mean - Extend traversal empty: verdict line for all 4 verdicts - Extend listing empty: verdict line instead of bare '0 ' - Backward compatible: existing tests green Co-Authored-By: Claude --- java_codebase_rag/jrag_envelope.py | 11 +- java_codebase_rag/jrag_render.py | 69 ++++++- tests/test_jrag_envelope_absence.py | 305 ++++++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 tests/test_jrag_envelope_absence.py diff --git a/java_codebase_rag/jrag_envelope.py b/java_codebase_rag/jrag_envelope.py index 52c1e777..d7217014 100644 --- a/java_codebase_rag/jrag_envelope.py +++ b/java_codebase_rag/jrag_envelope.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field from typing import Any, Literal +from absence_types import AbsenceDiagnosis from graph_types import NodeRef __all__ = [ @@ -118,6 +119,9 @@ class Envelope: # zero in-repo callers. Distinguishes the *correct* empty result ("external # entrypoint — no in-repo callers") from a bug-looking bare "0 callers". is_external_entrypoint: bool = False + # Absence diagnosis explaining why a result is empty (PR-ABS-4). Carried + # from MCP outputs and rendered in CLI text/JSON. None on ok/ambiguous. + absence: AbsenceDiagnosis | None = None def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-ready dict, omitting empty optionals. @@ -151,6 +155,8 @@ def to_dict(self) -> dict[str, Any]: out["message"] = self.message if self.is_external_entrypoint: out["is_external_entrypoint"] = True + if self.absence is not None: + out["absence"] = self.absence.model_dump() return out def to_json(self) -> str: @@ -237,6 +243,8 @@ def _to_idfree_dict(self) -> dict[str, Any]: out["message"] = self.message if self.is_external_entrypoint: out["is_external_entrypoint"] = True + if self.absence is not None: + out["absence"] = self.absence.model_dump() return out @staticmethod @@ -610,7 +618,7 @@ def resolve_query( # the agent-facing CLI). if "jrag search" not in raw_msg: raw_msg = f"{raw_msg} Use `jrag search ` for ranked fuzzy lookup." - return None, Envelope(status="not_found", message=raw_msg) + return None, Envelope(status="not_found", message=raw_msg, absence=out.absence) # Listing breadcrumbs (root is None): 1–2 template hints pointing at the natural @@ -1082,4 +1090,5 @@ def project_envelope(envelope: Envelope, detail: str) -> Envelope: file_location=envelope.file_location, message=envelope.message, is_external_entrypoint=envelope.is_external_entrypoint, + absence=envelope.absence, ) diff --git a/java_codebase_rag/jrag_render.py b/java_codebase_rag/jrag_render.py index 979cbd13..824bf2e8 100644 --- a/java_codebase_rag/jrag_render.py +++ b/java_codebase_rag/jrag_render.py @@ -12,6 +12,7 @@ from typing import Any +from absence_types import AbsenceDiagnosis from java_codebase_rag.jrag_envelope import Envelope, project_envelope, simple_name __all__ = ["render", "tiered_name", "display_name"] @@ -226,7 +227,36 @@ def _render_error(envelope: Envelope) -> str: def _render_not_found(envelope: Envelope) -> str: msg = envelope.message or "not found" - return f"not found: {msg}" + base = f"not found: {msg}" + + # If absence diagnosis is present, append verdict + message (+ did-you-mean) + if envelope.absence is not None: + lines = [base] + # Add verdict line (human-readable) + verdict = envelope.absence.verdict + if verdict == "not_in_project": + lines.append("Verdict: not in project") + elif verdict == "external_dependency": + lines.append("Verdict: external dependency") + elif verdict == "refine_query": + lines.append("Verdict: refine your query") + elif verdict == "correct_empty": + lines.append("Verdict: correct empty") + + # Add did-you-mean line if closest_symbols is non-empty + if envelope.absence.closest_symbols: + symbols = [s.fqn for s in envelope.absence.closest_symbols] + if len(symbols) == 1: + lines.append(f"Did you mean: {symbols[0]}?") + elif len(symbols) == 2: + lines.append(f"Did you mean: {symbols[0]} or {symbols[1]}?") + else: + joined = ", ".join(symbols[:-1]) + f", or {symbols[-1]}" + lines.append(f"Did you mean: {joined}?") + + return "\n".join(lines) + + return base def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") -> str: @@ -295,7 +325,21 @@ def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") -> if rest: lines.extend(_render_inspect_block(rest, 1)) if not lines: - lines.append(f"0 {noun}".rstrip()) + # Handle absence diagnosis (PR-ABS-4) + if envelope.absence is not None: + verdict = envelope.absence.verdict + if verdict == "refine_query": + lines.append("Verdict: refine your query") + elif verdict == "not_in_project": + lines.append("Verdict: not in project") + elif verdict == "external_dependency": + lines.append("Verdict: external dependency") + elif verdict == "correct_empty": + lines.append("Verdict: correct empty") + else: + lines.append(f"0 {noun}".rstrip()) + else: + lines.append(f"0 {noun}".rstrip()) # Listing breadcrumbs (Phase 2): <=2 `next:` hint lines when the listing # command emitted agent_next_actions (routes/clients/producers/topics). lines.extend(_next_action_lines(envelope)) @@ -413,10 +457,29 @@ def _render_traversal(envelope: Envelope, *, noun: str, detail: str = "normal") root_node = envelope.nodes.get(root_id, {}) root_fqn = str(root_node.get("fqn") or "").strip() root_svc = str(root_node.get("microservice") or "").strip() - if envelope.is_external_entrypoint: + + # Handle absence diagnosis (PR-ABS-4) + if envelope.absence is not None: + verdict = envelope.absence.verdict + if verdict == "correct_empty": + # Same text as is_external_entrypoint case + parts = ["external entrypoint — no in-repo callers"] + elif verdict == "not_in_project": + lines.append("Verdict: not in project") + parts = [f"0 {noun}".rstrip()] + elif verdict == "external_dependency": + lines.append("Verdict: external dependency") + parts = [f"0 {noun}".rstrip()] + elif verdict == "refine_query": + lines.append("Verdict: refine your query") + parts = [f"0 {noun}".rstrip()] + else: + parts = [f"0 {noun}".rstrip()] + elif envelope.is_external_entrypoint: parts = ["external entrypoint — no in-repo callers"] else: parts = [f"0 {noun}".rstrip()] + if root_fqn: parts.append(root_fqn) if root_svc: diff --git a/tests/test_jrag_envelope_absence.py b/tests/test_jrag_envelope_absence.py new file mode 100644 index 00000000..2c31e0e2 --- /dev/null +++ b/tests/test_jrag_envelope_absence.py @@ -0,0 +1,305 @@ +"""Tests for PR-ABS-4: CLI envelope + renderer alignment with absence vocabulary. + +These tests verify that: +1. Envelope.absence is serialized correctly (present when set, omitted when None) +2. Renderer maps all 4 verdicts to appropriate text +3. is_external_entrypoint rendering is unchanged (backward compatibility) +4. resolve-none carries absence through to the envelope +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from absence_types import AbsenceDiagnosis, AbsenceProof, ExternalIdentity +from graph_types import NodeRef +from java_codebase_rag.jrag_envelope import Envelope, resolve_query +from java_codebase_rag.jrag_render import render +from resolve_service import ResolveOutput + + +# ----- Test 1: Envelope.absence serialization ----- + +def test_envelope_to_dict_omits_absence_when_none() -> None: + """Envelope with absence=None omits the field from to_dict().""" + env = Envelope(status="ok", absence=None) + out = env.to_dict() + assert "absence" not in out + assert out == {"status": "ok"} + + +def test_envelope_to_dict_includes_absence_when_present() -> None: + """Envelope with absence set includes the serialized diagnosis.""" + diagnosis = AbsenceDiagnosis( + verdict="not_in_project", + cause="identifier_miss", + message="Symbol not found in project", + closest_symbols=[NodeRef(id="sym:1", kind="symbol", fqn="com.foo.Bar")], + distances=[0.15], + proof=AbsenceProof( + nearest_distance=0.15, + symbol_count_scanned=1000, + thresholds_applied={"symbol": 0.3}, + query_shape="identifier", + ), + ) + env = Envelope(status="not_found", absence=diagnosis) + out = env.to_dict() + assert "absence" in out + assert out["absence"]["verdict"] == "not_in_project" + assert out["absence"]["message"] == "Symbol not found in project" + assert out["absence"]["closest_symbols"][0]["fqn"] == "com.foo.Bar" + + +def test_envelope_to_json_includes_absence() -> None: + """Envelope.to_json() includes absence when present.""" + diagnosis = AbsenceDiagnosis( + verdict="external_dependency", + cause="external", + message="External dependency", + external_identity=ExternalIdentity( + fqn="org.external.Lib", + reason="prefix", + source="maven", + ), + ) + env = Envelope(status="not_found", absence=diagnosis) + json_str = env.to_json() + assert "absence" in json_str + assert "external_dependency" in json_str + assert "org.external.Lib" in json_str + + +# ----- Test 2: Renderer mapping verdicts ----- + +def test_render_not_found_with_absence_verdict() -> None: + """_render_not_found appends verdict + message when envelope.absence is present.""" + diagnosis = AbsenceDiagnosis( + verdict="not_in_project", + cause="identifier_miss", + message="No matches found", + closest_symbols=[NodeRef(id="sym:1", kind="symbol", fqn="com.foo.SimilarThing")], + distances=[0.25], + ) + env = Envelope(status="not_found", absence=diagnosis, message="No matches for 'foo.Bar'.") + out = render(env, fmt="text") + assert "not found" in out.lower() + # Should include verdict information + assert "not_in_project" in out or "not in project" in out.lower() + # Should include did-you-mean line for closest_symbols + assert "did you mean" in out.lower() or "similar" in out.lower() + + +def test_render_not_found_with_closest_symbols() -> None: + """Renderer shows did-you-mean line when closest_symbols is non-empty.""" + diagnosis = AbsenceDiagnosis( + verdict="refine_query", + cause="nl_miss", + message="Query too broad", + closest_symbols=[ + NodeRef(id="sym:1", kind="symbol", fqn="com.foo.Bar"), + NodeRef(id="sym:2", kind="symbol", fqn="com.baz.Qux"), + ], + distances=[0.4, 0.5], + ) + env = Envelope(status="not_found", absence=diagnosis) + out = render(env, fmt="text") + # Should show the closest symbols as suggestions + assert "com.foo.Bar" in out or "Bar" in out + assert "com.baz.Qux" in out or "Qux" in out + + +def test_render_not_found_without_closest_symbols() -> None: + """Renderer handles empty closest_symbols gracefully.""" + diagnosis = AbsenceDiagnosis( + verdict="correct_empty", + cause="meaningful_empty", + message="This is correctly empty", + closest_symbols=[], + distances=[], + ) + env = Envelope(status="not_found", absence=diagnosis) + out = render(env, fmt="text") + assert "correct_empty" in out or "correct empty" in out.lower() + + +# ----- Test 3: Traversal empty block with is_external_entrypoint ----- + +def test_render_traversal_empty_external_entrypoint() -> None: + """Traversal empty block renders 'external entrypoint — no in-repo callers' when is_external_entrypoint=True.""" + env = Envelope( + status="ok", + nodes={}, + edges=[], + root="sym:1", + is_external_entrypoint=True, + ) + env.nodes["sym:1"] = {"fqn": "com.foo.Controller.handle", "microservice": "foo-svc"} + out = render(env, fmt="text", noun="callers", shape="traversal") + assert "external entrypoint" in out.lower() + assert "no in-repo callers" in out.lower() + assert "com.foo.Controller.handle" in out + + +def test_render_traversal_empty_with_correct_empty_verdict() -> None: + """Traversal empty block with absence.verdict='correct_empty' renders same text as is_external_entrypoint.""" + diagnosis = AbsenceDiagnosis( + verdict="correct_empty", + cause="meaningful_empty", + message="External entrypoint with no in-repo callers", + ) + env = Envelope( + status="ok", + nodes={}, + edges=[], + root="sym:1", + absence=diagnosis, + ) + env.nodes["sym:1"] = {"fqn": "com.foo.Controller.handle", "microservice": "foo-svc"} + out = render(env, fmt="text", noun="callers", shape="traversal") + # Should render similar to is_external_entryponit case + assert "external entrypoint" in out.lower() or "correct empty" in out.lower() + + +def test_render_traversal_empty_with_other_verdicts() -> None: + """Traversal empty block with other verdicts renders short verdict line.""" + # not_in_project verdict + diagnosis = AbsenceDiagnosis( + verdict="not_in_project", + cause="identifier_miss", + message="Symbol not in project", + ) + env = Envelope( + status="ok", + nodes={}, + edges=[], + root="sym:1", + absence=diagnosis, + ) + env.nodes["sym:1"] = {"fqn": "com.foo.Missing", "microservice": "foo-svc"} + out = render(env, fmt="text", noun="callers", shape="traversal") + assert "not in project" in out.lower() or "not_in_project" in out + # Should still show the root FQN + assert "com.foo.Missing" in out + + +# ----- Test 4: Listing empty with absence ----- + +def test_render_listing_empty_with_absence() -> None: + """Listing empty renders verdict line before/instead of bare '0 '.""" + diagnosis = AbsenceDiagnosis( + verdict="refine_query", + cause="filter_miss", + message="Filter too restrictive", + ) + env = Envelope(status="ok", nodes={}, absence=diagnosis) + out = render(env, fmt="text", noun="routes") + assert "refine" in out.lower() or "refine_query" in out + # The verdict line should replace or augment the bare "0 routes" + lines = out.splitlines() + assert any("refine" in line.lower() for line in lines) + + +# ----- Test 5: Resolve-none carries absence ----- + +def test_resolve_none_carries_absence_to_envelope() -> None: + """resolve with status='none' (and out.absence) returns envelope carrying absence.""" + # Mock the graph and resolve_v2 + mock_graph = MagicMock() + mock_output = ResolveOutput( + success=True, + status="none", + node=None, + candidates=[], + message="No matches found", + absence=AbsenceDiagnosis( + verdict="not_in_project", + cause="identifier_miss", + message="Symbol not in project", + closest_symbols=[NodeRef(id="sym:1", kind="symbol", fqn="com.foo.Similar")], + distances=[0.2], + ), + ) + + # Mock resolve_v2 to return our test output + from resolve_service import resolve_v2 + original_resolve = resolve_v2 + import resolve_service + resolve_service.resolve_v2 = lambda *args, **kwargs: mock_output + + try: + _, env = resolve_query( + "com.foo.Missing", + graph=mock_graph, + hint_kind=None, + java_kind=None, + role=None, + fqn_contains=None, + cfg=None, + ) + assert env is not None + assert env.status == "not_found" + assert env.absence is not None + assert env.absence.verdict == "not_in_project" + assert env.absence.message == "Symbol not in project" + finally: + resolve_service.resolve_v2 = original_resolve + + +def test_resolve_without_absence() -> None: + """resolve with status='none' but no absence field returns envelope without absence.""" + mock_graph = MagicMock() + mock_output = ResolveOutput( + success=True, + status="none", + node=None, + candidates=[], + message="No matches found", + absence=None, # No diagnosis + ) + + from resolve_service import resolve_v2 + original_resolve = resolve_v2 + import resolve_service + resolve_service.resolve_v2 = lambda *args, **kwargs: mock_output + + try: + _, env = resolve_query( + "com.foo.Missing", + graph=mock_graph, + hint_kind=None, + java_kind=None, + role=None, + fqn_contains=None, + cfg=None, + ) + assert env is not None + assert env.status == "not_found" + assert env.absence is None + # Should still have the fallback message + assert "jrag search" in env.message + finally: + resolve_service.resolve_v2 = original_resolve + + +# ----- Test 6: All four verdicts render correctly ----- + +def test_all_verdicts_render_in_not_found() -> None: + """Each of the 4 verdicts renders appropriately in not_found context.""" + verdicts_messages = [ + ("refine_query", "Query needs refinement", "refine"), + ("not_in_project", "Not in this codebase", "not in project"), + ("external_dependency", "External library", "external"), + ("correct_empty", "Correctly empty", "correct empty"), + ] + + for verdict, msg, expected_text in verdicts_messages: + diagnosis = AbsenceDiagnosis( + verdict=verdict, + cause="identifier_miss" if verdict == "not_in_project" else "meaningful_empty", + message=msg, + ) + env = Envelope(status="not_found", absence=diagnosis) + out = render(env, fmt="text") + assert expected_text in out.lower(), f"Verdict {verdict} should render '{expected_text}' in output: {out}" From 3447d35aa65fb23c33324ec5847b1fed166f7d60 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Wed, 8 Jul 2026 02:00:13 +0300 Subject: [PATCH 12/17] docs(absence): verdict vocabulary + config knobs --- docs/AGENT-GUIDE.md | 8 ++++---- docs/CONFIGURATION.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 266f12c1..4bebfc7e 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -26,7 +26,7 @@ Copy the block between `