diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a4b7562..293ca08 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,29 @@ jobs:
- name: run tests
run: pytest graphsight-langgraph/tests graphsight/tests -q
+ # The graph engine: scoring maths plus the real LadybugDB path (schema,
+ # migration, MERGE semantics). Deliberately avoids torch/spacy — these tests
+ # stub the embedder, so the whole job installs in seconds.
+ engine-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: install engine test deps
+ run: pip install pytest ladybug numpy pandas requests tqdm
+ # named explicitly: the rest of backend/tests needs the full API stack
+ - name: run engine tests
+ run: >
+ pytest -q
+ tests/test_recency.py
+ tests/test_router_scoring.py
+ tests/test_github_graph.py
+ tests/test_db_integration.py
+ tests/test_ingest_github_wiring.py
+ working-directory: backend
+
frontend-build:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index 5e57e74..79c0ac4 100644
--- a/README.md
+++ b/README.md
@@ -285,10 +285,25 @@ hf-space/ # HuggingFace Space deployment (own git remote)
### Data model
-A single generic `Entity` node table (`id, label, type, embedding`) keeps the schema dynamic
+A single generic `Entity` node table (`id, label, type, ts, embedding`) keeps the schema dynamic
across all entity labels. `Document` nodes store one **sliding-window chunk** each (with raw
`content` for generation). Edges: `Document -[MENTIONS]-> Entity` and `Entity -[RELATES_TO]->
-Entity` (only between entities co-occurring in the *same* window — no document-wide hairball).
+Entity`, carrying `confidence, relation, ts`.
+
+`relation` is one of `AUTHORED · RESOLVES · REVIEWED · TOUCHES · PART_OF · REPORTED ·
+CO_OCCURS`, each with its own weight (`config.RELATION_WEIGHTS`). Text ingestion can only
+produce `CO_OCCURS` — same-window proximity, weighted lowest. Structured ingestion reads the
+rest from the source API, so a two-hop `AUTHORED → RESOLVES` chain outranks a one-hop guess.
+An edge **upgrades** if structure later proves a guess right; it never downgrades.
+
+`ts` is the underlying event time (0 when unknown), which drives recency decay:
+`score *= 0.5 ** (age_days / half_life)`, floored at `RECENCY_FLOOR`. Undated entities are
+left alone rather than penalised.
+
+**Opening an older store migrates it in place.** `init_schema()` adds the missing columns via
+`ALTER TABLE ... ADD ... DEFAULT`, keeping every existing row, and reprices legacy edges —
+the pre-0.3 writer stored them all at a degenerate `1.0`, which both flattened traversal and
+would have outranked every typed relation. Reingest to populate real timestamps and relations.
### High-Level Design (HLD)
@@ -927,6 +942,13 @@ python -m spacy download en_core_web_sm # fallback NER
# 2. ingest your datasets/ (writes memory.lbug, builds the HNSW index)
python scripts/ingest.py --datasets ./datasets --reset
+# 2b. or ingest a GitHub repo. Two passes land in one graph: PR prose through
+# extraction, and the same payloads through GitHubGraphBuilder, which reads
+# authorship, reviews, "Fixes #N" and touched files as typed, dated edges.
+python scripts/ingest_github.py --repo pallets/click --issues 50 --commits 50 --files
+# --files costs one extra API request per PR; without it there are no
+# TOUCHES edges. --no-text skips extraction and writes structure only.
+
# 3. evaluate (optional)
python scripts/benchmark.py # -> results.csv + summary table
diff --git a/backend/scripts/ingest_github.py b/backend/scripts/ingest_github.py
index 17e4644..5cca3c6 100644
--- a/backend/scripts/ingest_github.py
+++ b/backend/scripts/ingest_github.py
@@ -2,6 +2,14 @@
python scripts/ingest_github.py --repo langchain-ai/langchain
python scripts/ingest_github.py --repo owner/repo --limit 100 --reset
+ python scripts/ingest_github.py --repo owner/repo --issues 50 --commits 50 --files
+
+Two passes write into the same graph:
+
+ * text — PR prose through extraction + curation (fuzzy CO_OCCURS edges)
+ * structure — the same payloads through GitHubGraphBuilder, which reads
+ authorship, reviews, "Fixes #N" and touched files as typed, timestamped
+ edges. These are facts from the API, not guesses from word proximity.
Set GITHUB_TOKEN in .env to raise the rate limit from 60/hr to 5000/hr.
"""
@@ -26,6 +34,7 @@
from tracerag.db import TraceDB # noqa: E402
from tracerag.extract import EntityExtractor # noqa: E402
from tracerag.curation import CurationEngine, IngestStats # noqa: E402
+from tracerag.github_graph import GitHubGraphBuilder # noqa: E402
from ingest import ingest_text # noqa: E402
logger = logging.getLogger("tracerag.github")
@@ -59,34 +68,71 @@ def assemble_text(pr: dict) -> tuple[str, str]:
return f"pr-{number}", text
-def fetch_merged_prs(repo: str, limit: int, token: str | None) -> list[dict]:
- """Page through closed PRs, keeping only merged ones (merged_at != null), until limit."""
- headers = {
+def _headers(token: str | None) -> dict[str, str]:
+ h = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
if token:
- headers["Authorization"] = f"Bearer {token}"
+ h["Authorization"] = f"Bearer {token}"
+ return h
+
+
+def _get(url: str, token: str | None, params: dict | None = None):
+ resp = requests.get(url, headers=_headers(token), params=params, timeout=30)
+ if resp.status_code != 200:
+ raise RuntimeError(f"GitHub API {resp.status_code} for {url}: {resp.text[:200]}")
+ return resp.json()
+
- merged: list[dict] = []
+def _paged(url: str, limit: int, token: str | None, params: dict,
+ keep=lambda item: True) -> list[dict]:
+ """Page an endpoint until `limit` kept items, or the listing runs out."""
+ out: list[dict] = []
page = 1
- while len(merged) < limit:
- resp = requests.get(
- f"{GITHUB_API}/repos/{repo}/pulls",
- headers=headers,
- params={"state": "closed", "per_page": _PER_PAGE, "page": page},
- timeout=30,
- )
- if resp.status_code != 200:
- raise RuntimeError(
- f"GitHub API {resp.status_code} for {repo}: {resp.text[:200]}"
- )
- batch = resp.json()
+ while len(out) < limit:
+ batch = _get(url, token, {**params, "per_page": _PER_PAGE, "page": page})
if not batch:
break
- merged.extend(pr for pr in batch if pr.get("merged_at"))
+ out.extend(item for item in batch if keep(item))
page += 1
- return merged[:limit]
+ return out[:limit]
+
+
+def fetch_merged_prs(repo: str, limit: int, token: str | None) -> list[dict]:
+ """Page through closed PRs, keeping only merged ones (merged_at != null), until limit."""
+ return _paged(
+ f"{GITHUB_API}/repos/{repo}/pulls", limit, token,
+ {"state": "closed"}, keep=lambda pr: bool(pr.get("merged_at")),
+ )
+
+
+def fetch_issues(repo: str, limit: int, token: str | None) -> list[dict]:
+ """Issues only — the endpoint also returns PRs, which the builder skips."""
+ if limit <= 0:
+ return []
+ return _paged(
+ f"{GITHUB_API}/repos/{repo}/issues", limit, token,
+ {"state": "all"}, keep=lambda i: "pull_request" not in i,
+ )
+
+
+def fetch_commits(repo: str, limit: int, token: str | None) -> list[dict]:
+ if limit <= 0:
+ return []
+ return _paged(f"{GITHUB_API}/repos/{repo}/commits", limit, token, {})
+
+
+def attach_pr_files(repo: str, prs: list[dict], token: str | None) -> None:
+ """Fill each PR's `files` in place — one extra request per PR, so opt-in."""
+ for pr in tqdm(prs, desc=f"{repo} files", unit="pr", leave=False):
+ try:
+ pr["files"] = _get(
+ f"{GITHUB_API}/repos/{repo}/pulls/{pr['number']}/files",
+ token, {"per_page": _PER_PAGE},
+ )
+ except Exception as exc: # noqa: BLE001 — a missing file list isn't fatal
+ logger.debug("[%s] files for #%s failed: %s", repo, pr.get("number"), exc)
def repo_db_path(repo: str, graphs_dir: Path) -> Path:
@@ -101,6 +147,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help='One or more repos, e.g. "pallets/flask psf/requests".')
p.add_argument("--limit", type=int, default=50,
help="Max MERGED PRs to ingest per repo (protects rate limits).")
+ p.add_argument("--issues", type=int, default=0,
+ help="Also ingest up to N issues (REPORTED edges, RESOLVES targets).")
+ p.add_argument("--commits", type=int, default=0,
+ help="Also ingest up to N commits (AUTHORED + 'Fixes #N' edges).")
+ p.add_argument("--files", action="store_true",
+ help="Fetch each PR's changed files for TOUCHES edges. "
+ "Costs one extra API request per PR.")
+ p.add_argument("--no-text", action="store_true",
+ help="Skip the prose/curation pass; write structured edges only.")
p.add_argument("--db", type=Path, default=None,
help="Override output file (single-repo only; otherwise one "
"per-repo file is created under --graphs-dir).")
@@ -113,33 +168,48 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
def ingest_repo(
- repo: str, db_path: Path, limit: int, reset: bool,
- token: str | None, extractor: EntityExtractor,
+ repo: str, db_path: Path, args: argparse.Namespace,
+ token: str | None, extractor: EntityExtractor | None,
) -> None:
- """Fetch + ingest one repo's merged PRs into its own .lbug file."""
+ """Fetch + ingest one repo into its own .lbug file (text pass, then structure)."""
db_path.parent.mkdir(parents=True, exist_ok=True)
- if reset:
+ if args.reset:
for p in sorted(db_path.parent.glob(db_path.name + "*")):
logger.info("[%s] reset: removing %s", repo, p.name)
p.unlink()
- logger.info("[%s] fetching up to %d merged PRs", repo, limit)
- prs = fetch_merged_prs(repo, limit, token)
- logger.info("[%s] fetched %d merged PRs -> %s", repo, len(prs), db_path.name)
+ logger.info("[%s] fetching up to %d merged PRs", repo, args.limit)
+ prs = fetch_merged_prs(repo, args.limit, token)
+ issues = fetch_issues(repo, args.issues, token)
+ commits = fetch_commits(repo, args.commits, token)
+ if args.files:
+ attach_pr_files(repo, prs, token)
+ logger.info(
+ "[%s] fetched %d PRs, %d issues, %d commits -> %s",
+ repo, len(prs), len(issues), len(commits), db_path.name,
+ )
db = TraceDB(db_path)
db.init_schema()
engine = CurationEngine(db)
totals, skipped = IngestStats(), 0
try:
- for pr in tqdm(prs, total=len(prs), desc=f"{repo}", unit="pr"):
- doc_id, text = assemble_text(pr)
- if not text.strip():
- skipped += 1
- continue
- totals.merge(
- ingest_text(engine, extractor, doc_id, text, source=pr.get("html_url"))
- )
+ if not args.no_text:
+ for pr in tqdm(prs, total=len(prs), desc=f"{repo}", unit="pr"):
+ doc_id, text = assemble_text(pr)
+ if not text.strip():
+ skipped += 1
+ continue
+ totals.merge(
+ ingest_text(engine, extractor, doc_id, text,
+ source=pr.get("html_url"))
+ )
+
+ # structured pass: relations read from the payload, not inferred. Runs
+ # after the text pass so a CO_OCCURS guess gets upgraded, not the reverse.
+ builder = GitHubGraphBuilder(db, engine.embed, repo)
+ graph = builder.build(pulls=prs, issues=issues, commits=commits)
+
db.build_vector_index()
logger.info(
"[%s] done. %d PRs (%d skipped) | %d entities | "
@@ -150,6 +220,14 @@ def ingest_repo(
totals.ollama_calls, totals.relates_edges, totals.mentions_edges,
db.count_nodes(),
)
+ logger.info(
+ "[%s] structure: %d nodes, %d typed edges %s",
+ repo, graph.nodes, graph.edges,
+ dict(sorted(graph.by_relation.items(), key=lambda kv: -kv[1])),
+ )
+ if not args.files:
+ logger.info("[%s] no TOUCHES edges from files — rerun with --files "
+ "to link PRs to the code they changed.", repo)
finally:
db.close()
@@ -176,14 +254,15 @@ def main(argv: list[str] | None = None) -> int:
if args.db and len(args.repo) > 1:
logger.warning("--db is ignored with multiple repos; using per-repo files.")
- extractor = EntityExtractor()
+ # --no-text skips extraction entirely; don't pay to load the models
+ extractor = None if args.no_text else EntityExtractor()
for repo in args.repo:
if args.db and len(args.repo) == 1:
db_path = args.db if args.db.is_absolute() else (repo_root / args.db)
else:
db_path = repo_db_path(repo, graphs_dir)
try:
- ingest_repo(repo, db_path, args.limit, args.reset, token, extractor)
+ ingest_repo(repo, db_path, args, token, extractor)
except Exception as exc: # noqa: BLE001
logger.error("[%s] FAILED: %s", repo, exc)
diff --git a/backend/tests/test_db_integration.py b/backend/tests/test_db_integration.py
new file mode 100644
index 0000000..7da008d
--- /dev/null
+++ b/backend/tests/test_db_integration.py
@@ -0,0 +1,279 @@
+"""End-to-end against a real .lbug store.
+
+The unit tests exercise the scoring maths against a fake store; these run the
+actual DDL, MERGE ... ON MATCH clauses and vector index through LadybugDB, so a
+Cypher typo or a bad migration fails here instead of in production.
+
+Skipped when the driver isn't installed: pip install ladybug
+"""
+import shutil
+import time
+
+import pytest
+
+lb = pytest.importorskip("ladybug", reason="LadybugDB driver not installed")
+
+from tracerag import config # noqa: E402
+from tracerag.db import TraceDB # noqa: E402
+from tracerag.github_graph import GitHubGraphBuilder # noqa: E402
+from tracerag.router import TraceRouter # noqa: E402
+
+DAY = 86400
+NOW = int(time.time())
+
+
+def vec(*weights: float) -> list[float]:
+ """Unit-ish embedding: the given weights, zero-padded to EMBED_DIM."""
+ v = list(weights) + [0.0] * (config.EMBED_DIM - len(weights))
+ return [float(x) for x in v]
+
+
+QUERY_VEC = vec(1.0)
+NEAR = vec(0.93, 0.37) # cosine ~0.93 to QUERY_VEC
+FAR = vec(0.45, 0.89) # cosine ~0.45
+
+
+@pytest.fixture
+def db(tmp_path):
+ store = tmp_path / "t.lbug"
+ d = TraceDB(store, pool_size=2)
+ d.init_schema()
+ yield d
+ d.close()
+ shutil.rmtree(store, ignore_errors=True)
+
+
+def add(d: TraceDB, node_id, label, ntype, embedding=None, ts=0):
+ d.upsert_node(node_id, label, ntype, embedding or vec(0.5, 0.5), ts=ts)
+ return node_id
+
+
+# --- schema ---------------------------------------------------------------
+
+def test_schema_has_the_new_columns(db):
+ assert "ts" in db.table_columns(config.NODE_TABLE)
+ assert {"relation", "ts"} <= db.table_columns(config.REL_TABLE)
+
+
+def test_fresh_store_needs_no_migration(db):
+ assert db.migrate_schema() == []
+
+
+def test_pre_03_store_migrates_in_place(tmp_path):
+ """An old .lbug must keep its data and gain the columns — not be deleted."""
+ store = tmp_path / "old.lbug"
+ raw = lb.Database(str(store))
+ conn = lb.Connection(raw)
+ conn.execute(
+ f"CREATE NODE TABLE {config.NODE_TABLE} (id STRING PRIMARY KEY, "
+ f"label STRING, type STRING, embedding FLOAT[{config.EMBED_DIM}]);"
+ )
+ conn.execute(
+ f"CREATE REL TABLE {config.REL_TABLE} "
+ f"(FROM {config.NODE_TABLE} TO {config.NODE_TABLE}, confidence DOUBLE);"
+ )
+ conn.execute(
+ f"CREATE (:{config.NODE_TABLE} {{id:'legacy', label:'Legacy', "
+ f"type:'PR', embedding:{QUERY_VEC}}});"
+ )
+ conn.execute(
+ f"CREATE (:{config.NODE_TABLE} {{id:'legacy2', label:'Legacy2', "
+ f"type:'PR', embedding:{FAR}}});"
+ )
+ # the old writer stored every edge at a degenerate 1.0
+ conn.execute(
+ f"MATCH (a:{config.NODE_TABLE}{{id:'legacy'}}), "
+ f"(b:{config.NODE_TABLE}{{id:'legacy2'}}) "
+ f"CREATE (a)-[:{config.REL_TABLE} {{confidence: 1.0}}]->(b);"
+ )
+ conn.close()
+ raw.close()
+
+ d = TraceDB(store, pool_size=1)
+ added = d.migrate_schema()
+ assert set(added) == {
+ f"{config.NODE_TABLE}.ts",
+ f"{config.REL_TABLE}.relation",
+ f"{config.REL_TABLE}.ts",
+ }
+ # the pre-existing rows survived and read back with the column default
+ rows = d._fetch(
+ f"MATCH (e:{config.NODE_TABLE}) RETURN e.id AS id, e.ts AS ts "
+ f"ORDER BY e.id;")
+ assert rows == [{"id": "legacy", "ts": 0}, {"id": "legacy2", "ts": 0}]
+ assert edge(d)["relation"] == config.RELATION_CO_OCCURS
+
+ # the degenerate 1.0 is repriced, so a typed relation can now outrank it
+ legacy_conf = edge(d)["confidence"]
+ assert legacy_conf == pytest.approx(
+ config.RELATION_WEIGHTS[config.RELATION_CO_OCCURS])
+ assert legacy_conf < config.RELATION_WEIGHTS[config.RELATION_AUTHORED]
+
+ # ...and upgrade it, which a 1.0 edge could never have allowed
+ d.add_relationship("legacy", "legacy2",
+ relation=config.RELATION_AUTHORED, ts=NOW)
+ assert edge(d)["relation"] == config.RELATION_AUTHORED
+
+ # the migrated store accepts a fresh typed, timestamped write
+ add(d, "new", "New", "PR", NEAR, ts=NOW)
+ d.add_relationship("legacy", "new", relation=config.RELATION_RESOLVES, ts=NOW)
+ assert d.migrate_schema() == [] # idempotent
+ d.close()
+
+
+# --- nodes ----------------------------------------------------------------
+
+def test_node_timestamp_only_moves_forward(db):
+ add(db, "n", "N", "PR", ts=NOW - 10 * DAY)
+ add(db, "n", "N", "PR", ts=NOW) # newer event wins
+ add(db, "n", "N", "PR", ts=NOW - 90 * DAY) # older event must not roll it back
+ rows = db._fetch(
+ f"MATCH (e:{config.NODE_TABLE} {{id:'n'}}) RETURN e.ts AS ts;")
+ assert rows[0]["ts"] == NOW
+
+
+def test_undated_rewrite_over_a_dated_row_does_not_overflow(db):
+ """Regression: $ts=0 was inferred as INT8, so the CASE overflowed against a
+ real epoch value. Re-ingesting an undated item must just be a no-op."""
+ add(db, "n", "N", "PR", ts=NOW)
+ add(db, "n", "N", "PR", ts=0)
+ add(db, "m", "M", "PR")
+ db.add_relationship("n", "m", relation=config.RELATION_AUTHORED, ts=NOW)
+ db.add_relationship("n", "m", relation=config.RELATION_AUTHORED, ts=0)
+ node = db._fetch(
+ f"MATCH (e:{config.NODE_TABLE} {{id:'n'}}) RETURN e.ts AS ts;")
+ assert node[0]["ts"] == NOW
+ assert edge(db)["ts"] == NOW
+
+
+# --- edges ----------------------------------------------------------------
+
+def edge(db) -> dict:
+ rows = db._fetch(
+ f"MATCH ()-[r:{config.REL_TABLE}]->() "
+ f"RETURN r.confidence AS confidence, r.relation AS relation, r.ts AS ts;")
+ return rows[0]
+
+
+def test_relation_defaults_to_its_configured_weight(db):
+ add(db, "a", "A", "Person")
+ add(db, "b", "B", "PR")
+ db.add_relationship("a", "b", relation=config.RELATION_AUTHORED, ts=NOW)
+ e = edge(db)
+ assert e["relation"] == config.RELATION_AUTHORED
+ assert e["confidence"] == pytest.approx(
+ config.RELATION_WEIGHTS[config.RELATION_AUTHORED])
+ assert e["ts"] == NOW
+
+
+def test_proximity_edge_upgrades_when_structure_proves_it(db):
+ add(db, "a", "A", "Person")
+ add(db, "b", "B", "PR")
+ db.add_relationship("a", "b", relation=config.RELATION_CO_OCCURS)
+ db.add_relationship("a", "b", relation=config.RELATION_AUTHORED, ts=NOW)
+ e = edge(db)
+ assert e["relation"] == config.RELATION_AUTHORED
+ assert e["confidence"] == pytest.approx(
+ config.RELATION_WEIGHTS[config.RELATION_AUTHORED])
+
+
+def test_a_later_guess_never_downgrades_a_known_edge(db):
+ add(db, "a", "A", "Person")
+ add(db, "b", "B", "PR")
+ db.add_relationship("a", "b", relation=config.RELATION_AUTHORED, ts=NOW)
+ db.add_relationship("a", "b", relation=config.RELATION_CO_OCCURS)
+ assert edge(db)["relation"] == config.RELATION_AUTHORED
+
+
+def test_expand_frontier_carries_relation_and_ts(db):
+ add(db, "a", "A", "Person")
+ add(db, "b", "B", "PR", ts=NOW - 3 * DAY)
+ db.add_relationship("a", "b", relation=config.RELATION_AUTHORED, ts=NOW)
+ nbrs = db.expand_frontier(["a"], k=5, max_degree=10)["a"]
+ assert nbrs[0]["relation"] == config.RELATION_AUTHORED
+ assert nbrs[0]["ts"] == NOW - 3 * DAY
+
+
+def test_subgraph_carries_relation_for_the_canvas(db):
+ add(db, "a", "A", "Person")
+ add(db, "b", "B", "PR")
+ db.add_relationship("a", "b", relation=config.RELATION_AUTHORED, ts=NOW)
+ sub = db.subgraph(["a"])
+ assert sub["edges"][0]["relation"] == config.RELATION_AUTHORED
+
+
+def test_vector_search_carries_ts(db):
+ add(db, "a", "A", "PR", NEAR, ts=NOW - 5 * DAY)
+ db.build_vector_index()
+ hit = db.vector_search(QUERY_VEC, k=1)[0]
+ assert hit["id"] == "a"
+ assert hit["ts"] == NOW - 5 * DAY
+
+
+# --- github builder against the real store --------------------------------
+
+PULL = {
+ "number": 412, "title": "Fix token refresh", "merged_at": "2026-07-27T10:00:00Z",
+ "user": {"login": "vishal"},
+ "requested_reviewers": [{"login": "arush"}],
+ "body": "Fixes #77",
+ "files": [{"filename": "auth/session.py"}],
+}
+
+
+def test_builder_writes_typed_edges_into_a_real_store(db):
+ b = GitHubGraphBuilder(db, lambda text: vec(0.5, 0.5), "acme/api")
+ stats = b.build(pulls=[PULL])
+ rows = db._fetch(
+ f"MATCH (a:{config.NODE_TABLE})-[r:{config.REL_TABLE}]->"
+ f"(c:{config.NODE_TABLE}) "
+ f"RETURN a.id AS src, c.id AS dst, r.relation AS relation;")
+ seen = {(r["src"], r["dst"], r["relation"]) for r in rows}
+ assert ("person:vishal", "pr:acme/api#412",
+ config.RELATION_AUTHORED) in seen
+ assert ("person:arush", "pr:acme/api#412",
+ config.RELATION_REVIEWED) in seen
+ assert ("pr:acme/api#412", "issue:acme/api#77",
+ config.RELATION_RESOLVES) in seen
+ assert ("pr:acme/api#412", "file:acme/api:auth/session.py",
+ config.RELATION_TOUCHES) in seen
+ assert stats.edges == len(rows)
+
+
+# --- the whole thing ------------------------------------------------------
+
+def test_stale_but_similar_loses_to_fresh_and_connected(db):
+ """The failure demo, run through the real DB rather than a fake one."""
+ stale_ts = NOW - 240 * DAY
+ fresh_ts = NOW - 1 * DAY
+ add(db, "pr:stale", "PR #101", "PR", NEAR, ts=stale_ts)
+ add(db, "pr:fresh", "PR #412", "PR", FAR, ts=fresh_ts)
+ add(db, "person:vishal", "vishal", "Person", vec(0.4, 0.4, 0.4))
+ db.add_relationship("person:vishal", "pr:fresh",
+ relation=config.RELATION_AUTHORED, ts=fresh_ts)
+ db.add_relationship("pr:stale", "person:vishal",
+ relation=config.RELATION_CO_OCCURS)
+ db.build_vector_index()
+
+ router = TraceRouter(db)
+ router._encode = lambda text: QUERY_VEC # skip the real embedder
+
+ # vector-only sanity: the stale PR genuinely looks more similar
+ hits = {h["id"]: h["similarity"] for h in db.vector_search(QUERY_VEC, k=5)}
+ assert hits["pr:stale"] > hits["pr:fresh"]
+
+ res = router.route("who owns the code that broke?", top_k=10)
+ ranked = [r.id for r in res.results]
+ assert ranked.index("pr:fresh") < ranked.index("pr:stale"), ranked
+
+ by_id = {r.id: r for r in res.results}
+ assert by_id["pr:stale"].recency < by_id["pr:fresh"].recency
+ assert by_id["pr:stale"].age_days == pytest.approx(240, abs=1)
+
+ hops = res.trace_log["execution_path"]["graph_hops"]
+ assert {h["relation"] for h in hops} >= {config.RELATION_AUTHORED}
+ assert res.trace_log["recency"]["enabled"] is True
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-q"])
diff --git a/backend/tests/test_github_graph.py b/backend/tests/test_github_graph.py
new file mode 100644
index 0000000..7429abe
--- /dev/null
+++ b/backend/tests/test_github_graph.py
@@ -0,0 +1,131 @@
+"""GitHub payloads -> typed, timestamped edges. No DB, no embedder."""
+import pytest
+
+from tracerag import config
+from tracerag.github_graph import GitHubGraphBuilder, parse_ts
+
+
+class FakeDB:
+ """Records what the builder would write."""
+
+ def __init__(self):
+ self.nodes = {}
+ self.edges = []
+
+ def upsert_node(self, node_id, label, node_type, embedding, ts=0):
+ prev = self.nodes.get(node_id)
+ # mirror the real upsert: ts only moves forward
+ if prev and prev["ts"] > ts:
+ ts = prev["ts"]
+ self.nodes[node_id] = {"label": label, "type": node_type, "ts": ts}
+
+ def add_relationship(self, a, b, confidence=None, relation=config.RELATION_CO_OCCURS, ts=0):
+ self.edges.append({"from": a, "to": b, "relation": relation, "ts": ts})
+
+ def rel(self, relation):
+ return [e for e in self.edges if e["relation"] == relation]
+
+
+@pytest.fixture
+def build():
+ db = FakeDB()
+ return db, GitHubGraphBuilder(db, lambda text: [0.0] * config.EMBED_DIM, "acme/platform")
+
+
+PR = {
+ "number": 4977,
+ "title": "chore: bump stripe-sdk 11.2 -> 12.0",
+ "merged_at": "2026-07-24T09:12:31Z",
+ "created_at": "2026-07-23T10:00:00Z",
+ "user": {"login": "lena"},
+ "requested_reviewers": [{"login": "marco"}],
+ "body": "Routine upgrade. Fixes #2291 and closes #2280.",
+ "files": [{"filename": "services/payment/auth.py"}],
+}
+
+
+def test_parse_ts():
+ assert parse_ts("2026-07-24T09:12:31Z") > 0
+ assert parse_ts(None) == 0
+ assert parse_ts("not a date") == 0
+
+
+def test_pull_request_emits_every_structural_relation(build):
+ db, b = build
+ from tracerag.github_graph import GraphStats
+ b.add_pull_request(PR, GraphStats())
+
+ authored = db.rel(config.RELATION_AUTHORED)
+ assert len(authored) == 1
+ assert authored[0]["from"] == "person:lena"
+ assert authored[0]["to"] == "pr:acme/platform#4977"
+
+ assert len(db.rel(config.RELATION_REVIEWED)) == 1
+ # both "Fixes #2291" and "closes #2280" become causal edges
+ assert {e["to"] for e in db.rel(config.RELATION_RESOLVES)} == {
+ "issue:acme/platform#2291", "issue:acme/platform#2280"
+ }
+ touches = {e["to"] for e in db.rel(config.RELATION_TOUCHES)}
+ assert "file:acme/platform:services/payment/auth.py" in touches
+
+
+def test_merge_time_lands_on_the_pr_node(build):
+ db, b = build
+ from tracerag.github_graph import GraphStats
+ b.add_pull_request(PR, GraphStats())
+ node = db.nodes["pr:acme/platform#4977"]
+ assert node["ts"] == parse_ts("2026-07-24T09:12:31Z") # merged_at, not created_at
+ assert node["type"] == "PR"
+
+
+def test_no_co_occurrence_edges_from_structured_input(build):
+ db, b = build
+ from tracerag.github_graph import GraphStats
+ b.add_pull_request(PR, GraphStats())
+ assert db.rel(config.RELATION_CO_OCCURS) == []
+
+
+def test_issues_endpoint_skips_pull_requests(build):
+ db, b = build
+ from tracerag.github_graph import GraphStats
+ stats = GraphStats()
+ b.add_issue({"number": 1, "pull_request": {}, "created_at": "2026-07-01T00:00:00Z"}, stats)
+ assert db.edges == []
+ b.add_issue({"number": 2291, "title": "sev1", "created_at": "2026-07-23T00:00:00Z",
+ "user": {"login": "ana"}}, stats)
+ assert len(db.rel(config.RELATION_REPORTED)) == 1
+
+
+def test_commit_author_and_closes(build):
+ db, b = build
+ from tracerag.github_graph import GraphStats
+ b.add_commit({
+ "sha": "7a2b4159fbc",
+ "commit": {"message": "fix auth flow\n\nFixes #2291",
+ "author": {"date": "2026-07-24T08:00:00Z", "name": "Lena K."}},
+ "author": {"login": "lena"},
+ }, GraphStats())
+ assert db.rel(config.RELATION_AUTHORED)[0]["from"] == "person:lena"
+ assert db.rel(config.RELATION_RESOLVES)[0]["to"] == "issue:acme/platform#2291"
+ assert db.nodes["commit:acme/platform:7a2b415"]["type"] == "Commit"
+
+
+def test_build_counts_relations(build):
+ db, b = build
+ stats = b.build(pulls=[PR], issues=[], commits=[])
+ assert stats.edges == len(db.edges)
+ assert stats.by_relation[config.RELATION_AUTHORED] == 1
+ assert stats.nodes > 0
+
+
+def test_structural_edges_outweigh_proximity():
+ """The whole point of typing edges: a real relation must beat a guess."""
+ authored = config.RELATION_WEIGHTS[config.RELATION_AUTHORED]
+ co = config.RELATION_WEIGHTS[config.RELATION_CO_OCCURS]
+ assert authored > co
+ # even a 2-hop structural path beats a 1-hop proximity hop
+ assert authored * config.RELATION_WEIGHTS[config.RELATION_RESOLVES] > co
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-q"])
diff --git a/backend/tests/test_ingest_github_wiring.py b/backend/tests/test_ingest_github_wiring.py
new file mode 100644
index 0000000..4dc8328
--- /dev/null
+++ b/backend/tests/test_ingest_github_wiring.py
@@ -0,0 +1,136 @@
+"""The live GitHub path actually reaches GitHubGraphBuilder.
+
+The builder has its own unit tests against fake payloads; these check the
+wiring — that ingest_github fetches the extra endpoints, filters them right,
+and lands typed edges in a real store. Network calls are stubbed.
+"""
+import sys
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("ladybug", reason="LadybugDB driver not installed")
+pytest.importorskip("requests")
+pytest.importorskip("tqdm")
+
+_BACKEND = Path(__file__).resolve().parents[1]
+for p in (_BACKEND, _BACKEND / "scripts"):
+ if str(p) not in sys.path:
+ sys.path.insert(0, str(p))
+
+import ingest_github as gh # noqa: E402
+from tracerag import config # noqa: E402
+from tracerag.db import TraceDB # noqa: E402
+
+EMBED = [0.0] * config.EMBED_DIM
+EMBED[0] = 1.0
+
+PULL = {
+ "number": 412, "title": "Fix token refresh", "merged_at": "2026-07-27T10:00:00Z",
+ "html_url": "https://github.com/acme/api/pull/412",
+ "user": {"login": "vishal"},
+ "requested_reviewers": [{"login": "arush"}],
+ "body": "Fixes #77",
+}
+ISSUE = {"number": 77, "title": "Sessions drop", "created_at": "2026-07-20T08:00:00Z",
+ "user": {"login": "utsav"}}
+COMMIT = {"sha": "abc1234def", "author": {"login": "vishal"},
+ "commit": {"message": "hotfix, closes #77",
+ "author": {"date": "2026-07-27T09:00:00Z"}}}
+FILES = [{"filename": "auth/session.py"}]
+
+
+# --- fetch filters --------------------------------------------------------
+
+def test_only_merged_prs_are_kept(monkeypatch):
+ pages = [[{"number": 1, "merged_at": "2026-01-01T00:00:00Z"},
+ {"number": 2, "merged_at": None}], []]
+ monkeypatch.setattr(gh, "_get", lambda *a, **k: pages.pop(0))
+ assert [p["number"] for p in gh.fetch_merged_prs("acme/api", 10, None)] == [1]
+
+
+def test_the_issues_endpoint_drops_pull_requests(monkeypatch):
+ pages = [[{"number": 5}, {"number": 6, "pull_request": {"url": "..."}}], []]
+ monkeypatch.setattr(gh, "_get", lambda *a, **k: pages.pop(0))
+ assert [i["number"] for i in gh.fetch_issues("acme/api", 10, None)] == [5]
+
+
+def test_zero_limits_skip_the_request_entirely(monkeypatch):
+ def boom(*a, **k):
+ raise AssertionError("should not have called the API")
+
+ monkeypatch.setattr(gh, "_get", boom)
+ assert gh.fetch_issues("acme/api", 0, None) == []
+ assert gh.fetch_commits("acme/api", 0, None) == []
+
+
+def test_a_failed_file_fetch_does_not_abort_the_run(monkeypatch):
+ def boom(*a, **k):
+ raise RuntimeError("GitHub API 403")
+
+ monkeypatch.setattr(gh, "_get", boom)
+ prs = [dict(PULL)]
+ gh.attach_pr_files("acme/api", prs, None) # must not raise
+ assert "files" not in prs[0]
+
+
+# --- the wiring -----------------------------------------------------------
+
+@pytest.fixture
+def ingested(tmp_path, monkeypatch):
+ monkeypatch.setattr(gh, "fetch_merged_prs", lambda *a, **k: [dict(PULL)])
+ monkeypatch.setattr(gh, "fetch_issues", lambda *a, **k: [ISSUE])
+ monkeypatch.setattr(gh, "fetch_commits", lambda *a, **k: [COMMIT])
+ monkeypatch.setattr(gh, "attach_pr_files",
+ lambda repo, prs, token: [pr.update(files=FILES) for pr in prs])
+ # stub the embedder — this test is about wiring, not vectors
+ monkeypatch.setattr(gh.CurationEngine, "embed", lambda self, text: list(EMBED))
+
+ db_path = tmp_path / "acme.lbug"
+ args = gh.parse_args(["--repo", "acme/api", "--no-text",
+ "--issues", "5", "--commits", "5", "--files"])
+ gh.ingest_repo("acme/api", db_path, args, None, None)
+
+ db = TraceDB(db_path, pool_size=1)
+ yield db
+ db.close()
+
+
+def edges(db) -> set[tuple[str, str, str]]:
+ rows = db._fetch(
+ f"MATCH (a:{config.NODE_TABLE})-[r:{config.REL_TABLE}]->"
+ f"(b:{config.NODE_TABLE}) "
+ f"RETURN a.id AS src, b.id AS dst, r.relation AS relation;")
+ return {(r["src"], r["dst"], r["relation"]) for r in rows}
+
+
+def test_live_path_writes_every_relation_kind(ingested):
+ seen = edges(ingested)
+ assert ("person:vishal", "pr:acme/api#412", config.RELATION_AUTHORED) in seen
+ assert ("person:arush", "pr:acme/api#412", config.RELATION_REVIEWED) in seen
+ assert ("pr:acme/api#412", "issue:acme/api#77", config.RELATION_RESOLVES) in seen
+ assert ("pr:acme/api#412", "file:acme/api:auth/session.py",
+ config.RELATION_TOUCHES) in seen
+ assert ("person:utsav", "issue:acme/api#77", config.RELATION_REPORTED) in seen
+ assert ("commit:acme/api:abc1234", "repo:acme/api",
+ config.RELATION_PART_OF) in seen
+ assert ("commit:acme/api:abc1234", "issue:acme/api#77",
+ config.RELATION_RESOLVES) in seen
+
+
+def test_the_pr_is_dated_by_its_merge_not_by_now(ingested):
+ from tracerag.github_graph import parse_ts
+
+ rows = ingested._fetch(
+ f"MATCH (e:{config.NODE_TABLE} {{id:'pr:acme/api#412'}}) RETURN e.ts AS ts;")
+ assert rows[0]["ts"] == parse_ts(PULL["merged_at"])
+
+
+def test_no_relation_is_left_untyped(ingested):
+ relations = {r for _, _, r in edges(ingested)}
+ assert config.RELATION_CO_OCCURS not in relations, (
+ "--no-text ran, so every edge should come from structure")
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-q"])
diff --git a/backend/tests/test_recency.py b/backend/tests/test_recency.py
new file mode 100644
index 0000000..1f9b9f3
--- /dev/null
+++ b/backend/tests/test_recency.py
@@ -0,0 +1,73 @@
+"""Recency decay: age -> score multiplier."""
+import time
+
+import pytest
+
+from tracerag import config
+from tracerag.recency import age_days, decay_factor, half_life_for
+
+DAY = 86400
+
+
+def test_no_timestamp_is_not_penalised():
+ assert decay_factor(0) == 1.0
+ assert decay_factor(None) == 1.0
+ assert age_days(0) is None
+
+
+def test_fresh_beats_stale():
+ now = time.time()
+ fresh = decay_factor(int(now - 1 * DAY), "PR", now=now)
+ stale = decay_factor(int(now - 240 * DAY), "PR", now=now)
+ assert fresh > stale
+ assert fresh > 0.95
+
+
+def test_half_life_halves_the_score():
+ now = time.time()
+ hl = half_life_for("PR")
+ factor = decay_factor(int(now - hl * DAY), "PR", now=now)
+ assert factor == pytest.approx(0.5, abs=0.01)
+
+
+def test_type_specific_half_lives():
+ now = time.time()
+ age = int(now - 30 * DAY)
+ # an incident goes stale much faster than a service definition
+ assert decay_factor(age, "Ticket", now=now) < decay_factor(age, "Service", now=now)
+
+
+def test_floor_keeps_ancient_items_reachable():
+ now = time.time()
+ ancient = decay_factor(int(now - 5000 * DAY), "Ticket", now=now)
+ assert ancient == pytest.approx(config.RECENCY_FLOOR)
+ assert ancient > 0
+
+
+def test_future_timestamps_do_not_boost():
+ now = time.time()
+ assert decay_factor(int(now + 10 * DAY), "PR", now=now) == pytest.approx(1.0)
+
+
+def test_disabled_is_a_no_op(monkeypatch):
+ monkeypatch.setattr(config, "RECENCY_ENABLED", False)
+ now = time.time()
+ assert decay_factor(int(now - 900 * DAY), "Ticket", now=now) == 1.0
+
+
+def test_the_failure_demo_scenario():
+ """The story the product is built on: a stale PR that reads like the query
+ must lose to a fresh one once recency is applied."""
+ now = time.time()
+ stale_relevance, fresh_relevance = 0.91, 0.34 # raw similarity
+ stale = stale_relevance * decay_factor(int(now - 240 * DAY), "PR", now=now)
+ fresh = fresh_relevance * decay_factor(int(now - 1 * DAY), "PR", now=now)
+ assert stale < 0.5 # 8-month-old PR is heavily discounted
+ assert fresh > 0.33 # yesterday's change keeps essentially all its score
+ # recency alone doesn't flip this one — the graph path is what closes the gap,
+ # but the ranking distance shrinks from 2.7x to under 1.5x
+ assert (stale / fresh) < 1.5
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-q"])
diff --git a/backend/tests/test_router_scoring.py b/backend/tests/test_router_scoring.py
new file mode 100644
index 0000000..2dc34b8
--- /dev/null
+++ b/backend/tests/test_router_scoring.py
@@ -0,0 +1,153 @@
+"""Router scoring: relation-weighted traversal + recency decay.
+
+Uses a fake store and a stub embedder so the ranking logic is tested on its
+own — no DB driver, no model download.
+"""
+import time
+
+import pytest
+
+from tracerag import config
+from tracerag.router import TraceRouter
+
+DAY = 86400
+NOW = time.time()
+
+
+class FakeDB:
+ """Minimal store: vector hits, typed neighbours, no documents."""
+
+ def __init__(self, hits, neighbours):
+ self._hits = hits
+ self._neighbours = neighbours
+
+ def vector_search(self, embedding, k=10):
+ return self._hits[:k]
+
+ def expand_frontier(self, node_ids, k, max_degree):
+ return {n: self._neighbours.get(n, [])[:k] for n in node_ids
+ if n in self._neighbours}
+
+ def find_nodes_by_label(self, label):
+ return []
+
+ def documents_for_entities(self, ids):
+ return {}
+
+
+def make_router(hits, neighbours):
+ r = TraceRouter(FakeDB(hits, neighbours))
+ r._encode = lambda text: [0.0] * config.EMBED_DIM # skip the real embedder
+ return r
+
+
+def hit(node_id, sim, ntype="PR", ts=0, label=None):
+ return {"id": node_id, "label": label or node_id, "type": ntype,
+ "similarity": sim, "ts": ts}
+
+
+def nbr(node_id, relation, ntype="PR", ts=0):
+ return {"id": node_id, "label": node_id, "type": ntype, "ts": ts,
+ "relation": relation,
+ "confidence": config.RELATION_WEIGHTS[relation]}
+
+
+def test_structural_hop_outranks_co_occurrence():
+ """A node reached through AUTHORED must beat one reached by proximity."""
+ router = make_router(
+ hits=[hit("seed", 0.9)],
+ neighbours={"seed": [nbr("real", config.RELATION_AUTHORED),
+ nbr("noise", config.RELATION_CO_OCCURS)]},
+ )
+ res = router.route("who owns this?", top_k=10)
+ scores = {r.id: r.score_graph for r in res.results}
+ assert scores["real"] > scores["noise"]
+ # and the gap is the relation weight ratio, not an accident
+ assert scores["real"] / scores["noise"] == pytest.approx(
+ config.RELATION_WEIGHTS[config.RELATION_AUTHORED]
+ / config.RELATION_WEIGHTS[config.RELATION_CO_OCCURS], rel=1e-3
+ )
+
+
+def test_two_hop_structural_beats_one_hop_proximity():
+ router = make_router(
+ hits=[hit("seed", 0.9)],
+ neighbours={
+ "seed": [nbr("pr", config.RELATION_AUTHORED),
+ nbr("noise", config.RELATION_CO_OCCURS)],
+ "pr": [nbr("issue", config.RELATION_RESOLVES, ntype="Ticket")],
+ },
+ )
+ res = router.route("which issue did that fix?", top_k=10)
+ scores = {r.id: r.score_graph for r in res.results}
+ assert scores["issue"] > scores["noise"]
+
+
+def test_graph_scores_are_no_longer_all_one():
+ """Regression: with untyped 1.0 edges every reachable node tied at 1.0."""
+ router = make_router(
+ hits=[hit("seed", 0.9)],
+ neighbours={
+ "seed": [nbr("a", config.RELATION_AUTHORED),
+ nbr("b", config.RELATION_TOUCHES),
+ nbr("c", config.RELATION_CO_OCCURS)],
+ },
+ )
+ res = router.route("who owns this?", top_k=10)
+ graph_only = {r.score_graph for r in res.results if r.id != "seed"}
+ assert len(graph_only) > 1, "traversal produced no ranking gradient"
+
+
+def test_recency_demotes_a_stale_but_similar_hit():
+ """The failure-demo scenario, end to end through the router."""
+ stale = hit("pr_stale", 0.91, ts=int(NOW - 240 * DAY))
+ fresh = hit("pr_fresh", 0.60, ts=int(NOW - 1 * DAY))
+ router = make_router(hits=[stale, fresh], neighbours={})
+ res = router.route("explain what broke", top_k=10)
+ ranked = [r.id for r in res.results]
+ assert ranked[0] == "pr_fresh", f"stale hit still won: {ranked}"
+
+
+def test_undated_nodes_are_not_penalised():
+ router = make_router(
+ hits=[hit("dated", 0.8, ts=int(NOW - 300 * DAY)), hit("undated", 0.8, ts=0)],
+ neighbours={},
+ )
+ res = router.route("explain the architecture", top_k=10)
+ by_id = {r.id: r for r in res.results}
+ assert by_id["undated"].recency == 1.0
+ assert by_id["undated"].age_days is None
+ assert by_id["dated"].recency < 1.0
+ assert by_id["undated"].score_total > by_id["dated"].score_total
+
+
+def test_trace_log_exposes_relations_and_recency():
+ """The viewer can only show why something ranked if the trace says so."""
+ router = make_router(
+ hits=[hit("seed", 0.9, ts=int(NOW - 10 * DAY))],
+ neighbours={"seed": [nbr("pr", config.RELATION_RESOLVES)]},
+ )
+ res = router.route("who fixed it?", top_k=10)
+
+ hops = res.trace_log["execution_path"]["graph_hops"]
+ assert hops and hops[0]["relation"] == config.RELATION_RESOLVES
+
+ recency = res.trace_log["recency"]
+ assert recency["enabled"] is True
+ applied = {a["id"]: a for a in recency["applied"]}
+ assert applied["seed"]["age_days"] == pytest.approx(10, abs=0.5)
+ assert 0 < applied["seed"]["factor"] < 1
+
+
+def test_recency_can_be_disabled(monkeypatch):
+ monkeypatch.setattr(config, "RECENCY_ENABLED", False)
+ router = make_router(
+ hits=[hit("stale", 0.91, ts=int(NOW - 400 * DAY)), hit("fresh", 0.60, ts=int(NOW))],
+ neighbours={},
+ )
+ res = router.route("explain what broke", top_k=10)
+ assert res.results[0].id == "stale" # pure similarity ordering restored
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-q"])
diff --git a/backend/tracerag/config.py b/backend/tracerag/config.py
index c6d7bcf..f9ba7c1 100644
--- a/backend/tracerag/config.py
+++ b/backend/tracerag/config.py
@@ -199,8 +199,49 @@
# ladybugdb schema (kùzu-compatible cypher ddl)
NODE_TABLE = "Entity"
-REL_TABLE = "RELATES_TO" # entity -> entity, same-window co-occurrence
+REL_TABLE = "RELATES_TO" # entity -> entity; carries relation + ts
DOC_TABLE = "Document" # source doc node, no embedding
MENTIONS_TABLE = "MENTIONS" # document -> entity
VECTOR_INDEX = "idx_entity_embedding"
+
+# Relation vocabulary. Structured sources (the GitHub API) emit these directly;
+# CO_OCCURS is the weak fallback for text we can only infer from proximity.
+RELATION_AUTHORED = "AUTHORED"
+RELATION_REVIEWED = "REVIEWED"
+RELATION_RESOLVES = "RESOLVES"
+RELATION_TOUCHES = "TOUCHES"
+RELATION_PART_OF = "PART_OF"
+RELATION_REPORTED = "REPORTED"
+RELATION_CO_OCCURS = "CO_OCCURS"
+
+# How much a hop through each relation is worth. Traversal multiplies these, so
+# a 2-hop path through two strong edges still beats one weak hop.
+RELATION_WEIGHTS = {
+ RELATION_AUTHORED: 0.95,
+ RELATION_RESOLVES: 0.92,
+ RELATION_REVIEWED: 0.85,
+ RELATION_TOUCHES: 0.80,
+ RELATION_PART_OF: 0.80,
+ RELATION_REPORTED: 0.75,
+ RELATION_CO_OCCURS: 0.35, # proximity is a hint, not a fact
+}
+DEFAULT_RELATION_WEIGHT = float(os.getenv("TRACERAG_DEFAULT_REL_WEIGHT", "0.5"))
+
+# Recency: score *= 0.5 ** (age_days / half_life) — at half_life days an item
+# keeps half its score. Incidents go stale in weeks, service definitions in a
+# year, so the half-life is per entity type.
+RECENCY_ENABLED = os.getenv("TRACERAG_RECENCY", "1") not in ("0", "false", "False")
+RECENCY_HALF_LIFE_DAYS = {
+ "Ticket": 21.0,
+ "Issue": 21.0,
+ "PR": 60.0,
+ "Commit": 45.0,
+ "Person": 180.0,
+ "File": 120.0,
+ "Service": 365.0,
+ "Repo": 365.0,
+}
+DEFAULT_HALF_LIFE_DAYS = float(os.getenv("TRACERAG_HALF_LIFE_DAYS", "90"))
+# Never decay an item to nothing — old but on-topic still beats unrelated.
+RECENCY_FLOOR = float(os.getenv("TRACERAG_RECENCY_FLOOR", "0.35"))
VECTOR_METRIC = "cosine"
diff --git a/backend/tracerag/curation.py b/backend/tracerag/curation.py
index 456e920..e99dcd2 100644
--- a/backend/tracerag/curation.py
+++ b/backend/tracerag/curation.py
@@ -69,6 +69,11 @@ def _embed(self, text: str) -> list[float]:
vec = self._get_embedder().encode(text, normalize_embeddings=True)
return [float(x) for x in vec]
+ def embed(self, text: str) -> list[float]:
+ """Public handle on the shared vector space. The GitHub graph builder
+ writes structured nodes through this, so both paths land in one index."""
+ return self._embed(text)
+
def _get_llm(self):
if self._llm is None:
from .llm import make_client
@@ -213,5 +218,7 @@ def _build_chunks_and_edges(
if a == b or (a, b) in seen_pairs:
continue
seen_pairs.add((a, b))
- self.db.add_relationship(a, b)
+ # proximity only — tagged CO_OCCURS so traversal weights it far
+ # below a structural edge, and upgrades it if one arrives later
+ self.db.add_relationship(a, b, relation=config.RELATION_CO_OCCURS)
stats.relates_edges += 1
diff --git a/backend/tracerag/db.py b/backend/tracerag/db.py
index 472374b..780e1cb 100644
--- a/backend/tracerag/db.py
+++ b/backend/tracerag/db.py
@@ -15,6 +15,25 @@
logger = logging.getLogger(__name__)
+_TS = "CAST($ts AS INT64)" # see upsert_node — small ints infer as INT8
+
+
+def _added_columns() -> dict[str, list[tuple[str, str, str]]]:
+ """Columns that arrived after the first schema shipped: (name, type, default).
+
+ CREATE TABLE IF NOT EXISTS is a no-op on a store that already exists, so a
+ pre-0.3 .lbug keeps the old shape and only fails later, at query time, on
+ e.ts / r.relation. ALTER ... ADD backfills existing rows with the default,
+ so migrating is lossless — no reingest.
+ """
+ return {
+ config.NODE_TABLE: [("ts", "INT64", "0")],
+ config.REL_TABLE: [
+ ("relation", "STRING", f"'{config.RELATION_CO_OCCURS}'"),
+ ("ts", "INT64", "0"),
+ ],
+ }
+
class TraceDB:
"""Wrapper over LadybugDB with a read-connection pool.
@@ -109,9 +128,10 @@ def _fetch(
return self._records(conn.execute(query, params or {}))
def init_schema(self) -> None:
+ # ts = unix seconds of the underlying event (merge/creation), 0 when unknown
self.execute(
f"CREATE NODE TABLE IF NOT EXISTS {config.NODE_TABLE} ("
- f"id STRING PRIMARY KEY, label STRING, type STRING, "
+ f"id STRING PRIMARY KEY, label STRING, type STRING, ts INT64, "
f"embedding FLOAT[{config.EMBED_DIM}]);"
)
self.execute(
@@ -120,16 +140,72 @@ def init_schema(self) -> None:
)
self.execute(
f"CREATE REL TABLE IF NOT EXISTS {config.REL_TABLE} ("
- f"FROM {config.NODE_TABLE} TO {config.NODE_TABLE}, confidence DOUBLE);"
+ f"FROM {config.NODE_TABLE} TO {config.NODE_TABLE}, "
+ f"confidence DOUBLE, relation STRING, ts INT64);"
)
self.execute(
f"CREATE REL TABLE IF NOT EXISTS {config.MENTIONS_TABLE} ("
f"FROM {config.DOC_TABLE} TO {config.NODE_TABLE});"
)
+ self.migrate_schema()
# HNSW index built later via build_vector_index(); a live index blocks embedding writes
logger.info("Schema ready (node=%s, rel=%s)",
config.NODE_TABLE, config.REL_TABLE)
+ def table_columns(self, table: str) -> set[str]:
+ """Column names on a table; empty set when the table doesn't exist."""
+ try:
+ rows = self._fetch(f"CALL TABLE_INFO('{table}') RETURN *;")
+ except Exception as exc: # noqa: BLE001 — table absent, or older engine
+ logger.debug("TABLE_INFO('%s') unavailable: %s", table, exc)
+ return set()
+ return {r["name"] for r in rows}
+
+ def migrate_schema(self) -> list[str]:
+ """Bring an older store up to the current shape. Returns what it added."""
+ added: list[str] = []
+ for table, columns in _added_columns().items():
+ existing = self.table_columns(table)
+ if not existing:
+ continue # freshly created above, already correct
+ for name, sql_type, default in columns:
+ if name in existing:
+ continue
+ self.execute(
+ f"ALTER TABLE {table} ADD {name} {sql_type} DEFAULT {default};"
+ )
+ added.append(f"{table}.{name}")
+ if (table, name) == (config.REL_TABLE, "relation"):
+ self._downgrade_legacy_confidence()
+ if added:
+ logger.warning(
+ "Migrated %s to the 0.3 schema: added %s. Existing rows use the "
+ "column default — reingest to populate real timestamps and relations.",
+ self.db_path, ", ".join(added),
+ )
+ return added
+
+ def _downgrade_legacy_confidence(self) -> None:
+ """Reprice pre-0.3 edges as what they actually are: proximity guesses.
+
+ The old writer stored every edge at 1.0, which made traversal flat. Left
+ alone those edges would also outrank every typed relation (max 0.95) and
+ could never be upgraded, since the ON MATCH guard needs a strictly higher
+ confidence. The ALTER just labelled them all CO_OCCURS — so give them the
+ CO_OCCURS weight to match.
+ """
+ weight = config.RELATION_WEIGHTS[config.RELATION_CO_OCCURS]
+ self.execute(
+ f"MATCH ()-[r:{config.REL_TABLE}]->() "
+ f"WHERE r.confidence > $weight SET r.confidence = $weight;",
+ {"weight": float(weight)},
+ )
+ logger.warning(
+ "Repriced legacy edges to the %s weight (%.2f) so structured "
+ "relations can outrank and upgrade them.",
+ config.RELATION_CO_OCCURS, weight,
+ )
+
def build_vector_index(self) -> None:
"""(Re)build the HNSW index over current embeddings; call after ingestion."""
self._load_vector_extension()
@@ -152,22 +228,52 @@ def upsert_node(
label: str,
node_type: str,
embedding: Sequence[float],
+ ts: int = 0,
) -> None:
- # no ON MATCH SET: never clobber a canonical node with a noisier surface form
+ # no ON MATCH SET on label/type: never clobber a canonical node with a
+ # noisier surface form. ts does move forward — recency is the newest fact.
self._check_dim(embedding)
self.execute(
f"MERGE (e:{config.NODE_TABLE} {{id: $id}}) "
- f"ON CREATE SET e.label = $label, e.type = $type, e.embedding = $embedding",
- {"id": node_id, "label": label, "type": node_type,
+ f"ON CREATE SET e.label = $label, e.type = $type, e.ts = $ts, "
+ f"e.embedding = $embedding "
+ # CAST is load-bearing: the driver infers a small $ts (0 for undated
+ # items) as INT8, and the CASE then overflows on a real epoch value.
+ f"ON MATCH SET e.ts = CASE WHEN {_TS} > e.ts THEN {_TS} ELSE e.ts END",
+ {"id": node_id, "label": label, "type": node_type, "ts": int(ts),
"embedding": list(embedding)},
)
- def add_relationship(self, from_id: str, to_id: str, confidence: float = 1.0) -> None:
+ def add_relationship(
+ self,
+ from_id: str,
+ to_id: str,
+ confidence: float | None = None,
+ relation: str = config.RELATION_CO_OCCURS,
+ ts: int = 0,
+ ) -> None:
+ """Typed edge. Confidence defaults to the relation's configured weight,
+ so a structural AUTHORED hop outranks a proximity guess."""
+ if confidence is None:
+ confidence = config.RELATION_WEIGHTS.get(
+ relation, config.DEFAULT_RELATION_WEIGHT
+ )
self.execute(
f"MATCH (a:{config.NODE_TABLE} {{id: $from_id}}), "
f"(b:{config.NODE_TABLE} {{id: $to_id}}) "
- f"MERGE (a)-[r:{config.REL_TABLE}]->(b) ON CREATE SET r.confidence = $confidence",
- {"from_id": from_id, "to_id": to_id, "confidence": confidence},
+ f"MERGE (a)-[r:{config.REL_TABLE}]->(b) "
+ f"ON CREATE SET r.confidence = $confidence, r.relation = $relation, r.ts = $ts "
+ # a stronger relation later (CO_OCCURS then AUTHORED) upgrades the edge.
+ # SET items apply left to right, so relation must be decided BEFORE
+ # confidence is overwritten — otherwise its guard compares the new
+ # value against itself and the label never moves.
+ f"ON MATCH SET r.relation = CASE WHEN $confidence > r.confidence "
+ f"THEN $relation ELSE r.relation END, "
+ f"r.ts = CASE WHEN {_TS} > r.ts THEN {_TS} ELSE r.ts END, "
+ f"r.confidence = CASE WHEN $confidence > r.confidence "
+ f"THEN $confidence ELSE r.confidence END",
+ {"from_id": from_id, "to_id": to_id, "confidence": float(confidence),
+ "relation": relation, "ts": int(ts)},
)
def upsert_document(
@@ -215,7 +321,7 @@ def vector_search(
f"CALL QUERY_VECTOR_INDEX('{config.NODE_TABLE}', "
f"'{config.VECTOR_INDEX}', $q, {int(k)}) "
f"RETURN node.id AS id, node.label AS label, node.type AS type, "
- f"distance ORDER BY distance;",
+ f"node.ts AS ts, distance ORDER BY distance;",
{"q": list(query_embedding)},
)
rows = []
@@ -223,6 +329,7 @@ def vector_search(
distance = float(r["distance"])
rows.append({
"id": r["id"], "label": r["label"], "type": r["type"],
+ "ts": int(r["ts"]) if r.get("ts") is not None else 0,
"distance": distance, "similarity": 1.0 - distance,
})
return rows
@@ -259,7 +366,8 @@ def expand_frontier(
f"MATCH (a:{config.NODE_TABLE}) WHERE a.id IN $ids "
f"OPTIONAL MATCH (a)-[r:{config.REL_TABLE}]-(b:{config.NODE_TABLE}) "
f"RETURN a.id AS from_id, b.id AS to_id, b.label AS label, "
- f"b.type AS type, r.confidence AS confidence;",
+ f"b.type AS type, b.ts AS ts, r.confidence AS confidence, "
+ f"r.relation AS relation;",
{"ids": list(node_ids)},
)
grouped: dict[str, list[dict[str, Any]]] = {}
@@ -268,6 +376,8 @@ def expand_frontier(
continue
grouped.setdefault(r["from_id"], []).append({
"id": r["to_id"], "label": r["label"], "type": r["type"],
+ "ts": int(r["ts"]) if r.get("ts") is not None else 0,
+ "relation": r.get("relation") or config.RELATION_CO_OCCURS,
"confidence": float(r["confidence"])
if r["confidence"] is not None else 1.0,
})
@@ -330,12 +440,14 @@ def _add(nid, label, ntype):
f"MATCH (a:{config.NODE_TABLE})-[r:{config.REL_TABLE}]->"
f"(b:{config.NODE_TABLE}) "
f"WHERE a.id IN $ids AND b.id IN $ids "
- f"RETURN a.id AS source, b.id AS target, r.confidence AS confidence;",
+ f"RETURN a.id AS source, b.id AS target, r.confidence AS confidence, "
+ f"r.relation AS relation;",
{"ids": all_ids},
) if all_ids else []
edges = [
{"source": r["source"], "target": r["target"],
- "confidence": r["confidence"]}
+ "confidence": r["confidence"],
+ "relation": r.get("relation") or config.RELATION_CO_OCCURS}
for r in edge_rows
]
return {"nodes": list(nodes.values()), "edges": edges}
diff --git a/backend/tracerag/github_graph.py b/backend/tracerag/github_graph.py
new file mode 100644
index 0000000..39e794c
--- /dev/null
+++ b/backend/tracerag/github_graph.py
@@ -0,0 +1,206 @@
+"""Build the memory graph straight from GitHub's structured payloads.
+
+The API already knows who authored a PR, which issue it closes, and which
+files it touched — so we read those relations instead of guessing them from
+word proximity. Every edge here is a fact from the payload, never an
+inference, and every node carries the event's timestamp for recency scoring.
+
+Text extraction (extract.py + curation.py) still runs for prose; this module
+covers the part of the graph that structure can answer exactly.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import TYPE_CHECKING, Any, Iterable
+
+from . import config
+
+if TYPE_CHECKING: # the builder only needs upsert_node/add_relationship
+ from .db import TraceDB
+
+logger = logging.getLogger(__name__)
+
+# "Fixes #982", "closes JIRA-77" in a PR body — GitHub only auto-links some of these
+_CLOSES_RE = re.compile(
+ r"(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE
+)
+
+
+@dataclass
+class GraphStats:
+ nodes: int = 0
+ edges: int = 0
+ by_relation: dict[str, int] = field(default_factory=dict)
+
+ def count(self, relation: str) -> None:
+ self.edges += 1
+ self.by_relation[relation] = self.by_relation.get(relation, 0) + 1
+
+
+def parse_ts(value: str | None) -> int:
+ """GitHub ISO-8601 ('2026-07-24T09:12:31Z') -> unix seconds; 0 when absent."""
+ if not value:
+ return 0
+ try:
+ return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
+ except (ValueError, AttributeError):
+ return 0
+
+
+def _login(actor: dict[str, Any] | None) -> str | None:
+ if not actor:
+ return None
+ return actor.get("login") or actor.get("name")
+
+
+class GitHubGraphBuilder:
+ """Writes typed, timestamped edges from GitHub payloads into the graph.
+
+ Nodes need an embedding, so the caller supplies an embed function — the
+ same one curation uses, keeping one vector space across both paths.
+ """
+
+ def __init__(self, db: "TraceDB", embed_fn, repo: str) -> None:
+ self.db = db
+ self.embed = embed_fn
+ self.repo = repo
+ self.repo_id = f"repo:{repo}"
+ self._seen: set[str] = set()
+
+ # nodes -------------------------------------------------------------
+ def _node(self, node_id: str, label: str, node_type: str, ts: int = 0) -> str:
+ """Upsert once per run; embeddings are the expensive part."""
+ if node_id not in self._seen:
+ self.db.upsert_node(node_id, label, node_type, self.embed(label), ts=ts)
+ self._seen.add(node_id)
+ elif ts:
+ # already embedded this run — still let a newer event move the clock
+ self.db.upsert_node(node_id, label, node_type, self.embed(label), ts=ts)
+ return node_id
+
+ def person(self, login: str) -> str:
+ return self._node(f"person:{login}", login, "Person")
+
+ def pull_request(self, number: int, title: str, ts: int) -> str:
+ return self._node(f"pr:{self.repo}#{number}", f"PR #{number}", "PR", ts)
+
+ def issue(self, number: int, title: str = "", ts: int = 0) -> str:
+ return self._node(f"issue:{self.repo}#{number}", f"Issue #{number}", "Ticket", ts)
+
+ def file(self, path: str) -> str:
+ return self._node(f"file:{self.repo}:{path}", path, "File")
+
+ def commit(self, sha: str, ts: int) -> str:
+ return self._node(f"commit:{self.repo}:{sha[:7]}", sha[:7], "Commit", ts)
+
+ def repository(self) -> str:
+ return self._node(self.repo_id, self.repo.split("/")[-1], "Repo")
+
+ # ingestion ---------------------------------------------------------
+ def add_pull_request(self, pr: dict[str, Any], stats: GraphStats) -> None:
+ number = pr.get("number")
+ if number is None:
+ return
+ ts = parse_ts(pr.get("merged_at") or pr.get("closed_at") or pr.get("created_at"))
+ pr_id = self.pull_request(number, pr.get("title") or "", ts)
+ repo_id = self.repository()
+ self.db.add_relationship(pr_id, repo_id, relation=config.RELATION_TOUCHES, ts=ts)
+ stats.count(config.RELATION_TOUCHES)
+
+ author = _login(pr.get("user"))
+ if author:
+ self.db.add_relationship(
+ self.person(author), pr_id, relation=config.RELATION_AUTHORED, ts=ts
+ )
+ stats.count(config.RELATION_AUTHORED)
+
+ for reviewer in pr.get("requested_reviewers") or []:
+ login = _login(reviewer)
+ if login:
+ self.db.add_relationship(
+ self.person(login), pr_id, relation=config.RELATION_REVIEWED, ts=ts
+ )
+ stats.count(config.RELATION_REVIEWED)
+
+ # closes/fixes references — the causal link that makes tracing possible
+ for ref in set(_CLOSES_RE.findall(pr.get("body") or "")):
+ self.db.add_relationship(
+ pr_id, self.issue(int(ref)), relation=config.RELATION_RESOLVES, ts=ts
+ )
+ stats.count(config.RELATION_RESOLVES)
+
+ for f in pr.get("files") or []:
+ path = f.get("filename") if isinstance(f, dict) else f
+ if path:
+ self.db.add_relationship(
+ pr_id, self.file(path), relation=config.RELATION_TOUCHES, ts=ts
+ )
+ stats.count(config.RELATION_TOUCHES)
+
+ def add_issue(self, issue: dict[str, Any], stats: GraphStats) -> None:
+ if issue.get("number") is None or "pull_request" in issue:
+ return # the issues endpoint also returns PRs
+ ts = parse_ts(issue.get("created_at"))
+ issue_id = self.issue(issue["number"], issue.get("title") or "", ts)
+ self.db.add_relationship(
+ issue_id, self.repository(), relation=config.RELATION_TOUCHES, ts=ts
+ )
+ stats.count(config.RELATION_TOUCHES)
+ reporter = _login(issue.get("user"))
+ if reporter:
+ self.db.add_relationship(
+ self.person(reporter), issue_id,
+ relation=config.RELATION_REPORTED, ts=ts,
+ )
+ stats.count(config.RELATION_REPORTED)
+
+ def add_commit(self, commit: dict[str, Any], stats: GraphStats) -> None:
+ sha = commit.get("sha")
+ if not sha:
+ return
+ inner = commit.get("commit") or {}
+ ts = parse_ts((inner.get("author") or {}).get("date"))
+ commit_id = self.commit(sha, ts)
+ self.db.add_relationship(
+ commit_id, self.repository(), relation=config.RELATION_PART_OF, ts=ts
+ )
+ stats.count(config.RELATION_PART_OF)
+
+ author = _login(commit.get("author")) or (inner.get("author") or {}).get("name")
+ if author:
+ self.db.add_relationship(
+ self.person(author), commit_id,
+ relation=config.RELATION_AUTHORED, ts=ts,
+ )
+ stats.count(config.RELATION_AUTHORED)
+
+ for ref in set(_CLOSES_RE.findall(inner.get("message") or "")):
+ self.db.add_relationship(
+ commit_id, self.issue(int(ref)),
+ relation=config.RELATION_RESOLVES, ts=ts,
+ )
+ stats.count(config.RELATION_RESOLVES)
+
+ def build(
+ self,
+ pulls: Iterable[dict[str, Any]] = (),
+ issues: Iterable[dict[str, Any]] = (),
+ commits: Iterable[dict[str, Any]] = (),
+ ) -> GraphStats:
+ stats = GraphStats()
+ for pr in pulls:
+ self.add_pull_request(pr, stats)
+ for issue in issues:
+ self.add_issue(issue, stats)
+ for commit in commits:
+ self.add_commit(commit, stats)
+ stats.nodes = len(self._seen)
+ logger.info(
+ "github graph: %d nodes, %d edges %s",
+ stats.nodes, stats.edges, stats.by_relation,
+ )
+ return stats
diff --git a/backend/tracerag/memory.py b/backend/tracerag/memory.py
new file mode 100644
index 0000000..819bf67
--- /dev/null
+++ b/backend/tracerag/memory.py
@@ -0,0 +1,79 @@
+"""GraphMemory — the embedded entry point.
+
+ mem = GraphMemory("memory.lbug")
+ mem.ingest_github("acme/platform", pulls=prs, issues=issues, commits=commits)
+ hits = mem.query("who broke checkout yesterday?")
+
+One object owns the store, the embedder, and the router. Everything runs in
+this process against a single file; nothing is sent anywhere.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any, Iterable
+
+from . import config
+from .db import TraceDB
+from .github_graph import GitHubGraphBuilder, GraphStats
+from .router import RouterResponse, TraceRouter
+
+logger = logging.getLogger(__name__)
+
+
+class GraphMemory:
+ def __init__(self, path: str | Path = config.DB_PATH, embed_model: str | None = None):
+ self.path = str(path)
+ self.db = TraceDB(self.path)
+ self.db.init_schema()
+ self.router = TraceRouter(self.db, embed_model or config.EMBED_MODEL)
+ self._indexed = False
+
+ # ingest ------------------------------------------------------------
+ def ingest_github(
+ self,
+ repo: str,
+ pulls: Iterable[dict[str, Any]] = (),
+ issues: Iterable[dict[str, Any]] = (),
+ commits: Iterable[dict[str, Any]] = (),
+ ) -> GraphStats:
+ """Typed edges from GitHub payloads — authorship, closes, touches."""
+ builder = GitHubGraphBuilder(self.db, self.router._encode, repo)
+ stats = builder.build(pulls=pulls, issues=issues, commits=commits)
+ self._indexed = False
+ return stats
+
+ def ingest_text(self, doc_id: str, text: str, source: str | None = None):
+ """Prose path: extract entities, resolve them, store co-occurrence edges."""
+ from .curation import CurationEngine
+ from .extract import EntityExtractor
+
+ entities = EntityExtractor().extract(text)
+ stats = CurationEngine(self.db).ingest(doc_id, text, entities, source=source)
+ self._indexed = False
+ return stats
+
+ def build_index(self) -> None:
+ """(Re)build the HNSW index. Required before the first query after ingest."""
+ self.db.build_vector_index()
+ self._indexed = True
+
+ # query -------------------------------------------------------------
+ def query(self, question: str, top_k: int | None = None) -> RouterResponse:
+ if not self._indexed:
+ self.build_index()
+ return self.router.route(question, top_k)
+
+ def context(self, question: str, top_k: int | None = None) -> str:
+ """Retrieved context as a single grounded string, ready for a prompt."""
+ return self.router.build_context(self.query(question, top_k).results)
+
+ def close(self) -> None:
+ self.db.close()
+
+ def __enter__(self) -> "GraphMemory":
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ self.close()
diff --git a/backend/tracerag/recency.py b/backend/tracerag/recency.py
new file mode 100644
index 0000000..92490b4
--- /dev/null
+++ b/backend/tracerag/recency.py
@@ -0,0 +1,44 @@
+"""Age-based score decay: score *= 0.5 ** (age_days / half_life), floored.
+
+A true half-life — at exactly half_life days the multiplier is 0.5 — so the
+config numbers mean what they say when you tune them.
+
+Half-life is per entity type — an incident goes stale in weeks, a service
+definition in a year. Items with no timestamp are left untouched rather than
+penalised, so a graph without dates behaves exactly as it did before.
+"""
+
+from __future__ import annotations
+
+import time
+
+from . import config
+
+SECONDS_PER_DAY = 86400.0
+
+
+def age_days(ts: int | None, now: float | None = None) -> float | None:
+ """Age in days, or None when the item carries no usable timestamp."""
+ if not ts:
+ return None
+ now = time.time() if now is None else now
+ return max(0.0, (now - float(ts)) / SECONDS_PER_DAY)
+
+
+def half_life_for(node_type: str | None) -> float:
+ return config.RECENCY_HALF_LIFE_DAYS.get(
+ node_type or "", config.DEFAULT_HALF_LIFE_DAYS
+ )
+
+
+def decay_factor(
+ ts: int | None, node_type: str | None = None, now: float | None = None
+) -> float:
+ """Multiplier in [RECENCY_FLOOR, 1.0]. 1.0 when recency is off or ts is unknown."""
+ if not config.RECENCY_ENABLED:
+ return 1.0
+ age = age_days(ts, now)
+ if age is None:
+ return 1.0
+ factor = 0.5 ** (age / half_life_for(node_type))
+ return max(config.RECENCY_FLOOR, factor)
diff --git a/backend/tracerag/router.py b/backend/tracerag/router.py
index 286dd91..7f4cdc9 100644
--- a/backend/tracerag/router.py
+++ b/backend/tracerag/router.py
@@ -8,8 +8,13 @@
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
from . import config
-from .db import TraceDB
+from .recency import age_days, decay_factor
+
+if TYPE_CHECKING: # keeps the scoring logic importable without the DB driver
+ from .db import TraceDB
logger = logging.getLogger(__name__)
@@ -39,6 +44,8 @@ class RoutedNode:
score_total: float
score_vector: float
score_graph: float
+ recency: float = 1.0 # age multiplier applied to score_total
+ age_days: float | None = None # None when the node carries no timestamp
documents: list[dict] = field(default_factory=list)
@@ -52,7 +59,7 @@ class RouterResponse:
class TraceRouter:
"""Hybrid retriever: vector + graph, fused by query intent."""
- def __init__(self, db: TraceDB, embed_model: str = config.EMBED_MODEL) -> None:
+ def __init__(self, db: "TraceDB", embed_model: str = config.EMBED_MODEL) -> None:
self.db = db
self.embed_model = embed_model
self._embedder = None
@@ -190,7 +197,8 @@ def _vector_hits(
nid = h["id"]
if nid is None:
continue
- meta.setdefault(nid, {"label": h.get("label"), "type": h.get("type")})
+ meta.setdefault(nid, {"label": h.get("label"), "type": h.get("type"),
+ "ts": h.get("ts", 0)})
out.append((nid, float(h["similarity"])))
return out
@@ -200,7 +208,9 @@ def _graph_stream(
s_g: dict[str, float] = {seed: 1.0 for seed in seeds}
hops: list[dict] = []
- # multiplicative BFS: path score = product of edge confidences
+ # multiplicative BFS: path score = product of edge weights. A hop's weight
+ # is the relation's own strength (AUTHORED 0.95 vs CO_OCCURS 0.35), so a
+ # structural two-hop path beats a proximity guess one hop away.
frontier: dict[str, float] = {seed: 1.0 for seed in seeds}
visited: set[str] = set(seeds)
for _ in range(config.GRAPH_MAX_HOPS):
@@ -213,10 +223,15 @@ def _graph_stream(
for node_id, acc in frontier.items():
for nb in expanded.get(node_id, []):
to_id = nb["id"]
+ relation = nb.get("relation") or config.RELATION_CO_OCCURS
conf = nb["confidence"]
path_score = acc * conf
- hops.append({"from_id": node_id, "to_id": to_id, "confidence": conf})
- meta.setdefault(to_id, {"label": nb["label"], "type": nb["type"]})
+ hops.append({
+ "from_id": node_id, "to_id": to_id,
+ "confidence": conf, "relation": relation,
+ })
+ meta.setdefault(to_id, {"label": nb["label"], "type": nb["type"],
+ "ts": nb.get("ts", 0)})
if path_score > s_g.get(to_id, 0.0):
s_g[to_id] = path_score
if to_id not in visited:
@@ -302,13 +317,20 @@ def route(self, query: str, top_k: int | None = None) -> RouterResponse:
sv = vector_scores.get(nid, 0.0)
sg = graph_scores.get(nid, 0.0)
info = meta.get(nid, {})
+ ts = info.get("ts") or 0
+ ntype = info.get("type")
+ # recency multiplies the fused score: yesterday's change outranks a
+ # stale one that merely reads like the query
+ decay = decay_factor(ts, ntype)
results.append(RoutedNode(
id=nid,
label=info.get("label"),
- type=info.get("type"),
- score_total=alpha * sv + beta * sg,
+ type=ntype,
+ score_total=(alpha * sv + beta * sg) * decay,
score_vector=sv,
score_graph=sg,
+ recency=round(decay, 4),
+ age_days=(lambda a: round(a, 1) if a is not None else None)(age_days(ts)),
))
results.sort(key=lambda r: r.score_total, reverse=True)
results = results[:total_k]
@@ -330,7 +352,16 @@ def route(self, query: str, top_k: int | None = None) -> RouterResponse:
"execution_path": {
"linked_seeds": linked_seeds,
"vector_seeds": seeds,
- "graph_hops": hops,
+ "graph_hops": hops, # each hop carries its relation
+ },
+ "recency": {
+ "enabled": config.RECENCY_ENABLED,
+ "floor": config.RECENCY_FLOOR,
+ # per-node age + multiplier, so the viewer can show *why* something ranked
+ "applied": [
+ {"id": r.id, "age_days": r.age_days, "factor": r.recency}
+ for r in results if r.age_days is not None
+ ],
},
"metrics": {
"graph_hits": graph_hits,
diff --git a/frontend/src/components/right/EntityNode.tsx b/frontend/src/components/right/EntityNode.tsx
index 4edb6ce..bdc2560 100644
--- a/frontend/src/components/right/EntityNode.tsx
+++ b/frontend/src/components/right/EntityNode.tsx
@@ -16,11 +16,24 @@ import type { TraceNode } from "@/types/trace";
export type EntityNodeData = TraceNode;
+// compact age for the node chip: 4d, 3w, 8mo, 2y
+function formatAge(days: number): string {
+ if (days < 1) return "today";
+ if (days < 14) return `${Math.round(days)}d`;
+ if (days < 60) return `${Math.round(days / 7)}w`;
+ if (days < 365) return `${Math.round(days / 30)}mo`;
+ return `${(days / 365).toFixed(1)}y`;
+}
+
function EntityNodeComponent({ data, selected }: NodeProps
{style.label} + {decayed && ( + + {formatAge(ageDays!)} + + )}
@@ -129,6 +154,7 @@ function EntityNodeComponent({ data, selected }: NodeProps