Skip to content

Constrain edge endpoints to the extracted entity set, and salvage the rest instead of dropping them - #285

Merged
verveguy merged 12 commits into
mainfrom
fabrik/issue-281
Jul 30, 2026
Merged

Constrain edge endpoints to the extracted entity set, and salvage the rest instead of dropping them#285
verveguy merged 12 commits into
mainfrom
fabrik/issue-281

Conversation

@verveguy

Copy link
Copy Markdown
Owner

Closes #281

Summary

Fixes the defect where an edge-extraction endpoint that the entity pass never produced was silently discarded — up to 97.8% of edges on an unchunked, concept-heavy page. Three independent-but-complementary changes:

  1. FR-001 — schema-level constraint (Anthropic path). build_edge_tool_schema now puts an enum of the batch's sanitized entity names on source_name/target_name in the extract_edges tool schema, so a compliant model literally cannot name an off-list endpoint. The edge-extraction HTTP call is skipped entirely when the sanitized name list is empty (an empty enum is invalid schema).
  2. FR-002 — prompt contradiction. extract_text.txt/extract_message.txt/extract_json.txt banned "abstract concepts" outright while the ontology offers a Concept type. Reworded the ban to a vagueness/specificity test (a vague feeling like "joy" is still excluded; a specific, named, Wikipedia-articleable concept like "climate change" is now extracted as Concept), with a worked example added to each prompt.
  3. FR-003/FR-005 — salvage + deferred drop (both paths). Pre-lock edge validation in episode.rs no longer permanently drops an edge for an unresolvable endpoint. It now only filters self-referential edges (always correct) and salvages an off-list endpoint by cosine-matching its name embedding against the batch's own entity embeddings (reusing DEDUP_THRESHOLD = 0.85), rewriting the edge to the matched entity's canonical name. Anything left unsalvaged passes through to Phase C (write-lock held), which is now the sole authoritative point that resolves against the persisted graph or finally drops an edge. This also removes the redundant pre-lock globally_resolved DB lookup added in Edges to already-existing entities silently dropped — resolve edge endpoints against the global entity table #209/PR fix(episode): resolve edge endpoints against persisted Entity table (#209) #218.
  4. FR-004 — reporting. edges_dropped_unresolvable is threaded from Phase C onto AddEpisodeResult and into knowledge_process_chunk's JSON result, rather than being observable only via eprintln!.

FR-006 (OpenAI-compatible path) falls out for free since the salvage/defer logic lives in episode.rs, downstream of either Extractor implementation — only the schema enum (FR-001) is Anthropic-specific.

Behavior change to flag for review

edges_extracted on AddEpisodeResult / in the knowledge_process_chunk IPC result now means "edges actually inserted" rather than "edges surviving the pre-lock filter." This is a public IPC field; values should only go up relative to prior behavior (edges Phase C's fallback could have resolved were previously pre-dropped), never down.

Documentation

Known limitations (flagged per the Plan's risk list)

  • The DEDUP_THRESHOLD = 0.85 salvage threshold is verified only against controlled test vectors (NameMapEmbedder), not real embedding output — a fast follow-up if production salvage proves too aggressive or too conservative.
  • SC-001/002/004 (percentage drop-rate bounds on the three real Wikipedia fixture pages) require live LLM calls and are not wired into CI; this PR proves the underlying mechanisms deterministically instead. Live percentage verification is a Validate-stage/manual activity using the same replay method already used in the issue thread.

Test plan

  • cargo fmt --all -- --check clean
  • cargo test — 799 passed, 8 ignored (full debug suite)
  • cargo clippy --all-targets -- -D warnings — no issues
  • New: sanitize_entity_names unit tests (control chars, dedup, empty-after-sanitize)
  • New: build_edge_tool_schema enum-contents test + empty-list HTTP-skip test
  • New: concept-ban regression test across all three source-type prompts
  • New: salvage-match, adversarial-non-collapse, and unresolvable-drop-and-count integration tests in edge_endpoint_resolution.rs
  • Extended ipc_parity.rs's test_knowledge_process_chunk_ok to assert edges_dropped_unresolvable is present and numeric
  • Manual: replay against the Global_warming/LEMON/Capacitive_Micromachined fixture pages to confirm the ≤5% drop-rate bound (SC-001/002) and at least one Concept entity (SC-004) — recommended for Validate

verveguy added 9 commits July 29, 2026 22:44
…hema

Extracts the control-char-stripping/trim/empty-drop logic already used by
edge_user_prompt into a standalone, dedup-adding helper so the upcoming
tool-schema enum (FR-001) can reuse identical sanitization.
…y set

Adds build_edge_tool_schema, which puts an enum on source_name/target_name
built from the sanitized entity list, so the Anthropic tool-use call
enforces the endpoint contract at the schema level (FR-001) rather than
relying on prompt text the model may ignore. Skips the edge-extraction
HTTP call entirely when the sanitized name list is empty, since an empty
enum is invalid schema.
…y test

extract_text.txt/extract_message.txt/extract_json.txt each banned "abstract
concepts" outright while the closed ontology (mod.rs) offers a Concept
entity type — the header ban silently won on unchunked input, discarding
the very entity the edge pass hubs its facts on (#281). Reword the ban to
distinguish a vague standalone feeling (never extract) from a specific,
named concept passing the existing "Wikipedia article" specificity test
(extract as Concept), and add a worked example to each prompt.
…mmit

Pre-lock edge validation used to permanently drop any edge whose endpoint
wasn't in the batch or a persisted-entity lookup, before Phase C's
lock-held resolution (which also checks the persisted graph) ever got a
chance to rescue it — two independent, easily-desynced resolution passes.

Pre-lock now only filters self-referential edges (always correct) and
salvages an off-list endpoint by cosine-matching its name embedding
against the batch's own entity name embeddings (reusing DEDUP_THRESHOLD),
rewriting the edge to the matched entity's canonical name. Anything left
unsalvaged passes through to Phase C, which becomes the sole authoritative
point that resolves against the persisted graph or finally drops an edge —
now counted as edges_dropped_unresolvable on AddEpisodeResult.
…hunk

Surfaces the count of edges dropped for unresolvable endpoints (FR-004)
in the JSON result, rather than leaving it observable only via stderr.
…ting

Adds integration coverage for the salvage/deferred-drop rework: an
off-list endpoint whose name embedding closely matches a batch entity is
salvaged rather than dropped; a below-threshold adversarial near-miss
(e.g. carbon dioxide vs carbon monoxide) is not force-matched; and a
genuinely unresolvable edge is dropped and surfaces in
edges_dropped_unresolvable. Extends ipc_parity's process_chunk test to
assert the new field is present and numeric.
Documents why pre-lock edge validation is now advisory-only (self-
referential filtering plus salvage), why the #209 pre-lock persisted-
entity lookup was removed rather than kept alongside salvage, and why
Phase C is the sole authoritative drop/count point — including why this
is what makes FR-006 hold on the OpenAI-compatible path, which has no
tool-use schema to constrain.
@verveguy

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Implement
branch: fabrik/issue-281 | commit: 75faf8f | main: 5c388d5 | 2026-07-30 03:23 UTC

Everything is committed and pushed, local branch matches remote, working tree clean. All plan tasks are complete. Now let's emit the PR creation marker and complete the stage.

Implemented all three fixes from the plan: an enum constraint on the Anthropic extract_edges tool schema (FR-001), a reworded concept-ban prompt that resolves the contradiction with the Concept ontology type (FR-002), and a salvage-then-defer-to-write-lock resolution model in episode.rs that replaces the old pre-lock drop with cosine-similarity salvage plus authoritative Phase C counting (FR-003/004/005/006). Added unit and integration test coverage (including adversarial non-collapse and salvage-match cases), a new ADR-0051, and a README note; full local gate (fmt, 799 tests, clippy -D warnings) passes and all commits are pushed.


Used 2/100 turns, 0k input / 2k output tokens.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@verveguy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 39b5dfd1-73dc-4df8-87bf-ba719092110f

📥 Commits

Reviewing files that changed from the base of the PR and between e6cf8ae and 0f5d264.

📒 Files selected for processing (3)
  • crates/core/src/episode.rs
  • crates/core/src/prompts/mod.rs
  • crates/core/tests/edge_endpoint_resolution.rs
📝 Walkthrough

Walkthrough

Edge extraction now sanitizes entity names, constrains tool-schema endpoints to those names, and permits specific named abstract concepts. Episode ingestion salvages off-batch endpoints through embedding similarity, defers final endpoint resolution to the commit phase, drops unresolved relationships there, and reports inserted and dropped edge counts. The new counter is propagated through the ingestion response and documented. Tests cover schema generation, empty inputs, salvage behavior, unresolved edges, self-loop prevention, and IPC output.

Possibly related issues

  • Issue 281 — The PR implements constrained edge endpoints, endpoint salvage, deferred resolution, dropped-edge reporting, and regression coverage described by the issue.

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

@handarbeit-pruefer handarbeit-pruefer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I have what I need. Let me finalize the review.

I reviewed the diff-equivalent changes across crates/core/src/extractor.rs (FR-001 schema enum), crates/core/src/prompts/mod.rs and the three extract_*.txt prompts (FR-002 concept-ban rewording), crates/core/src/episode.rs (FR-003/005 salvage + deferred drop), crates/core/src/handlers.rs (FR-004 reporting), the new integration tests in edge_endpoint_resolution.rs, and ADR-0051. Since this sandbox's git history only contains the PR head commit with no reachable main/parent objects, I inspected the current state of these files directly rather than a line diff — findings below are anchored to the current file content, which should map onto the PR's diff lines.

The overall design is sound and well-documented (ADR-0051 clearly explains the two-pass-to-one-authoritative-pass consolidation), and the schema-enum + salvage-then-defer combination is a good fix for the underlying defect. I found one real correctness gap worth addressing before merge: the pre-lock self-referential-edge filter runs before the salvage rewrite, so a salvage match that rewrites one or both endpoints to the same canonical entity name can produce a self-loop edge that nothing downstream catches (Phase C has no self-loop check either). This is plausible in practice — e.g. an edge like ("Acme", "Acme Corp", ...) where "Acme" is off-list and salvage-matches back to "Acme Corp" — and isn't covered by the new adversarial/salvage tests.

Comment thread crates/core/src/episode.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/core/src/episode.rs (1)

244-347: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Parallelize salvage embedding lookups — the missing_names loop serializes every off-batch endpoint embed before Phase B even starts. The embedder already supports concurrent requests, so batching these with try_join_all/similar would cut tail latency on chunks with many salvageable endpoints.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 653bf16f-a246-463a-8da1-e76cc84b6a40

📥 Commits

Reviewing files that changed from the base of the PR and between 5c388d5 and 75faf8f.

📒 Files selected for processing (13)
  • README.md
  • crates/core/src/episode.rs
  • crates/core/src/extractor.rs
  • crates/core/src/handlers.rs
  • crates/core/src/prompts/extract_json.txt
  • crates/core/src/prompts/extract_message.txt
  • crates/core/src/prompts/extract_text.txt
  • crates/core/src/prompts/mod.rs
  • crates/core/tests/edge_endpoint_resolution.rs
  • crates/core/tests/ipc_parity.rs
  • docs/adr/0051-edge-endpoint-salvage-and-deferred-drop.md
  • docs/adr/index.md
  • specs/281-constrain-edge-endpoints-to/spec.md

Comment thread crates/core/src/episode.rs Outdated
Salvage can rewrite an off-list endpoint to match the entity name
already used by the edge's other endpoint (e.g. "Global Warming"
salvaging to "Climate Change" when the edge already targets "Climate
Change"), producing a self-loop that the earlier pre-salvage
self-referential filter can't see and Phase C has no guard against.
Re-run the filter after salvage so such edges are dropped instead of
persisted as self-referential relationships.
@verveguy

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Review
branch: fabrik/issue-281 | commit: 61fe921 | main: 5c388d5 | 2026-07-30 03:38 UTC

Everything is committed, pushed, and the working tree is clean. Review complete.

Review Findings

Fixed

  • Issue: Salvage rewriting an off-list edge endpoint can collide with the edge's other endpoint name, producing a self-referential edge that neither the pre-salvage self-ref filter (runs too early to see the rewrite) nor Phase C (has no self-ref guard) catches — so a self-loop could be silently persisted to the graph. Example: an edge ("Global Warming", "Climate Change", ...) where "Global Warming" salvage-matches "Climate Change" becomes ("Climate Change", "Climate Change", ...) after rewrite and would have been inserted as-is. Fix: re-run the self-referential filter immediately after the salvage rewrite in crates/core/src/episode.rs, and added test_salvage_collapsing_both_endpoints_to_same_entity_is_not_inserted in edge_endpoint_resolution.rs to cover it (commit 61fe921).

Verified

  • Rebase: branch was already up to date with origin/main (no conflicts).
  • PR Constrain edge endpoints to the extracted entity set, and salvage the rest instead of dropping them #285 has no external bot/human review comments yet.
  • Read the full diff against main across episode.rs, extractor.rs, prompts/mod.rs + the three prompt .txt files, handlers.rs, both test files, the ADR, and the README — implementation matches the plan's FR-001 through FR-006.
  • Confirmed build_edge_tool_schema's enum constraint (FR-001), the salvage/defer model replacing the old pre-lock drop (FR-003/FR-005), edges_dropped_unresolvable threading through AddEpisodeResultknowledge_process_chunk (FR-004), and the reworded concept-ban prompts (FR-002) all check out against the spec.
  • cosine_similarity correctly returns 0.0 for zero-norm vectors, so MockEmbedder's zero-vector edges never spuriously salvage-match.
  • ADR-0051 numbering doesn't collide with anything else added to docs/adr/ since.
  • Full local gate: cargo fmt --all clean, cargo test --workspace → 800 passed / 8 ignored (up from 799 after adding the regression test), cargo clippy --all-targets -- -D warnings → no issues.
  • Pushed 61fe921; CI's release-mode test job is running (not waited on locally, per this repo's 10-minute foreground budget — CI owns that verification).

Blocking

None.

Reviewed the #281 implementation end-to-end and found one real bug: salvage rewriting an off-list edge endpoint could collide with the edge's other endpoint, producing an unguarded self-referential edge that would have been silently persisted. Fixed by re-running the self-referential filter after salvage, added a regression test, and confirmed with a full local gate (fmt, 800 tests, clippy -D warnings, all clean). Fix is committed and pushed; PR #285 is ready for human review.


Used 43/100 turns, 11k input / 11k output tokens.

CodeRabbit flagged that the comment claimed Phase C reuses db_shared's
Arc, but Phase C actually reloads its own handle (db_c) independently
right before the write lock. Clarify the comment so a future
contributor doesn't "deduplicate" that reload as redundant and
reintroduce a stale-handle risk around a concurrent clear_all.
@verveguy

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Review (review feedback addressed)
branch: fabrik/issue-281 | commit: e6cf8ae | main: 5c388d5 | 2026-07-30 03:39 UTC

Both review threads resolved. No issue-body changes are needed — this was PR-level review feedback, not a spec change.

Summary

  • handarbeit-pruefer's finding: Already fixed in my prior Review-stage commit 61fe921 (re-checking self-referential edges after salvage rewrite, with a regression test). No further action needed — this was independently caught by both of us.
  • coderabbitai's finding: Applied their suggested comment fix in crates/core/src/episode.rs:232-235 — clarified that Phase C reloads its own DB handle (db_c) rather than reusing db_shared's Arc, so a future contributor won't mistakenly "deduplicate" that reload. Committed as e6cf8ae and pushed.
  • Verified the fix builds cleanly (cargo fmt + cargo build), and resolved both PR review threads via GraphQL.

Threads addressed:

  • crates/core/src/episode.rs:335 — resolved
  • crates/core/src/episode.rs:233 — resolved

Resolved 2 review thread(s) across 2 comment(s).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/core/src/episode.rs (1)

607-618: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject self-loops after UUID resolution.

The name-level check misses Phase B dedup merges: two distinct names can map to one UUID in name_to_uuid, causing Line 618 to persist src_uuid == dst_uuid. Check resolved UUIDs before insertion and add a dedup-merge regression test.

Proposed fix
             let (src_uuid, dst_uuid) = match (src_uuid, dst_uuid) {
                 (Some(s), Some(d)) => (s, d),
                 (src, dst) => {
                     // ...
                 }
             };
+            if src_uuid == dst_uuid {
+                eprintln!(
+                    "liminis-context-graph: dropping self-referential edge at commit: '{}' → '{}'",
+                    edge.source_name, edge.target_name
+                );
+                continue;
+            }
             conn.insert_relates_to_edge(&RelatesToEdge {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ab1df856-5171-4aa7-a222-a398c3a64367

📥 Commits

Reviewing files that changed from the base of the PR and between 75faf8f and 61fe921.

📒 Files selected for processing (2)
  • crates/core/src/episode.rs
  • crates/core/tests/edge_endpoint_resolution.rs

@handarbeit-pruefer handarbeit-pruefer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good, this confirms the design. I have what I need to write the review now.

Summary

Reviewed the salvage/deferred-drop rework in episode.rs, the build_edge_tool_schema/sanitize_entity_names additions in extractor.rs/prompts/mod.rs, the prompt wording changes, and the new/extended integration tests (edge_endpoint_resolution.rs, ipc_parity.rs). The core design is sound and well-documented (ADR-0051 clearly explains the rationale for making Phase C the sole drop authority), and the test coverage for salvage, adversarial non-collapse, and the post-salvage self-referential recheck is solid. I found one real correctness gap worth addressing: the sanitize_entity_names transform used to build the schema enum (and the prompt's entity list) can diverge from the actual ExtractedEntity.name values stored in extraction.entities, because control characters are stripped by deletion rather than by replacement-with-space, and dedup is exact-string rather than the same trim+lowercase normalization used everywhere else in episode.rs. For entities whose extracted name contains stray control characters, this can make a batch-local edge endpoint that should exact-match fail the entity_name_set/name_to_uuid lookups and fall through to salvage (or the persisted-graph fallback) unnecessarily — undermining part of what FR-001 is meant to guarantee. This is a narrow edge case, not a blocker, but worth a small fix (e.g. replace control chars with a space, or normalize with the same trim/lowercase key used elsewhere) given the PR is specifically about not losing edges to endpoint-name mismatches.

Comment thread crates/core/src/prompts/mod.rs Outdated
@verveguy

Copy link
Copy Markdown
Owner Author

🏭 Fabrik — stage: Validate
branch: fabrik/issue-281 | commit: e6cf8ae | main: 5c388d5 | 2026-07-30 03:46 UTC

Validation Report

Requirements: 6/6 FRs verified

  • FR-001 (schema enum constraint): build_edge_tool_schema (extractor.rs:75) adds an enum on source_name/target_name from sanitized batch entity names; wired into do_extract_edges (extractor.rs:316). Unit test confirms the enum contains exactly the sanitized names. Empty-entity-list short-circuit (extractor.rs:298) skips the call entirely, matching the edge-case spec.
  • FR-002 (prompt contradiction resolved): all three prompts (extract_text.txt, extract_message.txt, extract_json.txt) reworded from a blanket concept ban to a specificity/"Wikipedia-article" test, with worked examples (e.g. "climate change" as a good Concept extraction). Regression test guards against the old phrasing reappearing.
  • FR-003 (salvage via name-embedding similarity): episode.rs:335-360 cosine-matches off-list endpoints against batch entity name embeddings using the existing DEDUP_THRESHOLD/cosine_similarity, rewriting matched edges in place. Covered by test_off_list_endpoint_salvaged_via_name_embedding_similarity and adversarial non-collapse test.
  • FR-004 (reporting): edges_dropped_unresolvable flows from Phase C's closure (episode.rs:551-665) through AddEpisodeResult into handle_knowledge_process_chunk's JSON (handlers.rs:426). Covered in ipc_parity.rs.
  • FR-005 (deferred drop to lock-held phase): pre-lock retain now only filters self-referential edges (re-checked after salvage rewrite too, per the Review-stage fix); Phase C (episode.rs:600-620, under the write lock) is the sole authoritative drop/count point, with fallback to persisted entities via get_entity_by_name_ci.
  • FR-006 (both extraction paths): salvage/defer logic lives in episode.rs, downstream of either Extractor impl, so it applies regardless of Anthropic vs. OpenAI-compatible path.

SC-003's regression coverage (off-list endpoint semantically equivalent to a batch entity resolves rather than drops) and the drop-count assertion are both present in edge_endpoint_resolution.rs. SC-001/002/004 (live percentage drop rates on real Wikipedia pages) are explicitly out of scope for automated CI per the Plan's documented scope decision — mechanism-level tests substitute, which is a reasonable call for requirements needing live LLM calls.

Test Suite: PASSED

  • cargo fmt --all --check: clean
  • cargo test --workspace: 800 passed, 8 ignored (55 suites, ~195s)
  • cargo clippy --all-targets -- -D warnings: no issues

Regressions: None detected

No TODO/FIXME left in the diff; changed file set matches the Plan's file list exactly (episode.rs, extractor.rs, handlers.rs, three prompt files, prompts/mod.rs, two test files, ADR-0051, README).

Branch State

Issues Found

None.

Verdict: READY TO MERGE

Validation passed: all 6 functional requirements verified against code, full local gate clean (fmt, 800 tests, clippy -D warnings), no regressions, branch rebased and pushed. PR #285 mergeable: MERGEABLE, mergeStateStatus: BLOCKED (pending in-progress CI test job, not a conflict).


Used 27/100 turns, 15k input / 5k output tokens.

@handarbeit-pruefer handarbeit-pruefer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed the diff for PR #285 (schema-enum constraint on extract_edges, the concept-ban prompt reword, the pre-lock salvage/deferred-drop rework in episode.rs, and the edges_dropped_unresolvable IPC threading), read ADR-0051, and ran the affected test suites (edge_endpoint_resolution, ipc_parity, prompts::) plus a full workspace build — all green. The core mechanism is sound: the enum constraint and empty-list HTTP skip in extractor.rs are correctly gated, the salvage step in episode.rs correctly reuses batch-only entity embeddings (never touching the persisted graph, per the ADR's stated rationale), the post-salvage self-loop re-check closes the collision case the tests exercise, and Phase C is unambiguously the sole drop/count authority. Two minor things worth a look before merge: an observability gap around self-referential drops, and an unbounded schema-size consideration for very large entity batches.

// (write-lock held), which is now the *sole* point that resolves an endpoint — falling back
// to the persisted graph — or finally drops the edge, making `edges_dropped_unresolvable`
// authoritative (FR-003, FR-005) instead of one of two independent, easily-desynced passes.
extraction.edges.retain(|edge| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Self-referential edges (both the pre-salvage drop here and the post-salvage collision drop at line 352) are only reported via eprintln! — there's no counter for them, unlike edges_dropped_unresolvable. Since this PR's stated purpose (FR-004) is making drop counts observable to a caller instead of only on stderr, a batch where salvage collapses several distinct off-list endpoints onto the same entity (producing several self-loops) will silently under-report total edge loss in the knowledge_process_chunk JSON result — edges_extracted + edges_dropped_unresolvable won't account for edges lost via this path. Consider a documented note on AddEpisodeResult (or a small additional counter) clarifying that self-referential drops aren't included in either existing counter.

"required": ["edges"]
}
});
let edge_tool = build_edge_tool_schema(&sanitized_names);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The source_name/target_name enum is built from the full sanitized entity-name list with no size cap. On a concept-heavy, unchunked page with a very large entity count — the PR's own motivating scenario — this could produce a very large extract_edges tool schema on every edge-extraction call, inflating request size/cost per call. Not a correctness bug, but worth confirming there's an upstream chunk-size or entity-count ceiling that keeps this bounded in practice, since nothing in this PR caps it.

…point matching

sanitize_entity_names strips control characters before an entity name reaches
the model (in the edge prompt's entity list and the tool schema's enum), but
episode.rs's entity_name_set/name_to_uuid lookups keyed off the original,
unsanitized name (trim+lowercase only). An entity name containing a control
character therefore couldn't be matched by the sanitized form the model
echoes back, defeating part of FR-001's batch-local matching guarantee.

Adds prompts::normalize_name as the single control-strip+trim+lowercase key
used on both sides of every entity/edge-endpoint name comparison in
episode.rs, and a regression test covering the control-char case.
// persisted Entity table (e.g. a recurring hub entity created in an earlier ingest
// batch that survived Site 1's validation via the same fallback) (FR-002, FR-003).
let src_uuid = match name_to_uuid.get(&edge.source_name.trim().to_lowercase()) {
let src_uuid = match name_to_uuid.get(&normalize_name(&edge.source_name)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removing the pre-lock globally_resolved lookup (per ADR-0051) means every off-batch, unsalvaged edge endpoint now triggers get_entity_by_name_ci here, inside Phase C's spawn_blocking closure, while the exclusive write lock (_write_guard) is held. Previously this DB round-trip happened pre-lock (outside the critical section) for edges that failed the batch-local check. For the concept-heavy/unchunked pages this PR specifically targets (up to 97.8% of edges previously dropped, now flowing through to this fallback), the write-lock hold time could grow meaningfully with edge count, serializing concurrent add_episode callers for longer than before. The ADR's "Consequences" section acknowledges the timing shift but frames it as free ("doesn't add a new query, only moves it later") without addressing that moving it under the lock is exactly the kind of change AD-4's three-phase split was designed to avoid. Worth a benchmark on a batch with many unresolved edges before merge, or at minimum flagging as a known follow-up alongside the DEDUP_THRESHOLD caveat already called out.

}
}

fn strip_control_chars(s: &str) -> String {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

strip_control_chars deletes control characters outright instead of replacing them with a separator, so e.g. "Bob\ncontrol\tchars" becomes "Bobcontrolchars" (asserted by the test at line ~301/311 in this same file). This is used both for the sanitized names shown to the model (entity list + schema enum) and, via normalize_name, as the matching key for batch-local and salvage edge-endpoint resolution. Gluing tokens together on either side of a stripped control character is a plausible (if low-probability) source of an unintended name collision — two distinct entities could normalize to the same concatenated string, or a legitimate entity name could accidentally collide with a glued fragment of another. Substituting a space for control characters (then re-collapsing whitespace) would avoid the concatenation while still meeting the "no newlines in the bullet list / JSON enum" requirement that motivated this function.

@verveguy
verveguy merged commit 72e9d25 into main Jul 30, 2026
9 checks passed
@verveguy
verveguy deleted the fabrik/issue-281 branch July 30, 2026 04:16
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.

Constrain edge endpoints to the extracted entity set, and stop banning the concepts edges hub on

1 participant