# Temporal Query API Design — Issue #99

**Date:** 2026-03-09
**Scope:** `/Users/masa/Projects/mcp-vector-search/src/mcp_vector_search/`
**Purpose:** Design a pragmatic v1 temporal query API reusing existing infrastructure

---

## 1. Current `CodeEntity` Schema (Confirmed)

File: `src/mcp_vector_search/core/knowledge_graph.py`, lines 262-270

```sql
CREATE NODE TABLE IF NOT EXISTS CodeEntity (
    id STRING PRIMARY KEY,
    name STRING,
    entity_type STRING,
    file_path STRING,
    commit_sha STRING,
    created_at TIMESTAMP DEFAULT current_timestamp()
)
```

All relationship tables (CALLS, IMPORTS, INHERITS, CONTAINS, FOLLOWS, REFERENCES, DOCUMENTS, HAS_TAG, DEMONSTRATES) carry:

```sql
weight DOUBLE DEFAULT 1.0,
commit_sha STRING,
MANY_MANY
```

So `commit_sha` is present on **both nodes and edges** in the schema. The schema is already positioned for temporal filtering — the data population is the gap (see §2).

---

## 2. `commit_sha` — Populated or Empty in Practice?

**Finding: `commit_sha` on `CodeEntity` is EMPTY for all code entities in normal indexing.**

### Trace

**Step A — `CodeChunk.commit_hash` origin**

`CodeChunk.commit_hash` (defined in `src/mcp_vector_search/core/models.py`, line 258) is populated only when git blame enrichment runs. In `chunk_processor.py` lines 418-421:

```python
if blame:
    chunk.last_author = blame.author
    chunk.last_modified = blame.timestamp
    chunk.commit_hash = blame.commit_hash   # set from GitBlameCache
```

`GitBlameCache` (in `git_blame.py`) runs `git blame --porcelain` and captures the commit hash of the **most recent modification** to each line range.

**Step B — `CodeEntity` construction in `kg_builder.py`**

At lines 1275-1280 (batch path, `_extract_code_entity`) and lines 1360-1365 (deprecated single path, `_process_chunk`):

```python
entity = CodeEntity(
    id=chunk_id,
    name=name,
    entity_type=chunk.chunk_type,
    file_path=str(chunk.file_path),
    # commit_sha NOT passed — defaults to None
)
```

`chunk.commit_hash` is never read here. The `commit_sha` field in the dataclass defaults to `None`, and in the batch MERGE query (`add_entities_batch`, bak line 1321) it becomes `""` (empty string).

**Step C — Where `commit_sha` IS set in the KG**

`commit_sha` IS populated on AUTHORED/MODIFIED relationship edges (Person→CodeEntity) via `_extract_authorship_fast` / `_extract_authorship_fast_sync`. These read `git log --format="%H|%an|%ae|%aI" --name-only` and attach each commit hash to the authorship edge. But this is the relationship edge, not the node.

**Conclusion:** In a live indexed graph, every `CodeEntity.commit_sha` is `""` (empty string). The `AUTHORED`/`MODIFIED` edge property `commit_sha` contains real commit SHAs, but that links a Person to an entity, not a code-structure snapshot.

---

## 3. `git_blame.py` — Called During Indexing or On-Demand Only?

**Finding: Called during indexing only when the `--git-blame` flag is passed.**

### Evidence

In `chunk_processor.py`, `_enrich_chunks_with_blame()` is only invoked when `self.git_blame_cache` is set (i.e., not `None`). The cache is only instantiated when the indexing pipeline is configured with the blame option.

In `git_blame.py`, `enrich_with_git_blame()` is an on-demand async function for enriching **search results** (not the KG). It is called post-search to populate `last_author`, `last_modified`, `commit_hash` on `SearchResult` objects — completely separate from the KG build path.

### Summary of the two git blame code paths

| Path | Function | When Used | Sets |
|---|---|---|---|
| Indexing | `GitBlameCache.get_blame_for_range()` | `--git-blame` flag at index time | `CodeChunk.commit_hash` (in-memory only, not persisted to KG) |
| Search enrichment | `enrich_with_git_blame()` | On every search result if `--git-blame` flag active | `SearchResult.commit_hash` (response only) |

Neither path currently writes `commit_sha` into the `CodeEntity` KG node.

---

## 4. The `.bak` File — What Was Attempted, Why Abandoned

File: `src/mcp_vector_search/core/knowledge_graph.py.bak`

### What it tried

The `.bak` file is a much more elaborate temporal schema using a **subprocess-isolation architecture** (multiprocessing.Process for every single Kuzu operation). Key additions over the current `knowledge_graph.py`:

1. **New node tables**: `Commit`, `ProgrammingLanguage`, `ProgrammingFramework`, `Repository`, `Branch` — a full version-control ontology inside Kuzu.

2. **New relationship tables**: `MODIFIES` (Commit→CodeEntity), `COMMITTED_TO` (Commit→Branch), `BELONGS_TO` (Branch→Repository), `BRANCHED_FROM` (Branch→Branch), `WRITTEN_IN` (CodeEntity→ProgrammingLanguage), `USES_FRAMEWORK` (Project→ProgrammingFramework).

3. **Full temporal tracking intent**: The `Commit` node has `sha`, `message`, `author_name`, `author_email`, `timestamp`. The `MODIFIES` relationship tracks `lines_added`/`lines_deleted`. This enables proper "what entities did commit X touch?" queries natively in Kuzu.

4. **Query methods**: `find_entity_by_name`, `find_related`, `get_call_graph`, `get_inheritance_tree`, `get_doc_references`, `get_visualization_data`, `get_stats`, `get_cross_entity_samples` — largely identical to the current `knowledge_graph.py`.

### Why it was abandoned

The subprocess-per-operation approach was the fatal flaw. Every `add_entity`, `add_relationship`, etc. spawned a new `multiprocessing.Process` with a 300-second timeout. This was:

- Catastrophically slow for batch builds (each entity = one process spawn)
- Architecturally broken: the subprocess strategy was invented to avoid Kuzu segfaults from PyTorch/gRPC background threads, but the solution was to serialize the entire build into a subprocess, not per-operation processes

The current `knowledge_graph.py` correctly resolves this with a thread lock (`_kuzu_lock`) and direct `conn.execute()` calls within the main process. The `.bak` file was the failed experiment before that solution was found.

**What the `.bak` file proves:** The full `Commit` node table + `MODIFIES` edge design was explicitly planned but abandoned due to the execution model. The temporal schema itself is sound — the implementation approach was broken.

---

## 5. Existing Temporal / History Sketches

Grep results for `entity_history`, `as_of`, `temporal`, `valid_from`, `valid_to` across `src/mcp_vector_search/`:

- **Zero matches** for `entity_history`, `as_of`, `valid_from`, `valid_to`
- `temporal` appears only in docstring prose ("temporal knowledge graph that tracks") — no implemented method
- No `get_entity_history` method exists anywhere

The only commit-ordering utility found is in `git_hooks.py` (`get_changed_files_since_commit`), which runs `git diff --name-only <hash> HEAD` — useful but not a general commit-ordering check.

`CommitInfo` exists in `src/mcp_vector_search/story/models.py` and is used by the story generator's git log extractor. It has `hash`, `short_hash`, `message`, `author`, `date`, `files_changed` fields. It is **not imported or used** by the KG subsystem.

---

## 6. Git Log Access Utilities Available

| Utility | Location | Does |
|---|---|---|
| `GitBlameCache` | `core/git_blame.py` | `git blame --porcelain` per file, cached |
| `enrich_with_git_blame()` | `core/git_blame.py` | Async per-result blame for search results |
| `get_changed_files_since_commit()` | `core/git_hooks.py` | `git diff --name-only <hash> HEAD` |
| `_extract_git_authors` / `_extract_git_authors_sync` | `core/kg_builder.py` | `git log --format="%H|%an|%ae|%aI" --name-only` for authorship |
| `CommitInfo` + `_extract_commits()` | `story/extractor.py` | Full `git log` with numstat, isolated to story feature |

No utility exists for `git merge-base --is-ancestor A B` (commit ordering check). This must be added.

---

## 7. Existing MCP Tool Surface and Handler Pattern

### Current KG MCP tools (from `tool_schemas.py` + `kg_handlers.py`)

| Tool Name | Handler Method | Description |
|---|---|---|
| `kg_build` | `handle_kg_build` | Build/rebuild KG from indexed chunks |
| `kg_stats` | `handle_kg_stats` | Entity/relationship counts |
| `kg_query` | `handle_kg_query` | Relationship query for a named entity |
| `kg_ontology` | `handle_kg_ontology` | Doc ontology browsing |
| `kg_ia` | `handle_kg_ia` | Information Architecture tree |

### Handler pattern (consistent)

All handlers follow this pattern in `kg_handlers.py`:

```python
async def handle_kg_<name>(self, args: dict[str, Any]) -> CallToolResult:
    try:
        # 1. Extract args
        param = args.get("param", default)
        # 2. Init KG
        kg_path = self.project_root / ".mcp-vector-search" / "knowledge_graph"
        kg = KnowledgeGraph(kg_path)
        await kg.initialize()
        # 3. Check non-empty
        stats = await kg.get_stats()
        if stats["total_entities"] == 0: return error
        # 4. Call KG method
        result = await kg.some_method(param)
        await kg.close()
        # 5. Format JSON response
        return CallToolResult(content=[TextContent(type="text", text=json.dumps(result))])
    except Exception as e:
        return CallToolResult(content=[...error...], isError=True)
```

Tool schemas follow a standard MCP `Tool(name=..., description=..., inputSchema={...})` pattern in `tool_schemas.py`, registered in `get_all_tool_schemas()`.

### CLI pattern (`cli/commands/kg.py`)

Each command is `@kg_app.command("name")` with a `typer.Option` / `typer.Argument`, then an inner `async def _run()` called via `asyncio.run()`.

---

## 8. Pragmatic v1 Design

### Core insight

The KG already has `commit_sha STRING` on every `CodeEntity` and every relationship edge — but it is always `""`. The **one fix needed** is to populate it during `kg_build`. With `commit_sha` populated, temporal filtering becomes a pure Kuzu query filter (`WHERE e.commit_sha = $sha` or `WHERE e.commit_sha IN $ancestor_shas`).

The expensive path (full `EntityVersion` node table, `valid_from`/`valid_to` intervals) requires re-indexing all history and is off-limits for v1.

### Phase 0 — Wire `commit_sha` into `CodeEntity` (prerequisite, ~40 lines)

**Where:** `kg_builder.py`, `_extract_code_entity()` (line 1275)

**Change:** Read `chunk.commit_hash` and pass it to `CodeEntity`:

```python
entity = CodeEntity(
    id=chunk_id,
    name=name,
    entity_type=chunk.chunk_type,
    file_path=str(chunk.file_path),
    commit_sha=chunk.commit_hash or "",   # ADD THIS
)
```

**Prerequisite condition:** `commit_hash` is only populated if the indexer ran with `--git-blame`. To make temporal queries work *without* requiring `--git-blame` at index time, a lightweight fallback is needed: at `kg_build` time, read the HEAD commit once via `git rev-parse HEAD` and use it as the default `commit_sha` for all entities whose `chunk.commit_hash` is `None`. This is coarse (all entities tagged with HEAD) but gives a non-empty baseline.

**Better approach:** At the start of `build_knowledge_graph()`, run:
```
git log --format="%H" --follow -- <file_path>
```
for each unique file path in the chunk set. This gives the last commit that touched each file — accurate without requiring full `--git-blame`. This maps `file_path -> last_commit_sha`, and each entity uses its file's last commit. Cost: O(unique files) git invocations, already partially done in `_extract_authorship_fast`.

Reuse the existing `git log --format="%H|%an|%ae|%aI" --name-only` result (which `_extract_authorship_fast` already produces) to build a `file_path -> most_recent_commit_sha` lookup table with zero additional git calls.

**Effort:** ~40 lines in `kg_builder.py`, no schema changes.

### New `KnowledgeGraph` Methods

#### `get_entities_at_commit(commit_sha: str) -> list[dict]`

```python
async def get_entities_at_commit(self, commit_sha: str) -> list[dict]:
    """Return all CodeEntity nodes tagged with commit_sha.

    For v1, this is a simple equality filter. With a richer population
    strategy (file->commit map), this reflects the state of files at
    the indexed commit.
    """
    result = self._execute_query_with_result(
        """
        MATCH (e:CodeEntity)
        WHERE e.commit_sha = $sha
        RETURN e.id, e.name, e.entity_type, e.file_path, e.commit_sha
        """,
        {"sha": commit_sha},
    )
    # ... unpack and return
```

**~30 lines.**

#### `get_callers_at_commit(function_name: str, commit_sha: str) -> list[dict]`

```python
async def get_callers_at_commit(self, function_name: str, commit_sha: str) -> list[dict]:
    """Return entities that CALL function_name, filtered to those tagged with commit_sha."""
    result = self._execute_query_with_result(
        """
        MATCH (caller:CodeEntity)-[r:CALLS]->(callee:CodeEntity)
        WHERE callee.name = $name
          AND caller.commit_sha = $sha
        RETURN caller.id, caller.name, caller.entity_type, caller.file_path,
               r.commit_sha AS edge_commit
        """,
        {"name": function_name, "sha": commit_sha},
    )
```

**~30 lines.**

#### `is_ancestor_commit(earlier: str, later: str, repo_root: Path) -> bool`

A pure git utility (not a KG method — belongs in a new `core/git_utils.py` or added to `git_hooks.py`):

```python
def is_ancestor_commit(earlier: str, later: str, repo_root: Path) -> bool:
    """Return True if `earlier` is an ancestor of `later` in the git DAG.

    Uses: git merge-base --is-ancestor <earlier> <later>
    Exit code 0 = ancestor, 1 = not ancestor, other = error.
    """
    result = subprocess.run(
        ["git", "merge-base", "--is-ancestor", earlier, later],
        cwd=repo_root,
        capture_output=True,
        timeout=10,
    )
    return result.returncode == 0
```

**~20 lines.** This is the primitive needed to answer "is commit A before commit B?"

#### `get_entities_between_commits(from_sha: str, to_sha: str, repo_root: Path) -> list[dict]`

Uses `git log --format="%H" <from_sha>..<to_sha>` to enumerate all commits in range, then queries `WHERE e.commit_sha IN $shas`. Answers: "what new entities appeared between these two commits?"

**~50 lines.**

### New MCP Tools

#### `kg_history` — Commit-scoped entity query

```json
{
  "name": "kg_history",
  "description": "Query KG entities and relationships scoped to a specific commit SHA. Returns what functions/classes existed (were last modified) at that commit.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "commit": {"type": "string", "description": "Git commit SHA (full or abbreviated 7-char)"},
      "entity": {"type": "string", "description": "Optional: filter to callers/callees of this entity"},
      "relationship": {"type": "string", "enum": ["calls", "called_by", "all"], "default": "all"},
      "limit": {"type": "integer", "default": 30}
    },
    "required": ["commit"]
  }
}
```

Handler: `handle_kg_history` in `kg_handlers.py`. Calls `kg.get_entities_at_commit(commit)` or `kg.get_callers_at_commit(entity, commit)`.

**~80 lines** in handler + ~30 lines schema.

#### `kg_diff` — What changed between two commits

```json
{
  "name": "kg_diff",
  "description": "Show what KG entities appeared or disappeared between two commits. Requires commit_sha to be populated (kg_build with git metadata).",
  "inputSchema": {
    "type": "object",
    "properties": {
      "from_commit": {"type": "string"},
      "to_commit":   {"type": "string"}
    },
    "required": ["from_commit", "to_commit"]
  }
}
```

**~80 lines** handler + ~20 lines schema.

### New CLI Commands Under `mvs kg`

| Command | Signature | Maps to |
|---|---|---|
| `mvs kg history <commit>` | `--entity NAME --relationship calls` | `kg.get_entities_at_commit` + optional callers filter |
| `mvs kg diff <from> <to>` | (no flags needed) | `kg.get_entities_between_commits` |
| `mvs kg ancestor <sha-a> <sha-b>` | (diagnostic) | `is_ancestor_commit`, prints True/False |

Each follows the existing pattern in `cli/commands/kg.py`: `@kg_app.command("history")`, inner `async def _run()`, `asyncio.run(_run())`.

**~150 lines total** for all three CLI commands.

---

## Summary Table

| Finding | Answer |
|---|---|
| `commit_sha` populated? | **No — always `""` in current indexing.** Schema column exists; write path missing in `_extract_code_entity`. |
| `git_blame.py` called during indexing? | **Only with `--git-blame` flag.** Default indexing: `commit_hash = None` on all chunks. |
| `.bak` file — what did it try? | Full temporal schema with `Commit` node table, `MODIFIES`/`COMMITTED_TO`/`BRANCHED_FROM` edges. Abandoned because per-operation subprocess isolation was catastrophically slow and unnecessary. |
| Existing temporal queries? | **None.** Zero `entity_history`, `as_of`, `valid_from`, `valid_to` implementations. |
| `CommitInfo` available? | Yes, in `story/models.py`, not used by KG. |

---

## Recommended Implementation Order

### Step 1 — Populate `commit_sha` on `CodeEntity` (prerequisite)
**File:** `kg_builder.py`, `_extract_code_entity()` (~40 lines)
- Reuse the `file_path -> commit_sha` mapping already built by `_extract_authorship_fast`
- Pass `commit_sha=file_commit_map.get(str(chunk.file_path), "")` to `CodeEntity()`
- No schema changes, no re-indexing of history, no new git calls
- Estimated effort: **1-2 hours**

### Step 2 — Add git utility: `is_ancestor_commit`
**File:** New `core/git_utils.py` or append to `core/git_hooks.py` (~20 lines)
- `git merge-base --is-ancestor A B`
- Estimated effort: **30 minutes**

### Step 3 — Add `KnowledgeGraph` query methods
**File:** `core/knowledge_graph.py`
- `get_entities_at_commit(commit_sha)` — ~30 lines
- `get_callers_at_commit(name, commit_sha)` — ~30 lines
- `get_entities_between_commits(from_sha, to_sha, repo_root)` — ~50 lines
- Estimated effort: **2-3 hours**

### Step 4 — Add MCP tools
**Files:** `mcp/tool_schemas.py` + `mcp/kg_handlers.py`
- `kg_history` tool schema (~30 lines) + handler (~80 lines)
- `kg_diff` tool schema (~20 lines) + handler (~80 lines)
- Estimated effort: **2-3 hours**

### Step 5 — Add CLI commands
**File:** `cli/commands/kg.py`
- `mvs kg history` (~60 lines)
- `mvs kg diff` (~60 lines)
- `mvs kg ancestor` (~30 lines, diagnostic)
- Estimated effort: **2 hours**

**Total estimated LOC:** ~580 lines
**Total estimated effort:** 8-11 hours
**Schema changes required:** None — `commit_sha STRING` already exists on all relevant tables.
**Re-indexing of history required:** None — temporal filtering only works for commits covered by the current index, which is by design for v1.

---

## Key Design Decision: Scope of v1 Temporal Accuracy

The v1 approach tags each entity with the **most recent commit that touched its file** at the time `kg_build` runs. This means:

- `get_entities_at_commit("abc123")` returns entities whose file was last modified at `abc123` — not entities that *existed* at `abc123`
- This is a **current-state filter**, not true bi-temporal history
- True bi-temporal queries ("what was the call graph at commit X three months ago?") require indexing past snapshots — out of scope for v1

This is correct behavior and should be documented in the MCP tool descriptions so users understand the semantics.
