logicsrc/packages/openontology/src/shacl.ts
Anthony Ettinger da5f6f8381
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.

Storage (Phase 2)
  @logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
  model at open, serves reads synchronously — a query evaluator that awaits per
  triple pattern is unusable — and buffers mutations as SQL that flush() writes
  in one transaction. Versioned idempotent migrations; indexes over subject,
  predicate, entity-valued object, status, both time axes, aliases, and external
  ids; FTS5 for label/alias search. The append-only status log is replayed on
  open, so retractions, supersessions, and merge redirects survive a reopen.

REST + SSE + OpenAPI (Phase 2)
  16 paths under /api/ontologies in logicsrc-web, described at
  /api/ontologies/openapi and referencing the published JSON Schemas rather
  than restating them. No token is read-only; a curator token can apply; an
  agent token can propose and cannot apply. Idempotency-Key on mutations,
  revision ETags, 409 on a stale base revision, and an SSE stream that emits
  the same event objects as the JSON endpoint.

MCP (Phase 2)
  OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
  schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
  tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
  proposals, never applies — the denial is the shared policy layer, not a
  second rule that could drift.

Interoperability (Phase 3)
  RDF/Turtle export and import of the reified profile, plus the plain triple
  for asserted relationships so a consumer wanting only the accepted graph gets
  one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
  unmapped in both the return value and the generated Turtle, because a shape
  that quietly means something narrower is worse than no shape.

Source adapters (Phase 3)
  CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
  PROPOSED change-set operations with source, evidence selector, run id, and
  confidence attached; fetch is injected so ingestion is offline and testable.
  Each declares its capabilities, so "nothing was deleted upstream" is never
  confused with "this adapter cannot see deletions" — none of the seven can.

TUI + explorer
  Keyboard-first panels (types, entities, claims, sources, queries, change
  sets, validation, audit) as plain strings that survive SSH and 60 columns;
  status is a glyph and a word, never colour alone; the key bar wraps rather
  than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
  at /openontology/explore with entity and claim views showing status, both
  clocks, confidence, sources, evidence, and append-only history — plus an
  /openprd page for the companion standard.

Bugs found and fixed while testing
  - the API built a new engine per request, so `explain` could never find a
    resultId from a prior request; engines are now cached per role
  - the TUI status bar called engine.validateOntologyPackage(), appending a
    package.validated event on every repaint; it now uses the pure validator

Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.

Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 05:10:33 -07:00

209 lines
7.1 KiB
TypeScript

import { packagePrefix, OO } from "./jsonld.js";
import type { BuiltPackage, Constraint, LoadedPackage } from "./types.js";
/**
* SHACL mapping for the constraint kinds whose semantics genuinely match.
*
* Four of the seven map cleanly onto node/property shapes. `unique` and the
* query-based checks do not — SHACL has no portable "this value appears once
* across the graph" without SPARQL constraints, and an OpenOntology saved
* query is not a SPARQL query. Those are reported as unmapped rather than
* approximated, because a shape that silently means something else is worse
* than no shape at all.
*/
export interface ShaclExport {
turtle: string;
mapped: Array<{ constraint: string; shape: string }>;
unmapped: Array<{ constraint: string; rule: string; reason: string }>;
}
export function constraintsToShacl(pkg: BuiltPackage | LoadedPackage): ShaclExport {
const manifest = pkg.manifest;
const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
const prefix = packagePrefix(pkg);
const mapped: ShaclExport["mapped"] = [];
const unmapped: ShaclExport["unmapped"] = [];
const body: string[] = [];
const shapeName = (constraint: Constraint) =>
`ns:${toPascal(constraint.id)}Shape`;
for (const constraint of pkg.schema.constraints) {
const severity = shaclSeverity(constraint.severity ?? "error");
const shape = shapeName(constraint);
const rule = constraint.rule;
switch (rule.type) {
case "required-predicate": {
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetClass ns:${rule.entityType} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:minCount 1 ;`,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "cardinality": {
const counts = [
rule.min !== undefined ? ` sh:minCount ${rule.min} ;` : null,
rule.max !== undefined ? ` sh:maxCount ${rule.max} ;` : null
].filter(Boolean) as string[];
body.push(
`${shape}`,
` a sh:NodeShape ;`,
rule.entityType ? ` sh:targetClass ns:${rule.entityType} ;` : ` sh:targetSubjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
...counts,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "allowed-values": {
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetSubjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:in (${rule.values.map((value) => JSON.stringify(String(value))).join(" ")}) ;`,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "domain-range": {
const lines = [`${shape}`, ` a sh:NodeShape ;`];
lines.push(` sh:targetSubjectsOf ns:${rule.predicate} ;`);
lines.push(` rdfs:comment ${JSON.stringify(constraint.description)} ;`);
if (rule.from?.length) {
lines.push(
` sh:or (${rule.from.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`
);
}
if (rule.to?.length) {
lines.push(
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:or (${rule.to.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`,
` sh:severity ${severity} ;`,
` ] ;`
);
}
lines.push(` sh:severity ${severity} .`, "");
body.push(...lines);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "temporal-bounds": {
const props: string[] = [];
if (rule.notBefore) props.push(` sh:minInclusive ${JSON.stringify(rule.notBefore)} ;`);
if (rule.notAfter) props.push(` sh:maxInclusive ${JSON.stringify(rule.notAfter)} ;`);
if (rule.requireValidFrom) props.push(` sh:minCount 1 ;`);
if (props.length === 0) {
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason: "temporal-bounds with no bounds has nothing to express"
});
break;
}
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetObjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path oo:validFrom ;`,
...props,
` sh:severity ${severity} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "unique":
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason:
"graph-wide uniqueness has no portable SHACL Core equivalent; it needs a sh:SPARQLConstraint"
});
break;
case "query":
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason: "an OpenOntology saved query is a triple-pattern AST, not SPARQL"
});
break;
default:
unmapped.push({ constraint: (constraint as Constraint).id, rule: "unknown", reason: "unrecognized rule type" });
}
}
const header = [
"@prefix sh: <http://www.w3.org/ns/shacl#> .",
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
`@prefix oo: <${OO}> .`,
`@prefix ns: <${ns}> .`,
"",
`# SHACL shapes generated from ${manifest.id}@${manifest.version} (compact prefix: ${prefix})`,
`# ${mapped.length} constraint(s) mapped, ${unmapped.length} not mappable to SHACL Core.`,
...unmapped.map((entry) => `# unmapped: ${entry.constraint} (${entry.rule}) — ${entry.reason}`),
""
];
return {
turtle: `${[...header, ...body].join("\n").trimEnd()}\n`,
mapped,
unmapped
};
}
function shaclSeverity(severity: string): string {
switch (severity) {
case "warning":
return "sh:Warning";
case "info":
case "policy":
return "sh:Info";
default:
return "sh:Violation";
}
}
function toPascal(id: string): string {
return id
.split(/[-_\s]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}