Skip to content

Commit 763fccf

Browse files
committed
fix(client): keep RDF 1.2 base direction (its:dir) in term writers and keys
termToNTriples and formatTerm wrote a directional literal such as {"xml:lang":"en","its:dir":"ltr"} as "hi"@en, both at the top level and inside a triple term, so site/GUI dataset snapshots changed the term's identity and literals differing only by direction collapsed. Add its:dir to SparqlAtomicTerm and emit it as @lang--dir. The site and GUI graph-view node keys now include the direction too. Tests: client writer/formatter (top-level, nested, parse round trip), site rowsToNQuads round trip, GUI termToNT key round trip, and graph-view node identity in both site and GUI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScyGGohDhirnbLbrUSnA5n
1 parent 417ab17 commit 763fccf

9 files changed

Lines changed: 166 additions & 8 deletions

File tree

‎gui/app/src/lib/inferred-facts.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,24 @@ test("literal base direction is a key component on both paths", () => {
208208
const p: SparqlTerm = { type: "uri", value: "http://ex/p" };
209209
assert.equal(tripleKeyOfBindings(a, p, dirLit), ltr);
210210
});
211+
212+
test("a directional literal inside a triple term round-trips through termToNT", () => {
213+
const a: SparqlTerm = { type: "uri", value: "http://ex/a" };
214+
const p: SparqlTerm = { type: "uri", value: "http://ex/p" };
215+
const tt = (dir: string | undefined): SparqlTerm => ({
216+
type: "triple",
217+
value: {
218+
subject: a,
219+
predicate: p,
220+
object: { type: "literal", value: "hi", "xml:lang": "en", "its:dir": dir },
221+
},
222+
});
223+
for (const t of [tt("ltr"), tt("rtl"), tt(undefined)]) {
224+
const line: string = `${termToNT(a)} ${termToNT(p)} ${termToNT(t)} .`;
225+
assert.equal(keyOfLine(line), tripleKeyOfBindings(a, p, t));
226+
}
227+
// The snapshot keeps the direction, so terms differing only by it stay distinct.
228+
assert.equal(termToNT(tt("ltr")), '<<( <http://ex/a> <http://ex/p> "hi"@en--ltr )>>');
229+
assert.notEqual(termToNT(tt("ltr")), termToNT(tt("rtl")));
230+
assert.notEqual(termToNT(tt("ltr")), termToNT(tt(undefined)));
231+
});

‎gui/app/src/lib/inferred-facts.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ export function keyOfSparqlTerm(t: SparqlTerm): string {
161161
);
162162
}
163163
// SPARQL 1.2 results carry the base direction as a separate `its:dir` field.
164-
const dir = (t as { "its:dir"?: string })["its:dir"];
165-
return literalKey(t.value, t["xml:lang"], dir, t.datatype);
164+
return literalKey(t.value, t["xml:lang"], t["its:dir"], t.datatype);
166165
}
167166

168167
/** Canonical key of a whole triple from three SPARQL-JSON terms. */

‎gui/app/src/lib/select-result-graph.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,26 @@ test("declines literal-only aggregate-shaped SELECT results", () => {
4040
};
4141
assert.equal(deriveSelectResultGraph(results), null);
4242
});
43+
44+
test("keeps literals that differ only by base direction as distinct nodes", () => {
45+
const a = { type: "uri" as const, value: "http://example.com/a" };
46+
const hi = (dir?: string) => ({
47+
type: "literal" as const,
48+
value: "hi",
49+
"xml:lang": "en",
50+
"its:dir": dir,
51+
});
52+
const results: SparqlResults = {
53+
head: { vars: ["s", "v"] },
54+
results: {
55+
bindings: [
56+
{ s: a, v: hi("ltr") },
57+
{ s: a, v: hi("rtl") },
58+
{ s: a, v: hi() },
59+
],
60+
},
61+
};
62+
const graph = deriveSelectResultGraph(results);
63+
assert.ok(graph);
64+
assert.equal(graph.nodes.length, 4);
65+
});

‎gui/app/src/lib/select-result-graph.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ function termKey(term: SparqlTerm): string {
3939
term.value,
4040
term.type === "literal" ? (term.datatype ?? "") : "",
4141
term.type === "literal" ? (term["xml:lang"] ?? "") : "",
42+
// RDF 1.2 base direction is part of the literal's identity.
43+
term.type === "literal" ? (term["its:dir"] ?? "") : "",
4244
]);
4345
}
4446

‎packages/sparq-client/src/index.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,25 @@ export interface WasmModule {
101101
// SPARQL 1.1 JSON + SHACL report shapes (for rendering).
102102
// ---------------------------------------------------------------------------
103103

104-
/** An IRI, blank node or literal in SPARQL-JSON form (`value` is the decoded lexical form). */
104+
/**
105+
* An IRI, blank node or literal in SPARQL-JSON form (`value` is the decoded lexical form).
106+
* A literal with an RDF 1.2 base direction carries the bare tag in `xml:lang` and the
107+
* direction (`ltr` / `rtl`) in `its:dir`, as SPARQL 1.2 Query Results JSON does.
108+
*/
105109
export interface SparqlAtomicTerm {
106110
type: "uri" | "literal" | "bnode";
107111
value: string;
108112
datatype?: string;
109113
"xml:lang"?: string;
114+
"its:dir"?: string;
115+
}
116+
117+
/** A literal's language suffix with its base direction: `en`, or `en--ltr` for `its:dir`. */
118+
function langTag(t: SparqlAtomicTerm): string | undefined {
119+
const lang = t["xml:lang"];
120+
if (!lang) return undefined;
121+
const dir = t["its:dir"];
122+
return dir ? `${lang}--${dir}` : lang;
110123
}
111124

112125
/**
@@ -120,6 +133,7 @@ export interface SparqlTripleTerm {
120133
value: { subject: SparqlTerm; predicate: SparqlTerm; object: SparqlTerm };
121134
datatype?: undefined;
122135
"xml:lang"?: undefined;
136+
"its:dir"?: undefined;
123137
}
124138

125139
/** One SPARQL-JSON term: an IRI / blank node / literal, or an RDF 1.2 triple term. */
@@ -155,7 +169,8 @@ function escapeNTriplesString(value: string): string {
155169
* triple term as `<<( s p o )>>`. The single shared term writer: every N-Triples/N-Quads
156170
* snapshot built from SPARQL-JSON bindings should route through this so a new term kind
157171
* cannot be silently dropped by one writer. A literal's lexical form is escaped the way the
158-
* engine's own serialiser does (backslash, double-quote, LF, CR); `xsd:string` is implicit.
172+
* engine's own serialiser does (backslash, double-quote, LF, CR); `xsd:string` is implicit;
173+
* an RDF 1.2 base direction (`its:dir`) is kept as `@lang--dir`.
159174
*/
160175
export function termToNTriples(t: SparqlTerm): string {
161176
switch (t.type) {
@@ -169,7 +184,7 @@ export function termToNTriples(t: SparqlTerm): string {
169184
}
170185
case "literal": {
171186
const quoted = `"${escapeNTriplesString(t.value)}"`;
172-
const lang = t["xml:lang"];
187+
const lang = langTag(t);
173188
if (lang) return `${quoted}@${lang}`;
174189
if (t.datatype && t.datatype !== XSD_STRING_IRI) return `${quoted}^^<${t.datatype}>`;
175190
return quoted;
@@ -973,7 +988,8 @@ export function formatTerm(t: SparqlTerm | undefined): string {
973988
const { subject, predicate, object } = t.value;
974989
return `<<( ${formatTerm(subject)} ${formatTerm(predicate)} ${formatTerm(object)} )>>`;
975990
}
976-
if (t["xml:lang"]) return `"${t.value}"@${t["xml:lang"]}`;
991+
const lang = langTag(t);
992+
if (lang) return `"${t.value}"@${lang}`;
977993
if (t.datatype && t.datatype !== "http://www.w3.org/2001/XMLSchema#string") {
978994
const short = t.datatype.replace("http://www.w3.org/2001/XMLSchema#", "xsd:");
979995
return `"${t.value}"^^${short}`;

‎packages/sparq-client/test/sparql-term.test.mjs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { test } from "node:test";
88
import {
99
formatTerm,
1010
isTripleTerm,
11+
parseNTriples,
1112
resultsToCsv,
1213
termToNTriples,
1314
termValue,
@@ -67,3 +68,52 @@ test("CSV export renders a triple term as its N-Triples form, not [object Object
6768
});
6869
assert.equal(csv, "t\r\n<<( <http://ex/alice> <http://ex/knows> <http://ex/alice> )>>");
6970
});
71+
72+
// RDF 1.2 base direction: SPARQL 1.2 results carry it as a separate `its:dir` field next to
73+
// the bare `xml:lang` tag. `"hi"@en--ltr` and `"hi"@en` are different RDF terms, so every
74+
// writer must keep the direction, at the top level and inside a triple term.
75+
const hiLtr = { type: "literal", value: "hi", "xml:lang": "en", "its:dir": "ltr" };
76+
const hiRtl = { type: "literal", value: "hi", "xml:lang": "en", "its:dir": "rtl" };
77+
const hiPlain = { type: "literal", value: "hi", "xml:lang": "en" };
78+
79+
test("termToNTriples keeps a top-level literal's base direction", () => {
80+
assert.equal(termToNTriples(hiLtr), '"hi"@en--ltr');
81+
assert.equal(termToNTriples(hiRtl), '"hi"@en--rtl');
82+
assert.equal(termToNTriples(hiPlain), '"hi"@en');
83+
});
84+
85+
test("termToNTriples keeps base direction inside a (nested) triple term", () => {
86+
const tt = (o) => ({ type: "triple", value: { subject: alice, predicate: knows, object: o } });
87+
assert.equal(
88+
termToNTriples(tt(hiLtr)),
89+
'<<( <http://ex/alice> <http://ex/knows> "hi"@en--ltr )>>',
90+
);
91+
assert.equal(
92+
termToNTriples(tt(tt(hiRtl))),
93+
'<<( <http://ex/alice> <http://ex/knows> <<( <http://ex/alice> <http://ex/knows> "hi"@en--rtl )>> )>>',
94+
);
95+
// Terms differing only by direction must not collapse.
96+
const forms = new Set([hiLtr, hiRtl, hiPlain].map((o) => termToNTriples(tt(o))));
97+
assert.equal(forms.size, 3);
98+
});
99+
100+
test("formatTerm keeps base direction, top-level and nested", () => {
101+
assert.equal(formatTerm(hiLtr), '"hi"@en--ltr');
102+
assert.equal(formatTerm(hiPlain), '"hi"@en');
103+
assert.equal(
104+
formatTerm({ type: "triple", value: { subject: alice, predicate: knows, object: hiRtl } }),
105+
'<<( <http://ex/alice> <http://ex/knows> "hi"@en--rtl )>>',
106+
);
107+
});
108+
109+
test("a directional literal nested in a triple term survives a snapshot round trip", () => {
110+
const o = { type: "triple", value: { subject: alice, predicate: knows, object: hiLtr } };
111+
const line = `${termToNTriples(alice)} ${termToNTriples(knows)} ${termToNTriples(o)} .`;
112+
const { statements } = parseNTriples(line);
113+
assert.equal(statements.length, 1);
114+
const back = statements[0].o;
115+
assert.equal(back.kind, "triple");
116+
assert.equal(back.o.kind, "literal");
117+
assert.equal(back.o.value, "hi");
118+
assert.equal(back.o.lang, "en--ltr");
119+
});

‎site/src/lib/result-graph-shape.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ import type { SparqlResults, SparqlTerm } from "./sparq-wasm";
2121
export const MAX_GRAPH_NODES = 24;
2222

2323
/**
24-
* A stable, INJECTIVE identity key for a term: same lexical value but different datatype/lang ⇒
24+
* A stable, INJECTIVE identity key for a term: same lexical value but different datatype/lang/dir ⇒
2525
* different node. Shared with deriveGraph (result-graph.ts) so the predicate's admission order and
2626
* the derivation's node identity can never disagree.
2727
*/
2828
export function termKey(t: SparqlTerm): string {
2929
const dt = t.type === "literal" ? (t.datatype ?? "") : "";
3030
const lang = t.type === "literal" ? (t["xml:lang"] ?? "") : "";
31-
return JSON.stringify([t.type, t.value, dt, lang]);
31+
// RDF 1.2 base direction is part of the literal's identity (`"hi"@en--ltr` ≠ `"hi"@en`).
32+
const dir = t.type === "literal" ? (t["its:dir"] ?? "") : "";
33+
return JSON.stringify([t.type, t.value, dt, lang, dir]);
3234
}
3335

3436
/**

‎site/test/repl-dataset.test.mjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
DEFAULT_GRAPH_COUNT_QUERY,
2121
parseGraphStats,
2222
} from "../src/lib/repl-dataset.ts";
23+
import { parseNTriples } from "@sparq/client";
2324

2425
test("isDatasetFormat is true exactly for the quad-bearing formats", () => {
2526
// Quad formats carry named graphs and must be loaded with loadDataset.
@@ -112,6 +113,32 @@ test("rowsToNQuads writes an RDF 1.2 triple term as <<( s p o )>>", () => {
112113
assert.ok(!out.includes("undefined"));
113114
});
114115

116+
// RDF 1.2 base direction (`its:dir` in SPARQL 1.2 JSON) is part of a literal's identity:
117+
// a snapshot must write `"hi"@en--ltr`, at the top level and inside a triple term.
118+
test("rowsToNQuads round-trips a directional literal, top-level and in a triple term", () => {
119+
const a = { type: "uri", value: "http://ex/a" };
120+
const p = { type: "uri", value: "http://ex/p" };
121+
const hi = (dir) => ({ type: "literal", value: "hi", "xml:lang": "en", "its:dir": dir });
122+
const tt = (o) => ({ type: "triple", value: { subject: a, predicate: p, object: o } });
123+
const rows = [
124+
{ s: a, p, o: hi("ltr") },
125+
{ s: a, p, o: tt(hi("rtl")) },
126+
{ s: a, p, o: tt(hi(undefined)) },
127+
];
128+
const nq = rowsToNQuads(rows);
129+
assert.equal(
130+
nq,
131+
'<http://ex/a> <http://ex/p> "hi"@en--ltr .\n' +
132+
'<http://ex/a> <http://ex/p> <<( <http://ex/a> <http://ex/p> "hi"@en--rtl )>> .\n' +
133+
'<http://ex/a> <http://ex/p> <<( <http://ex/a> <http://ex/p> "hi"@en )>> .',
134+
);
135+
const { statements } = parseNTriples(nq);
136+
assert.deepEqual(
137+
statements.map((st) => (st.o.kind === "triple" ? st.o.o.lang : st.o.lang)),
138+
["en--ltr", "en--rtl", "en"],
139+
);
140+
});
141+
115142
test("rowsToNQuads emits a quad line (with the graph) for named-graph rows", () => {
116143
const rows = [
117144
{

‎site/test/result-graph.test.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,24 @@ test("deriveGraph: literals with the same value but different datatype are disti
178178
assert.equal(g.nodes.length, 3);
179179
});
180180

181+
test("deriveGraph: literals differing only by base direction are distinct nodes", () => {
182+
const a = { type: "uri", value: "http://ex/a" };
183+
const hi = (dir) => ({ type: "literal", value: "hi", "xml:lang": "en", "its:dir": dir });
184+
const g = deriveGraph({
185+
head: { vars: ["s", "v"] },
186+
results: {
187+
bindings: [
188+
{ s: a, v: hi("ltr") },
189+
{ s: a, v: hi("rtl") },
190+
{ s: a, v: hi(undefined) },
191+
],
192+
},
193+
});
194+
assert.ok(g);
195+
// http://ex/a + "hi"@en--ltr + "hi"@en--rtl + "hi"@en = 4 distinct nodes.
196+
assert.equal(g.nodes.length, 4);
197+
});
198+
181199
test("deriveGraph: delimiter-containing lexical/datatype fields keep terms distinct (no key collision)", () => {
182200
// Regression: the term identity key must be INJECTIVE. It once concatenated (value, datatype,
183201
// lang) with a single-character delimiter, so a delimiter INSIDE one field could forge another

0 commit comments

Comments
 (0)