mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
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>
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import { toIri } from "./ids.js";
|
|
import { packagePrefix, OO, PROV } from "./jsonld.js";
|
|
import { OPENONTOLOGY_VERSION } from "./types.js";
|
|
import type { BuiltPackage, Claim, Entity, LoadedPackage } from "./types.js";
|
|
|
|
/**
|
|
* RDF/Turtle export and import for the losslessly mappable core subset.
|
|
*
|
|
* Claims are reified — each is its own resource carrying subject, predicate,
|
|
* object, status, time, confidence, and provenance — because a bare triple
|
|
* cannot say "asserted by this agent, from this commit, valid since April."
|
|
* Asserted relationship claims additionally emit the plain triple, so a
|
|
* consumer that only wants the current graph gets one.
|
|
*/
|
|
|
|
const PREFIXES: Array<[string, string]> = [
|
|
["oo", OO],
|
|
["prov", PROV],
|
|
["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#"]
|
|
];
|
|
|
|
/** Fields this profile carries. Everything else is reported as lossy. */
|
|
const LOSSLESS_CLAIM_FIELDS = new Set([
|
|
"openontology",
|
|
"kind",
|
|
"id",
|
|
"ontology",
|
|
"subject",
|
|
"predicate",
|
|
"object",
|
|
"status",
|
|
"confidence",
|
|
"validTime",
|
|
"observedAt",
|
|
"assertedAt",
|
|
"assertedBy",
|
|
"runId",
|
|
"sources",
|
|
"evidence",
|
|
"supersedes",
|
|
"disputes"
|
|
]);
|
|
|
|
const LOSSLESS_ENTITY_FIELDS = new Set([
|
|
"openontology",
|
|
"kind",
|
|
"id",
|
|
"type",
|
|
"canonicalName",
|
|
"aliases",
|
|
"externalIds",
|
|
"status",
|
|
"createdAt",
|
|
"createdBy",
|
|
"supersededBy"
|
|
]);
|
|
|
|
export interface TurtleExport {
|
|
turtle: string;
|
|
lossy: Array<{ objectId: string; fields: string[] }>;
|
|
/** Counts, so a caller can report what actually crossed the boundary. */
|
|
counts: { entities: number; claims: number; sources: number; triples: number };
|
|
}
|
|
|
|
export function exportTurtle(pkg: BuiltPackage | LoadedPackage): TurtleExport {
|
|
const manifest = pkg.manifest;
|
|
const prefix = packagePrefix(pkg);
|
|
const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
|
|
const iri = (id: string) => `<${toIri(id, { defaultNamespace: manifest.namespace })}>`;
|
|
const vocab = (term: string) => `<${ns}${encodeURIComponent(term)}>`;
|
|
|
|
const lossy: TurtleExport["lossy"] = [];
|
|
const lines: string[] = [];
|
|
let triples = 0;
|
|
|
|
const emit = (subject: string, pairs: Array<[string, string]>) => {
|
|
if (pairs.length === 0) return;
|
|
lines.push(`${subject}`);
|
|
pairs.forEach(([predicate, object], index) => {
|
|
triples += 1;
|
|
lines.push(` ${predicate} ${object}${index === pairs.length - 1 ? " ." : " ;"}`);
|
|
});
|
|
lines.push("");
|
|
};
|
|
|
|
for (const [name, uri] of PREFIXES) lines.push(`@prefix ${name}: <${uri}> .`);
|
|
lines.push(`@prefix ns: <${ns}> .`);
|
|
lines.push(`@prefix pkg: <${ns}> .`);
|
|
lines.push("");
|
|
lines.push(`# LogicSRC OpenOntology ${OPENONTOLOGY_VERSION} — ${manifest.id}@${manifest.version}`);
|
|
lines.push(`# compact id prefix: ${prefix}`);
|
|
lines.push("");
|
|
|
|
for (const entity of pkg.data.entities) {
|
|
const pairs: Array<[string, string]> = [
|
|
["a", vocab(entity.type)],
|
|
["rdfs:label", literal(entity.canonicalName)],
|
|
["oo:status", literal(entity.status ?? "active")],
|
|
["prov:generatedAtTime", typed(entity.createdAt, "xsd:dateTime")],
|
|
["prov:wasAttributedTo", literal(entity.createdBy)]
|
|
];
|
|
for (const alias of entity.aliases ?? []) pairs.push(["oo:alias", literal(alias)]);
|
|
for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) {
|
|
pairs.push(["oo:externalId", literal(`${namespace}:${value}`)]);
|
|
}
|
|
if (entity.supersededBy) pairs.push(["oo:supersededBy", iri(entity.supersededBy)]);
|
|
|
|
emit(iri(entity.id), pairs);
|
|
|
|
const extra = extraFields(entity as unknown as Record<string, unknown>, LOSSLESS_ENTITY_FIELDS);
|
|
if (extra.length) lossy.push({ objectId: entity.id, fields: extra });
|
|
}
|
|
|
|
for (const claim of pkg.data.claims) {
|
|
const objectTerm =
|
|
"entity" in claim.object ? iri(claim.object.entity) : valueTerm(claim.object);
|
|
|
|
const pairs: Array<[string, string]> = [
|
|
["a", "oo:Claim"],
|
|
["rdf:subject", iri(claim.subject)],
|
|
["rdf:predicate", vocab(claim.predicate)],
|
|
["rdf:object", objectTerm],
|
|
["oo:status", literal(claim.status)],
|
|
["prov:generatedAtTime", typed(claim.assertedAt, "xsd:dateTime")],
|
|
["prov:wasAttributedTo", literal(claim.assertedBy)]
|
|
];
|
|
|
|
if (claim.confidence !== undefined) pairs.push(["oo:confidence", typed(String(claim.confidence), "xsd:double")]);
|
|
if (claim.validTime?.from) pairs.push(["oo:validFrom", typed(claim.validTime.from, "xsd:dateTime")]);
|
|
if (claim.validTime?.to) pairs.push(["oo:validTo", typed(claim.validTime.to, "xsd:dateTime")]);
|
|
if (claim.observedAt) pairs.push(["oo:observedAt", typed(claim.observedAt, "xsd:dateTime")]);
|
|
if (claim.runId) pairs.push(["prov:wasGeneratedBy", literal(claim.runId)]);
|
|
for (const source of claim.sources ?? []) pairs.push(["prov:wasDerivedFrom", iri(source)]);
|
|
for (const record of claim.evidence ?? []) pairs.push(["oo:evidence", iri(record)]);
|
|
if (claim.supersedes) pairs.push(["oo:supersedes", iri(claim.supersedes)]);
|
|
if (claim.disputes) pairs.push(["oo:disputes", iri(claim.disputes)]);
|
|
|
|
emit(iri(claim.id), pairs);
|
|
|
|
// The plain triple, for consumers that only want the accepted graph.
|
|
if (claim.status === "asserted" && "entity" in claim.object) {
|
|
emit(iri(claim.subject), [[vocab(claim.predicate), iri(claim.object.entity)]]);
|
|
}
|
|
|
|
const extra = extraFields(claim as unknown as Record<string, unknown>, LOSSLESS_CLAIM_FIELDS);
|
|
if (extra.length) lossy.push({ objectId: claim.id, fields: extra });
|
|
}
|
|
|
|
for (const source of pkg.data.sources) {
|
|
emit(iri(source.id), [
|
|
["a", "prov:Entity"],
|
|
["oo:sourceType", literal(source.sourceType)],
|
|
["oo:uri", literal(source.uri)],
|
|
["prov:generatedAtTime", typed(source.retrievedAt, "xsd:dateTime")],
|
|
...(source.license ? ([["oo:license", literal(source.license)]] as Array<[string, string]>) : []),
|
|
...(source.contentHash ? ([["oo:contentHash", literal(source.contentHash)]] as Array<[string, string]>) : [])
|
|
]);
|
|
}
|
|
|
|
return {
|
|
turtle: `${lines.join("\n").trimEnd()}\n`,
|
|
lossy,
|
|
counts: {
|
|
entities: pkg.data.entities.length,
|
|
claims: pkg.data.claims.length,
|
|
sources: pkg.data.sources.length,
|
|
triples
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Import the profile `exportTurtle` produces.
|
|
*
|
|
* This is deliberately a parser for that profile, not a general Turtle parser:
|
|
* it reads the reified claim shape and the entity shape, and ignores plain
|
|
* triples (which are redundant with the claims). Anything it cannot interpret
|
|
* is reported rather than silently dropped.
|
|
*/
|
|
export function importTurtle(
|
|
turtle: string,
|
|
manifest: { id: string; namespace: string; prefix?: string }
|
|
): { entities: Entity[]; claims: Claim[]; unsupported: string[] } {
|
|
const base = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
|
|
const prefix = manifest.prefix ?? manifest.id;
|
|
|
|
const compact = (value: string): string => {
|
|
const trimmed = value.replace(/^<|>$/g, "");
|
|
if (!trimmed.startsWith(base)) return trimmed;
|
|
return [prefix, ...trimmed.slice(base.length).split("/").map(decodeURIComponent)].join(":");
|
|
};
|
|
const term = (value: string): string => {
|
|
const trimmed = value.replace(/^<|>$/g, "");
|
|
if (!trimmed.startsWith(base)) return trimmed;
|
|
return decodeURIComponent(trimmed.slice(base.length));
|
|
};
|
|
|
|
const entities: Entity[] = [];
|
|
const claims: Claim[] = [];
|
|
const unsupported: string[] = [];
|
|
|
|
for (const block of splitBlocks(turtle)) {
|
|
const pairs = block.pairs;
|
|
const type = pairs.find(([p]) => p === "a" || p === "rdf:type")?.[1];
|
|
|
|
if (type === "oo:Claim") {
|
|
const objectTerm = pairs.find(([p]) => p === "rdf:object")?.[1] ?? "";
|
|
const claim: Claim = {
|
|
openontology: OPENONTOLOGY_VERSION,
|
|
kind: "Claim",
|
|
id: compact(block.subject),
|
|
subject: compact(pairs.find(([p]) => p === "rdf:subject")?.[1] ?? ""),
|
|
predicate: term(pairs.find(([p]) => p === "rdf:predicate")?.[1] ?? ""),
|
|
object: objectTerm.startsWith("<")
|
|
? { entity: compact(objectTerm) }
|
|
: { value: parseLiteral(objectTerm) },
|
|
status: (unquote(pairs.find(([p]) => p === "oo:status")?.[1] ?? '"asserted"') as Claim["status"]) ?? "asserted",
|
|
assertedAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'),
|
|
assertedBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""')
|
|
};
|
|
|
|
const confidence = pairs.find(([p]) => p === "oo:confidence")?.[1];
|
|
if (confidence) claim.confidence = Number(unquote(confidence));
|
|
const from = pairs.find(([p]) => p === "oo:validFrom")?.[1];
|
|
const to = pairs.find(([p]) => p === "oo:validTo")?.[1];
|
|
if (from || to) {
|
|
claim.validTime = {
|
|
...(from ? { from: unquote(from) } : {}),
|
|
...(to ? { to: unquote(to) } : {})
|
|
};
|
|
}
|
|
const observed = pairs.find(([p]) => p === "oo:observedAt")?.[1];
|
|
if (observed) claim.observedAt = unquote(observed);
|
|
const run = pairs.find(([p]) => p === "prov:wasGeneratedBy")?.[1];
|
|
if (run) claim.runId = unquote(run);
|
|
|
|
const sources = pairs.filter(([p]) => p === "prov:wasDerivedFrom").map(([, o]) => compact(o));
|
|
if (sources.length) claim.sources = sources;
|
|
const evidence = pairs.filter(([p]) => p === "oo:evidence").map(([, o]) => compact(o));
|
|
if (evidence.length) claim.evidence = evidence;
|
|
const supersedes = pairs.find(([p]) => p === "oo:supersedes")?.[1];
|
|
if (supersedes) claim.supersedes = compact(supersedes);
|
|
const disputes = pairs.find(([p]) => p === "oo:disputes")?.[1];
|
|
if (disputes) claim.disputes = compact(disputes);
|
|
|
|
claims.push(claim);
|
|
continue;
|
|
}
|
|
|
|
if (type === "prov:Entity" || !type) continue;
|
|
|
|
if (!type.startsWith("<")) {
|
|
unsupported.push(`${block.subject} has unrecognized type ${type}`);
|
|
continue;
|
|
}
|
|
|
|
const entity: Entity = {
|
|
openontology: OPENONTOLOGY_VERSION,
|
|
kind: "Entity",
|
|
id: compact(block.subject),
|
|
type: term(type),
|
|
canonicalName: unquote(pairs.find(([p]) => p === "rdfs:label")?.[1] ?? '""'),
|
|
createdAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'),
|
|
createdBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""')
|
|
};
|
|
|
|
const aliases = pairs.filter(([p]) => p === "oo:alias").map(([, o]) => unquote(o));
|
|
if (aliases.length) entity.aliases = aliases;
|
|
|
|
const externalIds = pairs.filter(([p]) => p === "oo:externalId").map(([, o]) => unquote(o));
|
|
if (externalIds.length) {
|
|
entity.externalIds = Object.fromEntries(
|
|
externalIds.map((pair) => {
|
|
const at = pair.indexOf(":");
|
|
return [pair.slice(0, at), pair.slice(at + 1)];
|
|
})
|
|
);
|
|
}
|
|
|
|
const status = pairs.find(([p]) => p === "oo:status")?.[1];
|
|
if (status && unquote(status) !== "active") entity.status = unquote(status) as Entity["status"];
|
|
const supersededBy = pairs.find(([p]) => p === "oo:supersededBy")?.[1];
|
|
if (supersededBy) entity.supersededBy = compact(supersededBy);
|
|
|
|
entities.push(entity);
|
|
}
|
|
|
|
return { entities, claims, unsupported };
|
|
}
|
|
|
|
interface Block {
|
|
subject: string;
|
|
pairs: Array<[string, string]>;
|
|
}
|
|
|
|
/** Split the profile's `subject\n predicate object ;\n … .` blocks. */
|
|
function splitBlocks(turtle: string): Block[] {
|
|
const blocks: Block[] = [];
|
|
let current: Block | null = null;
|
|
|
|
for (const raw of turtle.split("\n")) {
|
|
const line = raw.trim();
|
|
if (!line || line.startsWith("#") || line.startsWith("@prefix")) continue;
|
|
|
|
if (!raw.startsWith(" ")) {
|
|
if (current) blocks.push(current);
|
|
current = { subject: line.replace(/\s*[;.]$/, ""), pairs: [] };
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
|
|
const body = line.replace(/\s*[;.]$/, "");
|
|
const space = body.indexOf(" ");
|
|
if (space < 0) continue;
|
|
current.pairs.push([body.slice(0, space), body.slice(space + 1).trim()]);
|
|
}
|
|
|
|
if (current) blocks.push(current);
|
|
// Plain triples re-state an asserted claim, so drop those single-pair blocks.
|
|
return blocks.filter((block) => block.pairs.some(([p]) => p === "a" || p === "rdf:type"));
|
|
}
|
|
|
|
function literal(value: string): string {
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
function typed(value: string, datatype: string): string {
|
|
return `${JSON.stringify(value)}^^${datatype}`;
|
|
}
|
|
|
|
function valueTerm(object: { value: unknown; language?: string }): string {
|
|
if (object.language) return `${JSON.stringify(String(object.value))}@${object.language}`;
|
|
if (typeof object.value === "number") return `${JSON.stringify(String(object.value))}^^xsd:double`;
|
|
if (typeof object.value === "boolean") return `${JSON.stringify(String(object.value))}^^xsd:boolean`;
|
|
return literal(String(object.value));
|
|
}
|
|
|
|
function unquote(term: string): string {
|
|
const match = /^"((?:[^"\\]|\\.)*)"/.exec(term.trim());
|
|
if (!match) return term.trim();
|
|
return JSON.parse(`"${match[1]}"`) as string;
|
|
}
|
|
|
|
function parseLiteral(term: string): unknown {
|
|
const text = unquote(term);
|
|
if (term.includes("^^xsd:double") || term.includes("^^xsd:integer")) return Number(text);
|
|
if (term.includes("^^xsd:boolean")) return text === "true";
|
|
return text;
|
|
}
|
|
|
|
function extraFields(object: Record<string, unknown>, lossless: Set<string>): string[] {
|
|
return Object.keys(object).filter((key) => !lossless.has(key) && object[key] !== undefined);
|
|
}
|