UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

356 lines • 16.9 kB
/** * ontology/fromSkos — a customer's SKOS concept scheme (JSON-LD, already * parsed) becomes OUR map: `readSkos` is the pure parse, `fromSkos` joins * the host's sources onto it and returns an `OntologySpec` for * `defineOntology` — the ONE shape everything else reads. * * Pattern: reader = adapter. Two pure functions over plain JSON; no I/O, no * registry, no second validation path — the result goes through * `defineOntology` unchanged, which is where an id that is not * identifier-safe, an alias that collides with an id, or a source * nobody declared is refused. * Role: Map. Nothing here infers: a concept is a node typed * `skos:Concept`, its id is the last segment of its IRI, its * meaning is its `definition`, else its `scopeNote`, else its * `prefLabel`; `broader` is `is-a`, `related` is `related`; every * other name and every label in another language is left where it * was. SKOS cannot say which SOURCE holds a term or which TOOL reads * it — the host binds those by hand in `SkosJoin.bind`; a term with * no binding is the honest state "declared, no source holds it". * Emits: N/A. * * THE OWNER'S RULING (2026-09-18): customers already have taxonomies, almost * always as SKOS. The library takes THEIRS in and turns it into OUR map; * readers are adapters, `defineOntology` stays canonical. OWL is out of * scope; a Turtle reader is a follow-up; serving the map back in SKOS * vocabulary waits for the bench. * * REFUSALS — every one an `Error` with a `code` (`ERR_SKOS_…`), the IRI(s) * involved and what was expected; the reader never returns a partial map: * ERR_SKOS_INPUT not a JSON-LD document, or a node that is not an object * ERR_SKOS_NAMED_GRAPH a node that is itself a named graph (`{ "@id", "@graph": [...] }`) — * its concepts would be dropped without a word; flatten the document first * ERR_SKOS_UNTYPED a node carrying SKOS properties with no `@type` * ERR_SKOS_NO_CONCEPTS no `skos:Concept` in the document * ERR_SKOS_NO_LABEL a concept with no prefLabel in the asked language * ERR_SKOS_ID an IRI whose last segment is empty * ERR_SKOS_ID_COLLISION two concepts collapsing to one id * ERR_SKOS_UNKNOWN_CONCEPT a broader/narrower/related target the document does not hold * ERR_SKOS_CYCLE a cycle in `broader` (SKOS forbids it) * ERR_SKOS_SCHEME_MISSING no id or no version, from the join or the scheme node * ERR_SKOS_SCHEME_AMBIGUOUS several scheme nodes and the join does not settle id and version * ERR_SKOS_BIND_UNKNOWN_TERM a `bind` key naming a term the scheme does not hold */ import { expandIri, expandNode, FOOTPRINT, idFromIri, inLanguage, irisOf, isRecord, literalsOf, readContext, SKOS, typesOf, valuesOf, VERSION_IRIS, } from './skosJsonLd.js'; /** The one error the reader throws: a `code`, the IRI(s) involved, what was expected. */ export class SkosError extends Error { code; iris; constructor(code, message, iris = []) { super(`${code}: ${message}`); this.name = 'SkosError'; this.code = code; this.iris = Object.freeze([...iris]); } } const DEFAULT_LANGUAGE = 'en'; const CONCEPT_PROPERTIES = [ SKOS.prefLabel, SKOS.altLabel, SKOS.hiddenLabel, SKOS.definition, SKOS.scopeNote, SKOS.broader, SKOS.narrower, SKOS.related, SKOS.inScheme, SKOS.topConceptOf, SKOS.hasTopConcept, ]; /** Split the input into its context and its node objects; refuse anything that is not a document. */ function readDocument(input) { if (Array.isArray(input)) return { context: readContext(undefined), nodes: checkNodes(input) }; if (!isRecord(input)) { throw new SkosError('ERR_SKOS_INPUT', 'expected a JSON-LD document: an object, an array of node objects, or { "@graph": [...] }.'); } const context = readContext(input['@context']); if ('@graph' in input) { if (!Array.isArray(input['@graph'])) { throw new SkosError('ERR_SKOS_INPUT', 'expected "@graph" to be an array of node objects.'); } return { context, nodes: checkNodes(input['@graph']) }; } const single = Object.fromEntries(Object.entries(input).filter(([key]) => key !== '@context')); return { context, nodes: checkNodes([single]) }; } function checkNodes(raw) { return raw.map((node, i) => { if (!isRecord(node)) { throw new SkosError('ERR_SKOS_INPUT', `node ${i} is not an object; expected a JSON-LD node object.`); } // A named graph nested in the graph: JSON-LD allows it, this reader does not // walk into it — and silently skipping it would hand back a partial map. if ('@graph' in node) { throw new SkosError('ERR_SKOS_NAMED_GRAPH', `node ${i}${typeof node['@id'] === 'string' ? ` ('${node['@id']}')` : ''} is a named graph; expected a flat "@graph" of node objects — flatten the document first.`, typeof node['@id'] === 'string' ? [node['@id']] : []); } return node; }); } /** Sort the nodes by `@type`; a node with SKOS properties and no type is refused, never guessed. */ function typeNodes(doc) { const concepts = []; const schemes = []; doc.nodes.forEach((node, i) => { const props = expandNode(node, doc.context); const rawId = props.get('@id'); const iri = typeof rawId === 'string' ? expandIri(rawId, doc.context) : `_:node${i}`; const types = typesOf(props, doc.context); if (types.includes(SKOS.Concept)) concepts.push({ iri, props }); else if (types.includes(SKOS.ConceptScheme)) schemes.push({ iri, props }); else if (types.length === 0 && CONCEPT_PROPERTIES.some((p) => props.has(p))) { throw new SkosError('ERR_SKOS_UNTYPED', `node '${iri}' carries SKOS properties but no @type; expected skos:Concept or skos:ConceptScheme.`, [iri]); } }); return { concepts, schemes }; } function readConcept(raw, context, language) { const { iri, props } = raw; const id = idFromIri(iri); if (id.length === 0) { throw new SkosError('ERR_SKOS_ID', `concept '${iri}' has no last path or fragment segment to take an id from.`, [iri]); } const prefLabels = inLanguage(literalsOf(props, SKOS.prefLabel, context), language); if (prefLabels.length === 0) { const seen = literalsOf(props, SKOS.prefLabel, context).map((l) => l.language ?? 'untagged'); throw new SkosError('ERR_SKOS_NO_LABEL', `concept '${iri}' has no skos:prefLabel in language '${language}' (labels seen: ${seen.length === 0 ? 'none' : seen.join(', ')}); a label is never taken from another language.`, [iri]); } const prefLabel = prefLabels[0]; const meaning = inLanguage(literalsOf(props, SKOS.definition, context), language)[0] ?? inLanguage(literalsOf(props, SKOS.scopeNote, context), language)[0] ?? prefLabel; const aliases = uniqueNames([ ...inLanguage(literalsOf(props, SKOS.altLabel, context), language), ...inLanguage(literalsOf(props, SKOS.hiddenLabel, context), language), ...(prefLabel !== id ? [prefLabel] : []), ]).filter((alias) => alias !== id); const unit = literalsOf(props, FOOTPRINT.unit, context)[0]?.value; return { iri, id, prefLabel, node: { meaning, ...(unit !== undefined && { unit }), ...(aliases.length > 0 && { aliases }), }, }; } function uniqueNames(names) { return [...new Set(names.map((n) => n.trim()).filter((n) => n.length > 0))]; } /** Concepts sorted by id, with a collision refused naming both IRIs. */ function readConcepts(typed, context, language) { if (typed.concepts.length === 0) { throw new SkosError('ERR_SKOS_NO_CONCEPTS', 'the document holds no node typed skos:Concept.'); } const byId = new Map(); for (const raw of typed.concepts) { const concept = readConcept(raw, context, language); const other = byId.get(concept.id); if (other !== undefined) { throw new SkosError('ERR_SKOS_ID_COLLISION', `concepts '${other.iri}' and '${concept.iri}' both collapse to id '${concept.id}'; expected distinct last segments.`, [other.iri, concept.iri]); } byId.set(concept.id, concept); } return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : 1)); } // ─── Edges ────────────────────────────────────────────────────────────── function readEdges(typed, concepts, context) { const idOf = new Map(typed.concepts.map((c) => [c.iri, idFromIri(c.iri)])); const resolve = (from, property, target) => { const id = idOf.get(target); if (id === undefined) { throw new SkosError('ERR_SKOS_UNKNOWN_CONCEPT', `concept '${from}' has ${property} '${target}', which is not a skos:Concept in this document.`, [from, target]); } return id; }; const propsOf = new Map(typed.concepts.map((c) => [c.iri, c.props])); const isA = new Map(); const related = new Map(); const declared = []; for (const concept of concepts) { const props = propsOf.get(concept.iri) ?? new Map(); for (const target of irisOf(props, SKOS.broader, context)) { addOnce(isA, { from: concept.id, to: resolve(concept.iri, 'skos:broader', target), relation: 'is-a', }); } for (const target of irisOf(props, SKOS.narrower, context)) { addOnce(isA, { from: resolve(concept.iri, 'skos:narrower', target), to: concept.id, relation: 'is-a', }); } for (const target of irisOf(props, SKOS.related, context)) { const other = resolve(concept.iri, 'skos:related', target); const [from, to] = concept.id < other ? [concept.id, other] : [other, concept.id]; addOnce(related, { from, to, relation: 'related' }); } for (const entry of valuesOf(props, FOOTPRINT.edge)) { if (!isRecord(entry)) continue; const edgeProps = expandNode(entry, context); const target = irisOf(edgeProps, FOOTPRINT.to, context)[0]; const relation = literalsOf(edgeProps, FOOTPRINT.relation, context)[0]?.value; const meaning = literalsOf(edgeProps, FOOTPRINT.meaning, context)[0]?.value; if (target === undefined || relation === undefined) continue; const edge = { from: concept.id, to: resolve(concept.iri, 'footprint:edge', target), relation, ...(meaning !== undefined && { meaning }), }; // `toSkos` writes an `is-a`/`related` edge that carries a meaning BOTH // as the SKOS relation and as a `footprint:edge`: the latter completes // the former (same key, the meaning added) rather than doubling it. const key = edgeKey(edge); if (isA.has(key)) isA.set(key, edge); else if (related.has(key)) related.set(key, edge); else declared.push(edge); } } // Sorted, so the same scheme written with `narrower` instead of `broader` // (or in another node order) yields the same edges — and the same hash. const edges = [ ...sortEdges(isA.values()), ...sortEdges(related.values()), ...sortEdges(declared), ]; refuseCycles(concepts, [...isA.values()]); return edges; } function sortEdges(edges) { return [...edges].sort((a, b) => edgeKey(a).localeCompare(edgeKey(b))); } function edgeKey(edge) { return `${edge.from} —${edge.relation}→ ${edge.to}`; } function addOnce(into, edge) { const key = edgeKey(edge); if (!into.has(key)) into.set(key, edge); } /** A cycle in `broader` is refused with the cycle named, id by id. */ function refuseCycles(concepts, isA) { const broader = new Map(); for (const edge of isA) broader.set(edge.from, [...(broader.get(edge.from) ?? []), edge.to]); const done = new Set(); const iriOf = new Map(concepts.map((c) => [c.id, c.iri])); const walk = (id, path) => { const at = path.indexOf(id); if (at >= 0) { const cycle = [...path.slice(at), id]; throw new SkosError('ERR_SKOS_CYCLE', `skos:broader forms a cycle: ${cycle.join(' → ')}; SKOS forbids a concept broader than itself.`, cycle.map((c) => iriOf.get(c) ?? c)); } if (done.has(id)) return; for (const next of broader.get(id) ?? []) walk(next, [...path, id]); done.add(id); }; for (const concept of concepts) walk(concept.id, []); } function readSchemeIdentity(typed, context) { if (typed.schemes.length !== 1) return {}; const scheme = typed.schemes[0]; const id = idFromIri(scheme.iri); let version; for (const iri of VERSION_IRIS) { version = literalsOf(scheme.props, iri, context)[0]?.value; if (version !== undefined) break; } return { iri: scheme.iri, ...(id.length > 0 && { id }), ...(version !== undefined && { version }), }; } // ─── The two doors ────────────────────────────────────────────────────── /** * The pure parse: concepts (sorted by id), edges, and the scheme's identity * when ONE `skos:ConceptScheme` node carries it. Refuses by code; never * partial. Exported so a host can look before it joins, and for tests. */ export function readSkos(input, options = {}) { const language = options.language ?? DEFAULT_LANGUAGE; const doc = readDocument(input); const typed = typeNodes(doc); const concepts = readConcepts(typed, doc.context, language); const edges = readEdges(typed, concepts, doc.context); const scheme = readSchemeIdentity(typed, doc.context); return Object.freeze({ ...scheme, language, concepts, edges }); } /** * The customer's scheme joined with the host's sources → an `OntologySpec`. * The host calls `defineOntology(fromSkos(input, join))`: nothing is * validated twice, and nothing is guessed — a term the join does not bind * has no sources, which is the honest state. */ export function fromSkos(input, join) { const typed = typeNodes(readDocument(input)); const scheme = readSkos(input, { ...(join.language !== undefined && { language: join.language }), }); const identity = settleIdentity(typed, scheme, join); const bind = join.bind ?? {}; const known = new Set(scheme.concepts.map((c) => c.id)); for (const term of Object.keys(bind)) { if (!known.has(term)) { throw new SkosError('ERR_SKOS_BIND_UNKNOWN_TERM', `bind names term '${term}', which the scheme does not hold; expected one of: ${[ ...known, ].join(', ')}.`); } } const nodes = {}; for (const concept of scheme.concepts) { const sources = bind[concept.id]; nodes[concept.id] = { ...concept.node, ...(sources !== undefined && { sources }) }; } return { id: identity.id, version: identity.version, sources: join.sources, nodes, edges: scheme.edges, }; } function settleIdentity(typed, scheme, join) { if (typed.schemes.length > 1 && (join.id === undefined || join.version === undefined)) { throw new SkosError('ERR_SKOS_SCHEME_AMBIGUOUS', `the document holds ${typed.schemes.length} skos:ConceptScheme nodes; expected one, or join.id and join.version to settle the identity.`, typed.schemes.map((s) => s.iri)); } const id = join.id ?? scheme.id; const version = join.version ?? scheme.version; const where = scheme.iri === undefined ? 'no skos:ConceptScheme node' : `scheme '${scheme.iri}'`; if (id === undefined) { throw new SkosError('ERR_SKOS_SCHEME_MISSING', `no map id: join.id not given and ${where} yields no last IRI segment.`, scheme.iri ? [scheme.iri] : []); } if (version === undefined) { throw new SkosError('ERR_SKOS_SCHEME_MISSING', `no map version: join.version not given and ${where} carries none of dcterms:modified, owl:versionInfo, schema:version.`, scheme.iri ? [scheme.iri] : []); } return { id, version }; } //# sourceMappingURL=fromSkos.js.map