agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
235 lines • 9.68 kB
JavaScript
/**
* ontology/skosJsonLd — the JSON-LD spellings a SKOS document uses, resolved
* to IRIs with no inference.
*
* Pattern: pure functions over an already-parsed JSON-LD document; a leaf
* under `fromSkos.ts` (its one import, `lib/linearText.ts`, is a
* leaf too).
* Role: Map (reader side). A key is resolved by the document's own
* `@context` when it has one — a term mapping, a prefix, `@vocab` —
* else by the fixed table of the vocabularies SKOS documents lean on
* (`skos:`, `dcterms:`, `owl:`, `schema:`, `footprint:`). A full IRI
* is itself. A bare key no context maps is NOT a SKOS property: it
* is left unresolved, never guessed from its spelling.
* Emits: N/A.
*
* Out of scope, by the owner's ruling (2026-09-18): a Turtle parser (the
* input is an OBJECT), remote `@context` fetching (a context given by URL is
* not resolved), OWL.
*/
import { isLineBreak } from '../lib/linearText.js';
/** The SKOS core namespace. */
export const SKOS_NS = 'http://www.w3.org/2004/02/skos/core#';
/** The OWL namespace — read for `owl:versionInfo` only; OWL itself is out of scope. */
export const OWL_NS = 'http://www.w3.org/2002/07/owl#';
/** Our own namespace for what SKOS cannot say: units, sources, tools, coverage, other relations. */
export const FOOTPRINT_NS = 'https://footprintjs.dev/ns/ontology#';
/** The prefixes the reader knows without a context — the fixed table. */
export const KNOWN_PREFIXES = Object.freeze({
skos: SKOS_NS,
dcterms: 'http://purl.org/dc/terms/',
dct: 'http://purl.org/dc/terms/',
owl: OWL_NS,
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
xsd: 'http://www.w3.org/2001/XMLSchema#',
schema: 'https://schema.org/',
footprint: FOOTPRINT_NS,
});
/** The IRIs the reader looks at, by name. */
export const SKOS = Object.freeze({
Concept: `${SKOS_NS}Concept`,
ConceptScheme: `${SKOS_NS}ConceptScheme`,
prefLabel: `${SKOS_NS}prefLabel`,
altLabel: `${SKOS_NS}altLabel`,
hiddenLabel: `${SKOS_NS}hiddenLabel`,
definition: `${SKOS_NS}definition`,
scopeNote: `${SKOS_NS}scopeNote`,
broader: `${SKOS_NS}broader`,
narrower: `${SKOS_NS}narrower`,
related: `${SKOS_NS}related`,
inScheme: `${SKOS_NS}inScheme`,
topConceptOf: `${SKOS_NS}topConceptOf`,
hasTopConcept: `${SKOS_NS}hasTopConcept`,
});
/** Where a scheme's version may be written, in the order the reader looks. */
export const VERSION_IRIS = Object.freeze([
'http://purl.org/dc/terms/modified',
`${OWL_NS}versionInfo`,
'https://schema.org/version',
'http://schema.org/version',
]);
/** The `footprint:` properties `toSkos` writes and `readSkos` reads back. */
export const FOOTPRINT = Object.freeze({
unit: `${FOOTPRINT_NS}unit`,
edge: `${FOOTPRINT_NS}edge`,
to: `${FOOTPRINT_NS}to`,
relation: `${FOOTPRINT_NS}relation`,
meaning: `${FOOTPRINT_NS}meaning`,
});
export function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isAbsoluteIri(value) {
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) || value.startsWith('urn:');
}
/**
* Read a `@context` (an object, an array of objects, or a URL — the last
* is not fetched). A term whose value is a string or `{ "@id": … }` maps
* to that IRI; a term whose value ends in `#` or `/` is also a prefix.
*/
export function readContext(context) {
const terms = new Map();
const prefixes = new Map(Object.entries(KNOWN_PREFIXES));
let vocab;
let language;
const parts = Array.isArray(context) ? context : [context];
for (const part of parts) {
if (!isRecord(part))
continue;
for (const [key, raw] of Object.entries(part)) {
if (key === '@vocab' && typeof raw === 'string')
vocab = raw;
else if (key === '@language' && typeof raw === 'string')
language = raw;
else if (key.startsWith('@'))
continue;
else {
const iri = typeof raw === 'string' ? raw : isRecord(raw) ? raw['@id'] : undefined;
if (typeof iri !== 'string')
continue;
const expanded = expandCompact(iri, prefixes) ?? iri;
terms.set(key, expanded);
if (expanded.endsWith('#') || expanded.endsWith('/'))
prefixes.set(key, expanded);
}
}
}
return { terms, prefixes, ...(vocab && { vocab }), ...(language && { language }) };
}
/** `prefix:local` → IRI when the prefix is known; else undefined. */
function expandCompact(value, prefixes) {
const colon = value.indexOf(':');
if (colon <= 0 || isAbsoluteIri(value))
return undefined;
const base = prefixes.get(value.slice(0, colon));
return base === undefined ? undefined : base + value.slice(colon + 1);
}
/**
* Resolve a key or a `@type` / `@id` value to an IRI: a keyword stays a
* keyword; a term the context maps; `prefix:local`; a full IRI; a bare
* name under `@vocab`. Anything else stays as written (a bare literal),
* which no SKOS IRI ever equals — so it is ignored, not misread.
*/
export function expandIri(value, context) {
if (value.startsWith('@'))
return value;
const term = context.terms.get(value);
if (term !== undefined)
return term;
const compact = expandCompact(value, context.prefixes);
if (compact !== undefined)
return compact;
if (isAbsoluteIri(value))
return value;
if (context.vocab !== undefined && !value.includes(':'))
return context.vocab + value;
return value;
}
/** A node's properties keyed by expanded IRI (keywords keep their `@` names). */
export function expandNode(node, context) {
const out = new Map();
for (const [key, value] of Object.entries(node))
out.set(expandIri(key, context), value);
return out;
}
/** Every value under a property as a flat list (`@list`, arrays and singles alike). */
export function valuesOf(node, iri) {
const raw = node.get(iri);
if (raw === undefined || raw === null)
return [];
if (Array.isArray(raw))
return raw;
if (isRecord(raw) && Array.isArray(raw['@list']))
return raw['@list'];
return [raw];
}
/** The `@type` IRIs of a node. */
export function typesOf(node, context) {
return valuesOf(node, '@type')
.filter((t) => typeof t === 'string')
.map((t) => expandIri(t, context));
}
/** The literals under a property: plain strings, numbers and `{ "@value", "@language"? }` objects. */
export function literalsOf(node, iri, context) {
const out = [];
for (const raw of valuesOf(node, iri)) {
if (typeof raw === 'string' || typeof raw === 'number') {
out.push({ value: String(raw), ...(context.language && { language: context.language }) });
}
else if (isRecord(raw) &&
(typeof raw['@value'] === 'string' || typeof raw['@value'] === 'number')) {
const language = typeof raw['@language'] === 'string' ? raw['@language'] : context.language;
out.push({ value: String(raw['@value']), ...(language && { language }) });
}
}
return out;
}
/** The IRIs a property points at: strings (resolved) and `{ "@id": … }` objects. */
export function irisOf(node, iri, context) {
const out = [];
for (const raw of valuesOf(node, iri)) {
if (typeof raw === 'string')
out.push(expandIri(raw, context));
else if (isRecord(raw) && typeof raw['@id'] === 'string')
out.push(expandIri(raw['@id'], context));
}
return out;
}
/**
* The literal in the asked language: a label TAGGED with it wins; a label
* with no tag at all serves any language (it is not tagged as another);
* a label tagged with a different language is never taken. Tags compare
* case-insensitively and exactly (`en` is not `en-US`).
*/
export function inLanguage(literals, language) {
const wanted = language.toLowerCase();
const tagged = literals.filter((l) => l.language?.toLowerCase() === wanted);
const untagged = literals.filter((l) => l.language === undefined);
return [...tagged, ...untagged].map((l) => l.value);
}
/**
* `iri.replace(/[?].*$/, '')` in one pass: the IRI up to its query. That
* regex can only finish at the end of the text and its `.` stops at a line
* break, so it cuts at the first `?` after the last line break — but it
* re-tried from every earlier `?`, which is quadratic on a run of them before a
* line break in a document's IRI.
*/
export function withoutQuery(iri) {
let lineStart = iri.length;
while (lineStart > 0 && !isLineBreak(iri.charCodeAt(lineStart - 1)))
lineStart--;
const query = iri.indexOf('?', lineStart);
return query === -1 ? iri : iri.slice(0, query);
}
/**
* A concept id from its IRI: the last `/` or `#` segment, percent-decoded,
* lower-cased, `-` and spaces to `_`. Empty when the IRI ends in a delimiter.
*/
export function idFromIri(iri) {
const stripped = withoutQuery(iri);
const last = Math.max(stripped.lastIndexOf('/'), stripped.lastIndexOf('#'));
const segment = last < 0 ? stripped : stripped.slice(last + 1);
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
}
catch {
// Not percent-encoded: the segment is used as written.
}
return decoded
.trim()
.toLowerCase()
.replace(/[-\s]+/g, '_');
}
//# sourceMappingURL=skosJsonLd.js.map