Skip to content

feat: incremental knowledge graph updates (closes #221)#789

Open
saij3b wants to merge 1 commit into
potpie-ai:mainfrom
saij3b:bounty/221-incremental-knowledge-graph-updates
Open

feat: incremental knowledge graph updates (closes #221)#789
saij3b wants to merge 1 commit into
potpie-ai:mainfrom
saij3b:bounty/221-incremental-knowledge-graph-updates

Conversation

@saij3b

@saij3b saij3b commented May 20, 2026

Copy link
Copy Markdown

Closes #221.

Adds an incremental knowledge-graph update system: FileSnapshot content-hash view, detect_changes() differential analyzer (added/modified/deleted), and IncrementalGraphUpdater doing scoped neo4j DETACH DELETE only for affected file_paths (preserving untouched nodes/relationships/inferences) and writing the filtered subgraph via the existing CodeGraphService._store_graph path. Includes unit tests for snapshot hashing, change-detection partitions, no-op short-circuits, blank-path filtering, and scoped-cleanup guarantees.

Summary by CodeRabbit

  • New Features

    • Incremental knowledge graph updates now supported—the system can apply changes to existing graphs without full rebuilds, improving performance when files are added, modified, or deleted.
  • Tests

    • Comprehensive unit test coverage for incremental graph update functionality.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR introduces an incremental knowledge-graph update service that detects file changes between snapshots, cleans up affected graph nodes, and applies differential updates without full reconstruction. It includes change detection, Neo4j-backed cleanup operations, and filtered subgraph storage, with comprehensive unit tests validating hashing, change detection, and updater behaviors.

Changes

Incremental Graph Updates

Layer / File(s) Summary
Data Contracts: FileSnapshot and ChangeSet
app/modules/parsing/graph_construction/incremental_update_service.py, tests/unit/parsing/test_incremental_update.py
FileSnapshot stores content-hashed file paths with deterministic SHA-256 hashing and copy-on-construction to prevent external mutations. ChangeSet categorizes files as added, modified, or deleted, with computed properties for affected and upserted files. Tests verify immutability and correct partitioning.
Change Detection
app/modules/parsing/graph_construction/incremental_update_service.py
detect_changes compares two snapshots by file-path keys and content hashes, computing added/deleted sets via key diffs and modified paths via hash inequality, returning a populated ChangeSet.
Incremental Graph Updater Service
app/modules/parsing/graph_construction/incremental_update_service.py, tests/unit/parsing/test_incremental_update.py
IncrementalGraphUpdater wraps CodeGraphService to apply incremental updates: get_existing_file_snapshot reconstructs a snapshot from live Neo4j FILE nodes, cleanup_files deletes all NODE entities for affected paths, apply_changes orchestrates the flow and short-circuits on empty changesets, and _store_subgraph reconstructs and filters a NetworkX graph to only upserted files before persisting. Tests verify cleanup path normalization, empty-changeset short-circuiting, and deletion-only application without artifact storage.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant IncrementalGraphUpdater
  participant Neo4j
  participant CodeGraphService
  Caller->>IncrementalGraphUpdater: apply_changes(changeset, new_artifacts)
  IncrementalGraphUpdater->>Neo4j: get_existing_file_snapshot()
  Neo4j-->>IncrementalGraphUpdater: FILE node data
  IncrementalGraphUpdater->>IncrementalGraphUpdater: detect affected files
  IncrementalGraphUpdater->>Neo4j: cleanup_files(affected_paths)
  Neo4j-->>IncrementalGraphUpdater: deleted node count
  alt new_artifacts provided
    IncrementalGraphUpdater->>IncrementalGraphUpdater: reconstruct filtered subgraph
    IncrementalGraphUpdater->>CodeGraphService: _store_graph(filtered subgraph)
    CodeGraphService->>Neo4j: persist nodes and edges
    Neo4j-->>CodeGraphService: insertion stats
    CodeGraphService-->>IncrementalGraphUpdater: node/edge counts
  end
  IncrementalGraphUpdater-->>Caller: stats dict (added, modified, deleted)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through graphs incremental,
Snapshots hashed, changes documented,
No full rebuild, just affected nodes—
Efficiency blooms where delta flows!
thump thump on the Neo4j garden path 🌿

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive While core change detection and incremental update mechanisms are implemented, several linked issue requirements remain unclear or potentially unaddressed. Verify that relationship integrity handling, cascading updates, inference preservation, versioning/tracking, error handling, and rollback capabilities are implemented or planned.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: implementing incremental knowledge graph updates, which aligns with the primary objective of the changeset.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the incremental update system with change detection, graph cleanup, and selective reapplication of changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/modules/parsing/graph_construction/incremental_update_service.py`:
- Around line 119-142: get_existing_file_snapshot builds the snapshot from nodes
labeled FILE, but cleanup_files only removes nodes labeled NODE, so FILE-labeled
nodes can survive and reappear in future diffs; update the cleanup logic
(cleanup_files and any related deletion queries) to target the same file-backed
nodes used by get_existing_file_snapshot—i.e., delete nodes with the FILE label
(or both FILE and NODE) instead of only :NODE—so that removed files are actually
removed from the live graph and won't be included in subsequent snapshots.
- Around line 162-175: The delete-before-insert is not atomic: the session.run
deletion using self.code_graph_service.driver.session() must be combined with
the subsequent _store_subgraph()/_store_graph() call inside a single write
transaction (e.g., use a write_transaction or transaction context) so that both
deletion and reinsertion commit together, or implement a compensating rollback
path if the store fails; update the code paths around the MATCH...DETACH DELETE
query and the calls to _store_subgraph()/_store_graph() to run within the same
transactional unit provided by code_graph_service (or explicitly roll back and
restore deleted nodes on failure).
- Around line 38-50: FileSnapshot is only shallowly immutable because
frozen=True prevents rebinding but allows in-place mutation of the files dict;
modify FileSnapshot.from_files to wrap the copied dict in an immutable read-only
mapping (e.g., use types.MappingProxyType on dict(files)) and update the files
type annotation to a read-only mapping (e.g., Mapping[str, str]) so callers
cannot mutate snapshot.files and subsequent detect_changes() sees stable data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d8b9ce4-b8d0-4536-aeba-9e224305171b

📥 Commits

Reviewing files that changed from the base of the PR and between 20b53fe and 91699ff.

📒 Files selected for processing (2)
  • app/modules/parsing/graph_construction/incremental_update_service.py
  • tests/unit/parsing/test_incremental_update.py

Comment on lines +38 to +50
@dataclass(frozen=True)
class FileSnapshot:
"""Content-hashed view of a set of files at a point in time.

``files`` maps repo-relative file path → SHA-256 hex digest of the
file contents. Two snapshots are comparable via :func:`detect_changes`.
"""

files: Dict[str, str] = field(default_factory=dict)

@classmethod
def from_files(cls, files: Dict[str, str]) -> "FileSnapshot":
return cls(files=dict(files))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

FileSnapshot is only shallowly immutable.

frozen=True prevents rebinding files, but callers can still mutate snapshot.files[...] in place. That can silently skew later detect_changes() results if a snapshot is reused. Wrap the mapping in a read-only container during construction.

Proposed fix
+from types import MappingProxyType
-from typing import Dict, Iterable, List, Optional, Set
+from typing import Dict, Iterable, List, Mapping, Optional, Set
@@
 `@dataclass`(frozen=True)
 class FileSnapshot:
@@
-    files: Dict[str, str] = field(default_factory=dict)
+    files: Mapping[str, str] = field(default_factory=dict)
+
+    def __post_init__(self) -> None:
+        object.__setattr__(self, "files", MappingProxyType(dict(self.files)))
@@
     `@classmethod`
     def from_files(cls, files: Dict[str, str]) -> "FileSnapshot":
-        return cls(files=dict(files))
+        return cls(files=files)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@dataclass(frozen=True)
class FileSnapshot:
"""Content-hashed view of a set of files at a point in time.
``files`` maps repo-relative file pathSHA-256 hex digest of the
file contents. Two snapshots are comparable via :func:`detect_changes`.
"""
files: Dict[str, str] = field(default_factory=dict)
@classmethod
def from_files(cls, files: Dict[str, str]) -> "FileSnapshot":
return cls(files=dict(files))
`@dataclass`(frozen=True)
class FileSnapshot:
"""Content-hashed view of a set of files at a point in time.
``files`` maps repo-relative file pathSHA-256 hex digest of the
file contents. Two snapshots are comparable via :func:`detect_changes`.
"""
files: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
object.__setattr__(self, "files", MappingProxyType(dict(self.files)))
`@classmethod`
def from_files(cls, files: Dict[str, str]) -> "FileSnapshot":
return cls(files=files)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/modules/parsing/graph_construction/incremental_update_service.py` around
lines 38 - 50, FileSnapshot is only shallowly immutable because frozen=True
prevents rebinding but allows in-place mutation of the files dict; modify
FileSnapshot.from_files to wrap the copied dict in an immutable read-only
mapping (e.g., use types.MappingProxyType on dict(files)) and update the files
type annotation to a read-only mapping (e.g., Mapping[str, str]) so callers
cannot mutate snapshot.files and subsequent detect_changes() sees stable data.

Comment on lines +119 to +142
def get_existing_file_snapshot(self, project_id: str) -> FileSnapshot:
"""Reconstruct a :class:`FileSnapshot` from the live graph.

Each ``FILE`` node carries a ``file_path`` and ``text`` property
(the file's source). We hash the text to produce the snapshot
entry. Files without a ``text`` property fall back to an empty
hash sentinel so they still participate in deletion detection.
"""
snapshot: Dict[str, str] = {}
with self.code_graph_service.driver.session() as session:
result = session.run(
"""
MATCH (n:FILE {repoId: $project_id})
RETURN n.file_path AS file_path,
COALESCE(n.text, '') AS text
""",
project_id=project_id,
)
for record in result:
path = record["file_path"]
if not path:
continue
snapshot[path] = FileSnapshot.hash_content(record["text"] or "")
return FileSnapshot(files=snapshot)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Snapshot/delete label mismatch can keep deleted files in future diffs.

get_existing_file_snapshot() rebuilds state from :FILE nodes, but cleanup_files() only deletes :NODE. If FILE is not also labeled NODE, modified/deleted files survive in the next snapshot and future diffs will treat them as still present. Cleanup should target the same file-backed nodes that snapshot reconstruction reads from.

One possible fix
-                MATCH (n:NODE {repoId: $project_id})
+                MATCH (n {repoId: $project_id})
                 WHERE n.file_path IN $paths

Also applies to: 147-175

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/modules/parsing/graph_construction/incremental_update_service.py` around
lines 119 - 142, get_existing_file_snapshot builds the snapshot from nodes
labeled FILE, but cleanup_files only removes nodes labeled NODE, so FILE-labeled
nodes can survive and reappear in future diffs; update the cleanup logic
(cleanup_files and any related deletion queries) to target the same file-backed
nodes used by get_existing_file_snapshot—i.e., delete nodes with the FILE label
(or both FILE and NODE) instead of only :NODE—so that removed files are actually
removed from the live graph and won't be included in subsequent snapshots.

Comment on lines +162 to +175
with self.code_graph_service.driver.session() as session:
result = session.run(
"""
MATCH (n:NODE {repoId: $project_id})
WHERE n.file_path IN $paths
WITH n, count(*) AS _
DETACH DELETE n
RETURN count(n) AS deleted
""",
project_id=project_id,
paths=paths,
)
record = result.single()
deleted = int(record["deleted"]) if record else 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Delete and reinsert are not atomic.

The cleanup commits before _store_subgraph() runs. If _store_graph() fails, the affected files stay deleted and the project graph is left partially updated. That breaks the rollback/partial-failure requirement called out in the PR objective. Run both steps in a single write transaction or add a compensating rollback path.

Also applies to: 213-223, 284-285

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/modules/parsing/graph_construction/incremental_update_service.py` around
lines 162 - 175, The delete-before-insert is not atomic: the session.run
deletion using self.code_graph_service.driver.session() must be combined with
the subsequent _store_subgraph()/_store_graph() call inside a single write
transaction (e.g., use a write_transaction or transaction context) so that both
deletion and reinsertion commit together, or implement a compensating rollback
path if the store fails; update the code paths around the MATCH...DETACH DELETE
query and the calls to _store_subgraph()/_store_graph() to run within the same
transactional unit provided by code_graph_service (or explicitly roll back and
restore deleted nodes on failure).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incremental Knowledge Graph updates

1 participant