Repository navigation
ENG-2373 Share the relation triple matching rule in packages/database #1527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
maparent
merged 2 commits into
main
from
eng-2373-share-the-relation-triple-matching-rule-in-packagesdatabase
Oct 7, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
150 changes: 150 additions & 0 deletions
150
packages/database/src/lib/__tests__/tripleMatching.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| matchNodeTypes, | ||
| parseTripleEndNames, | ||
| pickTripleCandidate, | ||
| } from "../tripleMatching"; | ||
|
|
||
| describe("matchNodeTypes", () => { | ||
| it("matches strongly on the same RID, local id or origin", () => { | ||
| expect(matchNodeTypes({ rid: "a" }, { rid: "a" })).toBe("strong"); | ||
| expect(matchNodeTypes({ localId: "evd" }, { localId: "evd" })).toBe( | ||
| "strong", | ||
| ); | ||
| expect(matchNodeTypes({ rid: "b" }, { importedFromRid: "b" })).toBe( | ||
| "strong", | ||
| ); | ||
| expect(matchNodeTypes({ importedFromRid: "c" }, { rid: "c" })).toBe( | ||
| "strong", | ||
| ); | ||
| expect( | ||
| matchNodeTypes({ importedFromRid: "c" }, { importedFromRid: "c" }), | ||
| ).toBe("strong"); | ||
| }); | ||
|
|
||
| it("matches on the normalized name only when nothing stronger matches", () => { | ||
| expect(matchNodeTypes({ name: " Evidence" }, { name: "evidence" })).toBe( | ||
| "name", | ||
| ); | ||
| expect(matchNodeTypes({ name: "Evidence" }, { name: "Claim" })).toBe( | ||
| undefined, | ||
| ); | ||
| }); | ||
|
|
||
| it("matches a shared default id only by name", () => { | ||
| expect( | ||
| matchNodeTypes( | ||
| { localId: "_CLM-node", name: "Claim" }, | ||
| { localId: "_CLM-node", name: "Claim" }, | ||
| ), | ||
| ).toBe("name"); | ||
| expect( | ||
| matchNodeTypes( | ||
| { localId: "_CLM-node", name: "Claim" }, | ||
| { localId: "_CLM-node", name: "Assertion" }, | ||
| ), | ||
| ).toBe(undefined); | ||
| }); | ||
|
|
||
| it("matches a user-created id strongly even when it starts with an underscore", () => { | ||
| expect( | ||
| matchNodeTypes({ localId: "_x1Yz2Ab3" }, { localId: "_x1Yz2Ab3" }), | ||
| ).toBe("strong"); | ||
| }); | ||
|
|
||
| it("does not match on fields both sides lack", () => { | ||
| expect(matchNodeTypes({}, {})).toBe(undefined); | ||
| }); | ||
| }); | ||
|
|
||
| describe("pickTripleCandidate", () => { | ||
| const x = { rid: "orn:a/claim", name: "Claim" }; | ||
| const yInB = { rid: "orn:b/evidence", localId: "evidence", name: "Evidence" }; | ||
|
|
||
| it("prefers the candidate whose ends match strongly over one matching by name", () => { | ||
| expect( | ||
| pickTripleCandidate( | ||
| [ | ||
| { | ||
| candidate: "renamed", | ||
| source: x, | ||
| destination: { localId: "evidence", name: "Data" }, | ||
| }, | ||
| { | ||
| candidate: "same-name", | ||
| source: x, | ||
| destination: { localId: "other", name: "Evidence" }, | ||
| }, | ||
| ], | ||
| { source: x, destination: yInB }, | ||
| ), | ||
| ).toBe("renamed"); | ||
| }); | ||
|
|
||
| it("matches a hidden end by the name parsed from the triple", () => { | ||
| expect( | ||
| pickTripleCandidate( | ||
| [{ candidate: "t", source: x, destination: { name: "evidence" } }], | ||
| { source: x, destination: yInB }, | ||
| ), | ||
| ).toBe("t"); | ||
| }); | ||
|
|
||
| it("prefers one strong end over none", () => { | ||
| expect( | ||
| pickTripleCandidate( | ||
| [ | ||
| { | ||
| candidate: "one-strong", | ||
| source: x, | ||
| destination: { name: "Evidence" }, | ||
| }, | ||
| { | ||
| candidate: "names-only", | ||
| source: { name: "Claim" }, | ||
| destination: { name: "Evidence" }, | ||
| }, | ||
| ], | ||
| { source: x, destination: yInB }, | ||
| ), | ||
| ).toBe("one-strong"); | ||
| }); | ||
|
|
||
| it("picks nothing when two candidates match at the same level", () => { | ||
| expect( | ||
| pickTripleCandidate( | ||
| [ | ||
| { candidate: "one", source: x, destination: { name: "Evidence" } }, | ||
| { candidate: "two", source: x, destination: { name: "evidence" } }, | ||
| ], | ||
| { source: x, destination: yInB }, | ||
| ), | ||
| ).toBe(undefined); | ||
| }); | ||
|
|
||
| it("picks nothing when one end matches no candidate", () => { | ||
| expect( | ||
| pickTripleCandidate( | ||
| [{ candidate: "t", source: x, destination: { name: "Question" } }], | ||
| { source: x, destination: yInB }, | ||
| ), | ||
| ).toBe(undefined); | ||
| }); | ||
| }); | ||
|
|
||
| describe("parseTripleEndNames", () => { | ||
| it("splits an Obsidian triple name around its label", () => { | ||
| expect( | ||
| parseTripleEndNames("Claim -Supports-> Evidence", "Supports"), | ||
| ).toEqual({ source: "Claim", destination: "Evidence" }); | ||
| }); | ||
|
|
||
| it("gives up when the label marker is missing or repeated", () => { | ||
| expect(parseTripleEndNames("Claim supports Evidence", "Supports")).toBe( | ||
| undefined, | ||
| ); | ||
| expect( | ||
| parseTripleEndNames("A -Supports-> B -Supports-> C", "Supports"), | ||
| ).toBe(undefined); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| // Matches a relation's ends against its relation type's triples. The Roam and Obsidian | ||
| // importers share it so both pick the same source triple. | ||
|
|
||
| // A triple end whose schema is hidden has only the name parsed from the triple's name. | ||
| export type NodeTypeIdentity = { | ||
| rid?: string; | ||
| localId?: string; | ||
| importedFromRid?: string; | ||
| name?: string; | ||
| }; | ||
|
|
||
| const normalizeName = (name: string): string => name.trim().toLowerCase(); | ||
|
|
||
| // The same in every Roam graph; keep in sync with `type` in | ||
| // apps/roam/src/data/defaultDiscourseNodes.ts. | ||
| // Check the exact list: a user-created type's id may also start with `_`. | ||
| export const ROAM_DEFAULT_NODE_TYPE_IDS: ReadonlySet<string> = new Set([ | ||
| "_CLM-node", | ||
| "_QUE-node", | ||
| "_EVD-node", | ||
| "_SRC-node", | ||
| ]); | ||
|
|
||
| // Id or import origin outranks name, as in Obsidian's node type import (id, then name, | ||
| // else a copy keeping the source's id). A bare id match across spaces covers import | ||
| // chains whose middle space is hidden; default ids don't count, as unrelated graphs share them. | ||
| export const matchNodeTypes = ( | ||
| a: NodeTypeIdentity, | ||
| b: NodeTypeIdentity, | ||
| ): "strong" | "name" | undefined => { | ||
| const sameValue = (x?: string, y?: string) => x !== undefined && x === y; | ||
| if ( | ||
| sameValue(a.rid, b.rid) || | ||
| (sameValue(a.localId, b.localId) && | ||
| !ROAM_DEFAULT_NODE_TYPE_IDS.has(a.localId!)) || | ||
| sameValue(a.importedFromRid, b.rid) || | ||
| sameValue(a.rid, b.importedFromRid) || | ||
| sameValue(a.importedFromRid, b.importedFromRid) | ||
| ) | ||
| return "strong"; | ||
| if ( | ||
| a.name !== undefined && | ||
| b.name !== undefined && | ||
| normalizeName(a.name) === normalizeName(b.name) | ||
| ) | ||
| return "name"; | ||
| return undefined; | ||
| }; | ||
|
|
||
| // Candidates matching more ends strongly decide first. A tie at the best rank means | ||
| // duplicate node types in the source space, so nothing is picked. | ||
| export const pickTripleCandidate = <T>( | ||
| candidates: { | ||
| candidate: T; | ||
| source: NodeTypeIdentity; | ||
| destination: NodeTypeIdentity; | ||
| }[], | ||
| ends: { source: NodeTypeIdentity; destination: NodeTypeIdentity }, | ||
| ): T | undefined => { | ||
| const scored = candidates | ||
| .map(({ candidate, source, destination }) => ({ | ||
| candidate, | ||
| matches: [ | ||
| matchNodeTypes(ends.source, source), | ||
| matchNodeTypes(ends.destination, destination), | ||
| ], | ||
| })) | ||
| .filter(({ matches }) => matches.every((m) => m !== undefined)); | ||
| const strongCount = (matches: (string | undefined)[]) => | ||
| matches.filter((m) => m === "strong").length; | ||
| const best = Math.max(...scored.map(({ matches }) => strongCount(matches))); | ||
| const deciding = scored.filter( | ||
| ({ matches }) => strongCount(matches) === best, | ||
| ); | ||
| return deciding.length === 1 ? deciding[0]!.candidate : undefined; | ||
| }; | ||
|
|
||
| // Obsidian names a triple `Source -label-> Destination`. | ||
| export const parseTripleEndNames = ( | ||
| tripleName: string, | ||
| label: string, | ||
| ): { source: string; destination: string } | undefined => { | ||
| const parts = tripleName.split(` -${label}-> `); | ||
| if (parts.length !== 2) return undefined; | ||
| return { source: parts[0]!, destination: parts[1]! }; | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Conflicting schema IDs select wrong triples
When distinct schemas reuse a non-default local ID,
matchNodeTypesranks them as a strong match despite conflicting RIDs.pickTripleCandidatecan then prefer the wrong triple over a name-matched candidate.Learn more
A RID identifies a schema together with its space, while a local ID only identifies it within one space. The matcher treats equal non-default local IDs as strong even when both available RIDs identify different schemas. The scorer then favors that candidate over a legitimate name match. The existing import matcher in findLocalNodeTypeMatch also reuses bare IDs, but this new matcher has both qualified identities available and uses its score to decide which source triple to select.
Example: The endpoint has
{rid: 'orn:spaceA.schema:x', localId: 'x', name: 'Evidence'}. Candidate one has{rid: 'orn:spaceB.schema:x', localId: 'x', name: 'Question'}; candidate two has{rid: 'orn:spaceB.schema:y', localId: 'y', name: 'Evidence'}. Candidate one wins on ID despite its explicitly different RID and name.Recommended fix: Restrict bare local-ID matches to cases without conflicting qualified RID or origin evidence; use matching RIDs and origin links to establish a strong cross-space match. Add a test with conflicting RIDs and equal local IDs.
Was this helpful? React with 👍 or 👎 to provide feedback.