feat(resolution): IncrementalResolution — graph-aware entity linking - #278
feat(resolution): IncrementalResolution — graph-aware entity linking#278galshubeli wants to merge 10 commits into
Conversation
Resolve a document's entities against the knowledge graph built so far, so a
mention can link to an entity extracted from an earlier document — the
cross-document case ordinary batch resolution structurally cannot see.
Pipeline (a funnel; the LLM only where cheap signals can't decide):
1. collapse a batch's own same-name duplicates (description-gated, free)
2. retrieve look-alike existing entities per survivor
3. pile survivors that share a candidate
4. one LLM partition per pile: which are the same entity, and which
existing node (if any) is the merge target
Same-name/different-type homographs (GraphRAG Concept vs Technology) resolve
for free by description similarity; different-name matches are judged by the
LLM. Existing graph nodes win the id so the store's MERGE attaches the new
mention with no special-casing. LLM owns free text only; provenance is unioned
and immutable-property conflicts are flagged, never guessed.
Additive and self-contained: new strategy class + exports only. No existing
strategy or shared code is touched. Closes the homograph gap in #277.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds ChangesIncremental entity resolution
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ExtractedEntities
participant IncrementalResolution
participant CandidateRetriever
participant LLMInterface
participant GraphData
ExtractedEntities->>IncrementalResolution: resolve graph data
IncrementalResolution->>CandidateRetriever: retrieve graph candidates
CandidateRetriever-->>IncrementalResolution: return candidate nodes
IncrementalResolution->>LLMInterface: classify candidate piles
LLMInterface-->>IncrementalResolution: return linking decisions
IncrementalResolution->>GraphData: apply merges and relationship remaps
Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new IncrementalResolution strategy that performs graph-aware, incremental entity linking: it collapses within-batch same-name duplicates, retrieves similar existing graph entities, forms “piles” of connected survivors, and uses an LLM once per pile to decide merges/links—enabling cross-document deduplication.
Changes:
- Introduces
IncrementalResolutionresolver with injectedcandidate_retriever+ embedding-based within-batch collapse + LLM pile partitioning/linking. - Exposes
IncrementalResolutionviagraphrag_sdk.ingestion.resolution_strategiesand top-level package exports. - Adds a dedicated test suite covering core behaviors (linking, no-candidate fast path, free merges, homographs, conflicts, malformed LLM response, name normalization).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| graphrag_sdk/tests/test_incremental_resolution.py | Adds tests for cross-document linking and the incremental funnel stages. |
| graphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/incremental_resolution.py | Implements the new incremental, graph-aware resolution strategy. |
| graphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/init.py | Re-exports IncrementalResolution from the resolution strategies package. |
| graphrag_sdk/src/graphrag_sdk/init.py | Re-exports IncrementalResolution from the SDK’s main package interface. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
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
`@graphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/incremental_resolution.py`:
- Around line 384-399: Widen the exception guard in _embed to include the
embedding result conversion and normalization setup, including np.array
construction, so malformed or ragged vectors are caught and return None through
the existing warning path. Preserve the current successful embedding behavior
and fail-toward-splitting semantics.
- Around line 321-345: Update _build_pile so survivor items are limited to
self.pile_cap before candidates are added, preserving pile order and survivor
indices. Ensure the returned items never exceed pile_cap, and leave truncated
survivors unlinked for this round; retain the existing candidate deduplication
and candidate-order behavior for remaining capacity.
- Around line 362-382: Update _absorb to preserve the loser’s existing
_CONFLICTS entries and propagate its _NEEDS_REVIEW flag before skipping those
metadata keys in the property loop. Merge conflict records with the survivor’s
conflicts without discarding prior entries, and set _NEEDS_REVIEW whenever
either node was previously flagged or a new immutable-property conflict is
detected.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: e0150ac9-2783-43d0-88e9-b5ad1d8e159a
📒 Files selected for processing (4)
graphrag_sdk/src/graphrag_sdk/__init__.pygraphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/__init__.pygraphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/incremental_resolution.pygraphrag_sdk/tests/test_incremental_resolution.py
…get parsing; retriever error tolerance
Addresses review feedback:
- Linking a batch entity into an existing graph node now keeps that node's
label. Graph writes are label-scoped (MERGE (n:<label> {id})), so applying
the LLM's type could create a second same-id node under a different label.
- _parse_decisions coerces int-like members and target ('5', 5.0) so a
string/float target no longer silently skips linking into an existing node.
- Candidate-retriever failures degrade to 'no candidates' (log + continue)
instead of aborting the whole document.
- Tests: label preservation under a conflicting LLM type, string/float target
linking, and retriever-failure tolerance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GraphRAG facade now defaults to graph-aware IncrementalResolution when an LLM and embedder are configured (wired with a graph-store-backed candidate retriever), and falls back to ExactMatchResolution otherwise so LLM-free setups are unchanged. Documents ingestion.md, strategies.md, and CHANGELOG. Note: the default now adds LLM + embedding calls during ingestion; pass resolver=ExactMatchResolution() for LLM-free ingestion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Naseem77
left a comment
There was a problem hiding this comment.
Findings:
- [medium] incremental_resolution.py:_build_pile — pile_cap only caps candidates. 15 survivors with cap=12 → 15-item prompt and every graph candidate evicted, so the LLM call can never link. (Proven by failing gap test.)
- [medium] _absorb + graph_store._clean_properties — _merge_conflicts is a list of dicts; the store filters it to empty and drops it. _needs_review=True persists but the conflict details never reach the graph. (Proven by failing gap test.)
- [medium] api/main.py:_graph_candidate_retriever — exact toLower(name) equality only; the PR's headline llama_index ≈ LlamaIndex case is unreachable with defaults. Verified live on FalkorDB: both variant lookups return 0 rows. No vector search despite the docstring.
- [low] — same-name nodes with no descriptions auto cross-type merge (description falls back to name → cosine 1.0), contradicting fail-toward-splitting.
- [low] — PR body claims "no shared code modified" but the default resolver changed → new LLM/embedding cost per ingest for existing users. Docs/CHANGELOG do disclose it; PR description should too.
…ust embed/retriever Resolves CodeRabbit + maintainer (Naseem77) review on #278: - _absorb records conflicts as '<field>: <a> vs <b>' STRINGS (the graph store drops lists of dicts, so structured records never persisted) and propagates a loser's existing conflicts + _needs_review on absorption. Verified with a live FalkorDB round-trip. - resolve() splits an oversized survivor pile into chunks so survivors can't breach pile_cap and evict every candidate (linking could never fire). - _same_name_merges requires real descriptions for a cross-type auto-merge; without them, name-vs-name cosine 1.0 no longer merges genuine homographs. - _embed builds the array inside the try/except; ragged embeddings degrade to no-merge instead of crashing the document. - Default candidate retriever folds separators and tries the concatenated form (catches graphrag_sdk<->GraphRAG-SDK and llama_index->LlamaIndex); docs/ docstring corrected to describe it honestly (name-based, best-effort, not semantic) — verified live. Tests: 14 (added conflict-propagation, no-description homograph, large-pile linking, ragged-embedder). Full unit suite green (1008 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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
`@graphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/incremental_resolution.py`:
- Around line 197-201: The chunked linking flow around _link_pile can produce
multiple surviving carriers with the same final_id, causing duplicate upserts
and overwritten metadata. Deduplicate surviving_nodes by resolved final_id
before resolve() returns, retaining one carrier per target and preserving the
intended provenance; update test_large_pile_still_links_candidates with an
assertion that res.nodes contains unique final ids.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: cacf7c60-02d3-4f09-b014-24a6d8b0efc6
📒 Files selected for processing (5)
docs/ingestion.mddocs/strategies.mdgraphrag_sdk/src/graphrag_sdk/api/main.pygraphrag_sdk/src/graphrag_sdk/ingestion/resolution_strategies/incremental_resolution.pygraphrag_sdk/tests/test_incremental_resolution.py
🚧 Files skipped from review as they are similar to previous changes (3)
- docs/strategies.md
- docs/ingestion.md
- graphrag_sdk/src/graphrag_sdk/api/main.py
CodeRabbit re-review: chunked linking (or two LLM groups sharing a target) can retarget two carriers to the same final_id, leaving both in the output → the store MERGEs one (label, id) twice and clobbers the earlier node's provenance/ conflicts. resolve() now collapses same-id survivors, folding provenance and conflict flags into the first keeper. test_large_pile_still_links_candidates asserts res.nodes have unique ids. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @Naseem77 — every finding was correct, and testing against a real FalkorDB (as you did) was exactly what my unit tests missed. All five addressed in 1. 2. 3. Default retriever exact-match only — the retriever now folds separators and tries the concatenated form. Live-verified on FalkorDB: 4. No-description cross-type auto-merge — 5. Stale PR body — updated: removed the "no shared code modified" claim and added a Behavior-change note (default resolver change + per-ingest cost + opt-out). A CodeRabbit re-review then caught a bug my pile-chunking fix introduced (two carriers linked to the same graph id → duplicate upsert); fixed in |
Naseem77
left a comment
There was a problem hiding this comment.
Summary: Adds a well-designed, well-tested graph-aware resolution strategy (596 lines) and flips it to the default resolver for LLM+embedder setups. Core algorithm is solid with genuine fail-safe behavior; the main risks are the retriever's per-entity graph scan, an LLM output shape that isn't validated (overlapping groups), and description overwrite on link.
Findings:
- [medium] graphrag_sdk/src/graphrag_sdk/api/main.py:1766 Candidate retriever is a full Entity scan per resolved entity
• Problem: The Cypher wraps n.name in toLower() / replace() , so no index can serve it. The docstring's claim that "a range index on Entity(name) keeps it cheap" is incorrect: functions applied to the property defeat range indexes.
• Impact: O(graph entities × survivors) per document. On large graphs (the stored perf memory shows FalkorDB writes at 13K+ nodes/s, so graphs get big fast) ingest latency will grow linearly with graph size, per entity, per document.
• Fix: Store a precomputed name_normalized property at write time and query it with an indexed equality, or use a CALL db.idx fulltext lookup. At minimum correct the docstring. - [medium] incremental_resolution.py:_link_pile LLM groups are not validated as disjoint
• Problem: _parse_decisions accepts groups where the same ref appears in multiple groups. A survivor absorbed by group A can be the carrier of group B: _absorb runs twice (duplicated provenance/conflicts), and remap[carrier.id] is silently overwritten last-wins, so group A's members transitively follow group B's target after _flatten .
• Impact: A sloppy but parseable LLM response can merge unrelated entities across groups, which contradicts the fail-toward-splitting stance. The malformed-response test covers unparseable JSON, not this overlapping-refs case.
• Fix: Track consumed refs across a pile's decisions and drop (or skip the whole verdict for) any group reusing a ref. Add a test. - [medium] incremental_resolution.py:_ask / _apply_canonical Linking replaces the existing node's description with a summary of 180-char truncated inputs
• Problem: The prompt shows each description truncated to 180 chars, the LLM writes a merged description from those snippets, and _apply_canonical + the store's SET n += props overwrite the existing graph node's full description (and name ) with it.
• Impact: Repeated ingests progressively erode rich descriptions accumulated on hub entities; information loss is silent and compounding.
• Fix: Send fuller descriptions (budgeted by max_summary_tokens ), or merge the LLM summary with the stored description instead of replacing, or leave the existing node's description unless the LLM saw it untruncated. - [medium] api/main.py:_default_resolver Default behavior change adds LLM + embedding + graph-query cost to every ingest
• Problem: Any user with LLM+embedder configured (the common setup) silently gets extra per-pile LLM calls, per-name-group embedding calls, and per-entity graph queries on upgrade.
• Impact: Ingest latency and cost regression; extraction is already the bottleneck (~15s/chunk per hsbc_perf). Nondeterministic merges may also surprise pipelines that relied on ExactMatch determinism.
• Fix: It is documented (CHANGELOG, docs, PR body), so this is a deliberate product call, not a blocker. Consider a minor-version bump signal and a one-line log at ingest start naming the active resolver (the ctx.log in resolve() covers this partially). - [low] incremental_resolution.py:_same_name_merges Free cross-type merge trusts a fixed cosine bar
• Problem: same_name_threshold=0.80 on min pairwise description cosine auto-merges without LLM review. With some embedders, two same-name but distinct entities with short, domain-adjacent descriptions can clear 0.80.
• Impact: Wrong merge (the unrecoverable direction) via the one path that bypasses the LLM. Risk is acknowledged in the docstring and gated on all members having real descriptions, so severity is low.
• Fix: None required; consider making the threshold embedder-aware or routing borderline (0.75-0.85) groups to the LLM pile instead. - [low] incremental_resolution.py:_fetch_candidates Sequential awaits per survivor
• Problem: Candidates are fetched one survivor at a time.
• Impact: Adds N round-trips of latency per document on top of finding 1.
• Fix: asyncio.gather with per-task exception handling (the existing per-survivor degradation maps cleanly onto return_exceptions=True ). - [low] api/main.py:_graph_candidate_retriever Separator folding misses 3+ consecutive separators and the reverse direction
• Problem: replace(' ', ' ') applied once doesn't fully collapse runs of separators, and a LlamaIndex mention won't find a stored llama_index .
• Impact: Missed links; best-effort behavior is explicitly documented in the docstring, so informational only.
• Fix: Optional; a stored normalized-name property (finding 1) fixes both at once.
…tions Naseem #3: linking rewrote the merged description from 180-char-truncated snippets and overwrote the existing node's full text, eroding rich descriptions across repeated merges. Fix: a single ENTITY_DESCRIPTION_RULE (core/prompts.py) governs how a description is written, used at BOTH extraction and the merge step, so descriptions are consistently bounded. The merge prompt no longer truncates inputs — full descriptions are sent, and each group (= one entity) gets its own merged description written under the same rule. Because inputs are bounded by the rule, no ad-hoc truncation is needed and the merge is faithful rather than lossy. Test: full (>180 char) description is sent to the LLM untruncated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
graphrag_sdk/src/graphrag_sdk/api/main.py:1782
- The default graph candidate query tries to normalize stored names by collapsing double spaces (
replace(..., ' ', ' ')). This does not fully normalize runs of 3+ separators (e.g.'a___b'→'a b'→'a b'), so lookups can miss valid matches even though the Python-side normalization collapses arbitrary runs. Comparing on a fully separator-stripped form avoids this edge case and keeps the matcher consistent for arbitrary separator runs.
folded = re.sub(r"[\s\-_]+", " ", base).strip()
variants = list({base, folded, folded.replace(" ", "")})
result = await store.query_raw(
"MATCH (n:__Entity__) "
"WHERE toLower(n.name) IN $variants "
"OR replace(replace(replace(toLower(n.name), '-', ' '), '_', ' '), ' ', ' ') "
"= $folded "
"RETURN n.id, labels(n), n.name, n.description LIMIT $k",
{"variants": variants, "folded": folded, "k": k},
)
…on wording The reworded rule shifted real-LLM extraction output and failed integration tests (a fixed-text fixture stopped yielding the 'Bob' entity / RELATES edge). Restore wording near the long-standing extraction instruction so extraction behaviour is unchanged; the shared rule + no-truncation description fix stand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- #1/#7 retriever scan → indexed name_key. Store writes a separator-free name_key on every entity (core/text.name_key) and ensures a range index; the default retriever does an indexed equality lookup instead of a toLower(name) scan. Symmetric across tokenization variants (llama_index <-> LlamaIndex). Corrected the false 'range index on name keeps it cheap' docstring. Verified live on FalkorDB (name_key stored, index present, symmetric matches). - #2 disjoint LLM groups: _link_pile drops any group reusing a ref already placed, so a sloppy-but-parseable response can't double-merge unrelated entities. - #5 cross-type free merge now needs the higher cross_type_threshold (0.90); same-type still merges at same_name_threshold (0.80). - #6 candidate retrieval runs concurrently via asyncio.gather (order preserved). - #4 the facade logs which resolver is active and its cost on each ingest. Tests: added overlapping-groups and cross-type-threshold cases. Full unit suite green (1011). Live round-trip validates #1/#7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@Naseem77 all findings addressed. #3 landed earlier ( #1 / #7 — retriever scan → indexed #2 — disjoint LLM groups. #5 — cross-type free merge. Now requires the higher #6 — sequential fetch. Candidate retrieval runs concurrently via #4 — cost signal. The facade now logs the active resolver and its per-ingest cost on each ingest; the default change is in the CHANGELOG/docs/PR body. Full unit suite green (1011), integration green. Live validation script covers #1/#7 end-to-end. Ready for another look. |
- IncrementalResolution.resolve: skip candidate retrieval when no LLM is configured (stage 4 never runs) so it reduces to the within-batch collapse. - _link_pile: de-dupe survivor refs within a group so a repeated ref cannot self-absorb a node (which then vanished as "absorbed"). - _parse_decisions: coerce canonical/type/description to clean strings, dropping dicts/lists/null so non-string LLM output never reaches node properties/storage. - Remove a now-stale `# pragma: no cover` (the embed-failure branch is tested). - graph_store.ensure_name_key_index: return bool and distinguish "already indexed" (success) from real failures (logged), instead of swallowing all. - graph_store.backfill_name_keys: populate name_key on legacy entities so an upgraded graph stays matchable by the indexed lookup. - Default retriever backfills legacy keys and only marks the index ready when creation actually succeeded. - Tests: within-group dedup, non-string LLM fields, index-bool/backfill, and facade default-resolver selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
graphrag_sdk/src/graphrag_sdk/storage/graph_store.py:355
ensure_name_key_index()treats any exception containing the substring "already" as success. This is overly broad and can incorrectly report success on unrelated failures (and it diverges from the existing index-idempotency markers used inVectorStore, which check specifically for "already indexed" / "already exists").
except Exception as exc:
if "already" in str(exc).lower():
return True # already indexed — the desired end state
logger.warning("Could not create name_key index: %s", exc)
return False
| ) | ||
| ``` | ||
|
|
||
| **Candidate retrieval.** The strategy links whatever the `candidate_retriever` surfaces. When used as the `GraphRAG` default, the facade wires a **name-based** retriever: it matches existing entities whose name is equal after folding case and separators (`GraphRAG-SDK` == `graphrag_sdk` == `GraphRAG SDK`), and also tries the separator-removed form, so it catches cross-document homographs (`GraphRAG` the Concept vs the Technology) and many tokenization variants (`llama_index` finds a stored `LlamaIndex`). It is *not* semantic — entity embeddings only exist after `finalize()`, so a vector search finds nothing mid-ingest — and because it matches an exact set of surface forms rather than fuzzily, it is best-effort (it won't catch arbitrary spellings or every direction). For guaranteed variant/semantic linking, pass a custom `candidate_retriever` (e.g. backed by your own index) or run a reconciliation pass after `finalize()` where entity embeddings are available. |
| store = self._graph_store | ||
| index_ready = {"done": False} | ||
|
|
||
| async def retrieve(name: str, description: str, k: int) -> list[GraphNode]: | ||
| key = name_key(name) | ||
| if not key: | ||
| return [] | ||
| if not index_ready["done"]: | ||
| # Backfill legacy entities (ingested before name_key existed) so | ||
| # they remain matchable, then only treat the index as ready if it | ||
| # actually created — otherwise retry the setup on the next call. | ||
| await store.backfill_name_keys() | ||
| index_ready["done"] = await store.ensure_name_key_index() |
New
IncrementalResolutionstrategy + exports, and it is wired as the default resolver in theGraphRAGfacade.Behavior change: when an LLM and embedder are configured, ingestion now defaults to
IncrementalResolution(wasExactMatchResolution), which adds LLM + embedding calls per ingest. It falls back toExactMatchResolutionautomatically when no LLM/embedder is present, so LLM-free setups are unchanged; opt out explicitly withresolver=ExactMatchResolution(). Documented indocs/ingestion.md,docs/strategies.md, and CHANGELOG.Closes #277
Closes #277
What
Adds
IncrementalResolution, a resolution strategy that resolves a document'sentities against the knowledge graph built so far — so a mention in today's
document can be linked to an entity extracted from a document ingested earlier.
Ordinary batch resolvers only deduplicate a document's entities among
themselves and structurally cannot see cross-document duplicates.
Algorithm
A funnel — cheap, certain work first; the LLM only where cheap signals can't decide:
existing node (if any) is the merge target
Two duplication axes, each handled where it's cheapest and most reliable:
GraphRAGConcept vs Technology) → resolved forfree by description similarity. An LLM told to be careful tends to over-split
these; the embedding is cheaper and more accurate.
llama_index≈LlamaIndex, or a new mentionof an existing node) → the LLM partitions a small pile, the framing that keeps
genuine look-alikes apart (
FALKORDB_USERNAME≠FALKORDB_PASSWORD).Design choices
MERGE (n {id})attaches the newmention with no special-casing; existing edges untouched.
merged description); provenance is unioned and immutable-property conflicts are
flagged (
_needs_review), never guessed.candidate_retriever; in production it wraps the graph store's name/vector search.Scope
Adds the new strategy and makes it the default resolver (see the Behavior change note at the top). Closes the same-name/different-type homograph gap in #277.
Tests
tests/test_incremental_resolution.py— 7 tests: cross-document linking into anexisting node while rejecting a look-alike, new-entity when no candidates (no LLM
call), free same-name collapse, genuine-homograph preservation, immutable-conflict
flagging, malformed-LLM fail-safe, and name normalization. Ruff clean; existing
resolution suite unaffected.
🤖 Generated with Claude Code
Summary by CodeRabbit
IncrementalResolutionfor direct importing.