From fc32c8ce3a189700324966dbedbb25e2211ece96 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 7 Oct 2026 00:09:28 -0400 Subject: [PATCH 1/2] ENG-2373 Share the relation triple matching rule in packages/database --- .../src/lib/__tests__/tripleMatching.test.ts | 150 ++++++++++++++++++ packages/database/src/lib/tripleMatching.ts | 85 ++++++++++ 2 files changed, 235 insertions(+) create mode 100644 packages/database/src/lib/__tests__/tripleMatching.test.ts create mode 100644 packages/database/src/lib/tripleMatching.ts diff --git a/packages/database/src/lib/__tests__/tripleMatching.test.ts b/packages/database/src/lib/__tests__/tripleMatching.test.ts new file mode 100644 index 000000000..e1b4168b6 --- /dev/null +++ b/packages/database/src/lib/__tests__/tripleMatching.test.ts @@ -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); + }); +}); diff --git a/packages/database/src/lib/tripleMatching.ts b/packages/database/src/lib/tripleMatching.ts new file mode 100644 index 000000000..585c28f98 --- /dev/null +++ b/packages/database/src/lib/tripleMatching.ts @@ -0,0 +1,85 @@ +// 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 Roam's `defaultDiscourseNodes`. +// Check the exact list: a user-created type's id may also start with `_`. +export const ROAM_DEFAULT_NODE_TYPE_IDS: ReadonlySet = 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 = ( + 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]! }; +}; From 0499b1625ea054baabc3ad8f3a0c561b6a22f48d Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Wed, 7 Oct 2026 12:52:24 -0400 Subject: [PATCH 2/2] Notes about the data duplication --- apps/roam/src/data/defaultDiscourseNodes.ts | 2 ++ packages/database/src/lib/tripleMatching.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/data/defaultDiscourseNodes.ts b/apps/roam/src/data/defaultDiscourseNodes.ts index cec808386..d91fd4417 100644 --- a/apps/roam/src/data/defaultDiscourseNodes.ts +++ b/apps/roam/src/data/defaultDiscourseNodes.ts @@ -1,5 +1,7 @@ import { DiscourseNode } from "~/utils/getDiscourseNodes"; +// Keep the `type` ids in sync with ROAM_DEFAULT_NODE_TYPE_IDS in +// packages/database/src/lib/tripleMatching.ts. const INITIAL_NODE_VALUES: Partial[] = [ { type: "_CLM-node", diff --git a/packages/database/src/lib/tripleMatching.ts b/packages/database/src/lib/tripleMatching.ts index 585c28f98..d66114f2d 100644 --- a/packages/database/src/lib/tripleMatching.ts +++ b/packages/database/src/lib/tripleMatching.ts @@ -11,7 +11,8 @@ export type NodeTypeIdentity = { const normalizeName = (name: string): string => name.trim().toLowerCase(); -// The same in every Roam graph; keep in sync with `type` in Roam's `defaultDiscourseNodes`. +// 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 = new Set([ "_CLM-node",