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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ lancedb_data/
cocoindex_java_lance.db/
*.kuzu
*.kuzu.wal
# Generated vocabulary-index sidecar (absence diagnosis; rebuilt on demand)
vocab_index.json
vocab_index.json.tmp

# Local env files
.env
Expand Down
14 changes: 9 additions & 5 deletions absence_diagnosis.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,15 @@ def _did_you_mean(
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.
"""Conservative threshold policy with a single deciding band.

Returns (verdict, cause, proof). ``not_in_project`` only when best
similarity < ``absence_absent_floor`` AND the query is identifier-shaped;
everything else (a close hit OR the middle band) → ``refine_query``, so a
real symbol is never falsely declared absent. ``absence_close_threshold``
is NOT a decider on this path (it decides the *find* path's "close hit
excluded by filter/scope" branch at the ``best_sim >= close`` check) — it
is recorded in ``proof.thresholds_applied`` for transparency only.
"""
close = float(getattr(cfg, "absence_close_threshold", 0.85))
floor = float(getattr(cfg, "absence_absent_floor", 0.40))
Expand Down
44 changes: 33 additions & 11 deletions absence_vocab.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
Expand All @@ -27,6 +28,10 @@

VOCAB_INDEX_FILENAME = "vocab_index.json"

# Sidecar schema version. Bump when the on-disk JSON shape changes; load() rejects
# a mismatch as stale (→ rebuild) so an old-format sidecar is never misread.
FORMAT_VERSION = 1


class VocabIndexStale(Exception):
"""Raised when loading a vocab index with stale ontology_version."""
Expand Down Expand Up @@ -148,7 +153,7 @@ def save(self, path: Path, *, ontology_version: int) -> None:
import time

data = {
"format_version": 1,
"format_version": FORMAT_VERSION,
"ontology_version": ontology_version,
"built_at": int(time.time()),
"symbol_count": self.symbol_count,
Expand All @@ -168,12 +173,20 @@ def save(self, path: Path, *, ontology_version: int) -> None:
for r in self.records
],
"ngrams": self.ngram_index,
"name_index": self._name_index,
# _name_index is intentionally NOT persisted: it is derivable from
# records and rebuilt in __init__ on load (single source of truth,
# no sidecar bloat).
}

path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f)
# Atomic write — dump to a temp sibling, then os.replace onto the target.
# A crash mid-write leaves either the previous complete file or the new
# complete file, never a truncated/corrupt sidecar (readers see one or
# the other atomically; os.replace is atomic on the same filesystem).
tmp_path = path.with_name(path.name + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp_path, path)

log.debug(f"VocabularyIndex saved to {path} ({self.symbol_count} symbols)")

Expand All @@ -188,14 +201,22 @@ def load(cls, path: Path) -> "VocabularyIndex":
VocabularyIndex

Raises:
VocabIndexStale: If sidecar ontology_version doesn't match expected
VocabIndexStale: If sidecar format_version or ontology_version
doesn't match expected
"""
from ast_java import ONTOLOGY_VERSION

with open(path) as f:
with open(path, encoding="utf-8") as f:
data = json.load(f)

# Check ontology version
# Check format version (sidecar JSON schema) first.
if data.get("format_version") != FORMAT_VERSION:
raise VocabIndexStale(
f"Vocab index format_version {data.get('format_version')} "
f"does not match expected {FORMAT_VERSION}"
)

# Check ontology version (graph schema the index was built against).
if data.get("ontology_version") != ONTOLOGY_VERSION:
raise VocabIndexStale(
f"Vocab index ontology version {data.get('ontology_version')} "
Expand All @@ -217,11 +238,11 @@ def load(cls, path: Path) -> "VocabularyIndex":
for r in data["records"]
]

# _name_index is rebuilt from records in __init__ (not persisted).
return cls(
records=records,
ngram_index=data["ngrams"],
q=data["q"],
_name_index=data.get("name_index"),
)

def lookup(self, name: str, *, limit: int) -> list[SymbolRecord]:
Expand Down Expand Up @@ -349,9 +370,10 @@ def get_vocabulary_index(graph: Any, cfg: Any) -> VocabularyIndex:
_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")
except Exception as e:
# Stale (VocabIndexStale), missing (FileNotFoundError), or corrupt
# (JSONDecodeError/KeyError) — all subsumed by Exception; rebuild.
log.debug(f"Vocab index missing/stale/corrupt ({e}), rebuilding from graph")

# Build from graph
index = VocabularyIndex.build(graph, q=cfg.absence_ngram_q)
Expand Down
67 changes: 34 additions & 33 deletions java_codebase_rag/jrag_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@
# none) and are left untagged.
_ROUTE_KIND_TAGS: dict[str, str] = {"kafka_topic": "kafka", "http_endpoint": "http"}

# Absence verdict → human-readable label, shared by the not-found / listing /
# traversal empty-result renderers. ``AbsenceVerdict`` is a closed Literal of
# these four values.
_ABSENCE_VERDICT_TEXT: dict[str, str] = {
"not_in_project": "not in project",
"external_dependency": "external dependency",
"refine_query": "refine your query",
"correct_empty": "correct empty",
}


def _verdict_line(absence: AbsenceDiagnosis) -> str | None:
"""A ``Verdict: <label>`` line for an absence diagnosis, or ``None`` if the
verdict is not one of the known values."""
text = _ABSENCE_VERDICT_TEXT.get(absence.verdict)
return f"Verdict: {text}" if text else None

# Identity keys already represented in a listing line (display_name + @service +
# kind tag). At ``--detail full`` the per-row kv-block skips these (they are in
# the header line) and renders every OTHER key, so full listing == per-row
Expand Down Expand Up @@ -232,16 +249,15 @@ def _render_not_found(envelope: Envelope) -> str:
# 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")
# Verdict line (human-readable label)
vline = _verdict_line(envelope.absence)
if vline:
lines.append(vline)

# Per-cause explanation message — surfaces the diagnosis's authored help
# (external-identity context, filter-relaxation suggestions, etc.).
if envelope.absence.message:
lines.append(envelope.absence.message)

# Add did-you-mean line if closest_symbols is non-empty
if envelope.absence.closest_symbols:
Expand Down Expand Up @@ -327,17 +343,8 @@ def _render_listing(envelope: Envelope, *, noun: str, detail: str = "normal") ->
if not lines:
# 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())
vline = _verdict_line(envelope.absence)
lines.append(vline if vline else f"0 {noun}".rstrip())
else:
lines.append(f"0 {noun}".rstrip())
# Listing breadcrumbs (Phase 2): <=2 `next:` hint lines when the listing
Expand Down Expand Up @@ -460,20 +467,14 @@ def _render_traversal(envelope: Envelope, *, noun: str, detail: str = "normal")

# 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
absence = envelope.absence
if absence.verdict == "correct_empty":
# Same text as the 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:
vline = _verdict_line(absence)
if vline:
lines.append(vline)
parts = [f"0 {noun}".rstrip()]
elif envelope.is_external_entrypoint:
parts = ["external entrypoint — no in-repo callers"]
Expand Down
1 change: 0 additions & 1 deletion vocab_index.json

This file was deleted.

Loading