Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/roam/src/data/defaultDiscourseNodes.ts
Original file line number Diff line number Diff line change
@@ -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<DiscourseNode>[] = [
{
type: "_CLM-node",
Expand Down
150 changes: 150 additions & 0 deletions packages/database/src/lib/__tests__/tripleMatching.test.ts
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);
});
});
86 changes: 86 additions & 0 deletions packages/database/src/lib/tripleMatching.ts
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!)) ||
Comment on lines +34 to +35

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.

🟡 Conflicting schema IDs select wrong triples

When distinct schemas reuse a non-default local ID, matchNodeTypes ranks them as a strong match despite conflicting RIDs. pickTripleCandidate can 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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]! };
};
Loading