mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
feat(openontology): implement OpenOntology Phase 0 + local engine and CLI (#99)
Implements OpenPRD 0001 through Phase 0 (specification, schemas, example, docs surface) and Phase 1 (local engine, CLI, conformance tests). Schemas (17 contracts, JSON Schema Draft 2020-12, additionalProperties:false) manifest, namespace, entity-type, property, relationship-type, constraint, query, action, entity, claim, source, evidence, changeset, review, approval, event, package — registered in @logicsrc/validators and exported from @logicsrc/schemas under https://logicsrc.com/schemas/openontology/. @logicsrc/openontology - canonical JSON + sha256 package digests; YAML, JSON, NDJSON, and inline authoring all compile to the same bytes, so digests are authoring-agnostic - id profile: compact / IRI / urn with one canonicalization rule, prefix bound by a Namespace object so IRIs reverse unambiguously - validation: schema, graph (domain/range, datatypes, dangling refs), provenance (source-or-firstParty, agent runId, derivation inputs), policy (excerpt limits, licensing, visibility, staleness) and declared constraints; four severities, stable codes, text/json/yaml/markdown - portable triple-pattern query AST: multi-hop, 14 operators, asOf and recordedAsOf, per-status filtering, distinct/order/limit, explanation mode, and enforced depth/binding/row limits - append-only store: claims are immutable; dispute/retract/supersede append status transitions and the effective status is the latest one - change sets: 9 operations, atomic pre-flight, conflict detection on stale base revisions, semantic diff with duplicate-identity warnings and affected-query deltas, per-operation reviewer decisions - policy: agents propose but can never apply — the denial keys on actor type, so every scope plus high confidence plus --yolo still cannot apply; merges need approval, bulk retractions need two, undeclared action side effects are denied - JSON-LD 1.1 export/import with PROV-O aliases and lossy-field reporting - pluggable signature envelope with a jws-ed25519 reference profile and a fail-closed trust policy CLI: logicsrc ontology init|validate|lint|build|inspect, entity, claim, query, changeset, import, export, audit. Reads take --format, writes default to a proposal, exit codes are stable for CI. Example: examples/openontology/ethereum-ecosystem — 12 entity types, 17 relationship types, 63 entities, 169 claims, 25 sources, 31 evidence records, 5 saved queries, every claim lifecycle state, and a pending merge proposal. All data is fictional; the directory is removable without affecting any core test. Docs: docs/openontology{,-governance,-interoperability}.md, a real /openontology route, homepage + nav + sitemap entries, and a root README section. Verification: 112 new tests; full monorepo build and every workspace test pass; conformance bundle (18 valid + 13 invalid fixtures) runs against the published schemas alone; Node.js 25 and Bun 1.3 produce byte-identical digests, revisions, event trails, and query results. Not included (later PRD phases): MCP resources, REST/SSE, Turso adapter, TUI and PWA surfaces, RDF/SHACL mappings, source adapters, governed actions. Refs: prd/0001-add-logicsrc-openontology-spec.md Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d9dab0447
commit
58c942c67f
101 changed files with 11934 additions and 10 deletions
275
packages/openontology/src/jsonld.ts
Normal file
275
packages/openontology/src/jsonld.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { toIri } from "./ids.js";
|
||||
import { OPENONTOLOGY_VERSION } from "./types.js";
|
||||
import type { BuiltPackage, Claim, Entity, LoadedPackage } from "./types.js";
|
||||
|
||||
export const OO = "https://logicsrc.com/ns/openontology#";
|
||||
export const PROV = "http://www.w3.org/ns/prov#";
|
||||
|
||||
/**
|
||||
* JSON-LD 1.1 context for the core model.
|
||||
*
|
||||
* Provenance terms deliberately alias W3C PROV-O where the semantics really
|
||||
* match (R67/R134) rather than inventing parallel vocabulary.
|
||||
*/
|
||||
export function buildContext(manifest: { namespace: string }): Record<string, unknown> {
|
||||
return {
|
||||
"@version": 1.1,
|
||||
oo: OO,
|
||||
prov: PROV,
|
||||
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
|
||||
xsd: "http://www.w3.org/2001/XMLSchema#",
|
||||
ns: manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`,
|
||||
id: "@id",
|
||||
type: "@type",
|
||||
label: { "@id": "rdfs:label" },
|
||||
alias: { "@id": "oo:alias", "@container": "@set" },
|
||||
externalId: { "@id": "oo:externalId", "@container": "@index" },
|
||||
status: { "@id": "oo:status" },
|
||||
Claim: "oo:Claim",
|
||||
subject: { "@id": "rdf:subject", "@type": "@id" },
|
||||
predicate: { "@id": "rdf:predicate", "@type": "@id" },
|
||||
object: { "@id": "rdf:object" },
|
||||
confidence: { "@id": "oo:confidence", "@type": "xsd:double" },
|
||||
validFrom: { "@id": "oo:validFrom", "@type": "xsd:dateTime" },
|
||||
validTo: { "@id": "oo:validTo", "@type": "xsd:dateTime" },
|
||||
observedAt: { "@id": "oo:observedAt", "@type": "xsd:dateTime" },
|
||||
assertedAt: { "@id": "prov:generatedAtTime", "@type": "xsd:dateTime" },
|
||||
assertedBy: { "@id": "prov:wasAttributedTo", "@type": "@id" },
|
||||
source: { "@id": "prov:wasDerivedFrom", "@type": "@id", "@container": "@set" },
|
||||
evidence: { "@id": "oo:evidence", "@type": "@id", "@container": "@set" },
|
||||
run: { "@id": "prov:wasGeneratedBy", "@type": "@id" },
|
||||
supersedes: { "@id": "oo:supersedes", "@type": "@id" },
|
||||
disputes: { "@id": "oo:disputes", "@type": "@id" },
|
||||
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields the JSON-LD profile represents losslessly. Anything else is reported. */
|
||||
const LOSSLESS_ENTITY_FIELDS = new Set([
|
||||
"openontology",
|
||||
"kind",
|
||||
"id",
|
||||
"type",
|
||||
"canonicalName",
|
||||
"labels",
|
||||
"aliases",
|
||||
"externalIds",
|
||||
"status",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"supersededBy"
|
||||
]);
|
||||
|
||||
const LOSSLESS_CLAIM_FIELDS = new Set([
|
||||
"openontology",
|
||||
"kind",
|
||||
"id",
|
||||
"subject",
|
||||
"predicate",
|
||||
"object",
|
||||
"status",
|
||||
"confidence",
|
||||
"validTime",
|
||||
"observedAt",
|
||||
"assertedAt",
|
||||
"assertedBy",
|
||||
"runId",
|
||||
"sources",
|
||||
"evidence",
|
||||
"supersedes",
|
||||
"disputes",
|
||||
"ontology"
|
||||
]);
|
||||
|
||||
export interface JsonLdExport {
|
||||
document: Record<string, unknown>;
|
||||
/** Fields dropped by this profile, reported rather than silently lost (R135). */
|
||||
lossy: Array<{ objectId: string; fields: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact prefix bound to this package's namespace.
|
||||
*
|
||||
* Canonicalizing `ethereum:person:alice` to an IRI drops the prefix, so
|
||||
* reversing an IRI back to a compact id needs the binding. A package declares
|
||||
* it via a Namespace object whose uri is the package namespace; otherwise the
|
||||
* package id is the prefix (the plain "package-qualified" reading).
|
||||
*/
|
||||
export function packagePrefix(pkg: BuiltPackage | LoadedPackage): string {
|
||||
const declared = pkg.schema.namespaces.find((ns) => ns.uri === pkg.manifest.namespace);
|
||||
return declared?.prefix ?? pkg.manifest.id;
|
||||
}
|
||||
|
||||
export function exportJsonLd(pkg: BuiltPackage | LoadedPackage): JsonLdExport {
|
||||
const manifest = pkg.manifest;
|
||||
const iri = (id: string) => toIri(id, { defaultNamespace: manifest.namespace });
|
||||
const lossy: JsonLdExport["lossy"] = [];
|
||||
|
||||
const graph: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const entity of pkg.data.entities) {
|
||||
const node: Record<string, unknown> = {
|
||||
id: iri(entity.id),
|
||||
type: iri(`${entityTypePrefix(manifest.id)}:${entity.type}`),
|
||||
label: entity.canonicalName,
|
||||
status: entity.status ?? "active",
|
||||
"prov:generatedAtTime": entity.createdAt,
|
||||
"prov:wasAttributedTo": entity.createdBy
|
||||
};
|
||||
if (entity.aliases?.length) node.alias = entity.aliases;
|
||||
if (entity.externalIds) node.externalId = entity.externalIds;
|
||||
if (entity.labels) node["rdfs:label"] = languageArray(entity.labels);
|
||||
if (entity.supersededBy) node["oo:supersededBy"] = { id: iri(entity.supersededBy) };
|
||||
|
||||
const extra = extraFields(entity as unknown as Record<string, unknown>, LOSSLESS_ENTITY_FIELDS);
|
||||
if (extra.length) lossy.push({ objectId: entity.id, fields: extra });
|
||||
|
||||
graph.push(node);
|
||||
}
|
||||
|
||||
for (const claim of pkg.data.claims) {
|
||||
const node: Record<string, unknown> = {
|
||||
id: iri(claim.id),
|
||||
type: "Claim",
|
||||
subject: iri(claim.subject),
|
||||
predicate: iri(`${entityTypePrefix(manifest.id)}:${claim.predicate}`),
|
||||
status: claim.status,
|
||||
assertedAt: claim.assertedAt,
|
||||
assertedBy: claim.assertedBy
|
||||
};
|
||||
|
||||
node.object =
|
||||
"entity" in claim.object
|
||||
? { id: iri(claim.object.entity) }
|
||||
: literal(claim.object);
|
||||
|
||||
if (claim.confidence !== undefined) node.confidence = claim.confidence;
|
||||
if (claim.validTime?.from) node.validFrom = claim.validTime.from;
|
||||
if (claim.validTime?.to) node.validTo = claim.validTime.to;
|
||||
if (claim.observedAt) node.observedAt = claim.observedAt;
|
||||
if (claim.runId) node.run = claim.runId;
|
||||
if (claim.sources?.length) node.source = claim.sources.map(iri);
|
||||
if (claim.evidence?.length) node.evidence = claim.evidence.map(iri);
|
||||
if (claim.supersedes) node.supersedes = iri(claim.supersedes);
|
||||
if (claim.disputes) node.disputes = iri(claim.disputes);
|
||||
|
||||
const extra = extraFields(claim as unknown as Record<string, unknown>, LOSSLESS_CLAIM_FIELDS);
|
||||
if (extra.length) lossy.push({ objectId: claim.id, fields: extra });
|
||||
|
||||
graph.push(node);
|
||||
}
|
||||
|
||||
for (const source of pkg.data.sources) {
|
||||
graph.push({
|
||||
id: iri(source.id),
|
||||
type: "prov:Entity",
|
||||
"oo:sourceType": source.sourceType,
|
||||
"oo:uri": source.uri,
|
||||
"prov:generatedAtTime": source.retrievedAt,
|
||||
...(source.license ? { "oo:license": source.license } : {}),
|
||||
...(source.contentHash ? { "oo:contentHash": source.contentHash } : {})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
"@context": pkg.context ?? buildContext(manifest),
|
||||
"@graph": graph
|
||||
},
|
||||
lossy
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the reified profile produced by `exportJsonLd`, so a package can make
|
||||
* the JSON → JSON-LD → JSON round trip the conformance suite asserts.
|
||||
*/
|
||||
export function importJsonLd(
|
||||
document: Record<string, unknown>,
|
||||
manifest: { id: string; namespace: string; version?: string; prefix?: string }
|
||||
): { entities: Entity[]; claims: Claim[] } {
|
||||
const base = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
|
||||
const prefix = manifest.prefix ?? manifest.id;
|
||||
const compact = (value: string): string => {
|
||||
if (!value.startsWith(base)) return value;
|
||||
const segments = value.slice(base.length).split("/").map(decodeURIComponent);
|
||||
return [prefix, ...segments].join(":");
|
||||
};
|
||||
|
||||
const graph = (document["@graph"] as Array<Record<string, unknown>>) ?? [];
|
||||
const entities: Entity[] = [];
|
||||
const claims: Claim[] = [];
|
||||
|
||||
for (const node of graph) {
|
||||
const type = node.type as string | undefined;
|
||||
const id = compact(String(node.id));
|
||||
|
||||
if (type === "Claim") {
|
||||
const object = node.object as Record<string, unknown>;
|
||||
const claim: Claim = {
|
||||
openontology: OPENONTOLOGY_VERSION,
|
||||
kind: "Claim",
|
||||
id,
|
||||
subject: compact(String(node.subject)),
|
||||
predicate: compact(String(node.predicate)).split(":").pop() as string,
|
||||
object:
|
||||
object && typeof object === "object" && "id" in object
|
||||
? { entity: compact(String(object.id)) }
|
||||
: { value: (object as { "@value"?: unknown })?.["@value"] ?? object },
|
||||
status: (node.status as Claim["status"]) ?? "asserted",
|
||||
assertedAt: String(node.assertedAt),
|
||||
assertedBy: String(node.assertedBy)
|
||||
};
|
||||
if (node.confidence !== undefined) claim.confidence = Number(node.confidence);
|
||||
if (node.validFrom || node.validTo) {
|
||||
claim.validTime = {
|
||||
...(node.validFrom ? { from: String(node.validFrom) } : {}),
|
||||
...(node.validTo ? { to: String(node.validTo) } : {})
|
||||
};
|
||||
}
|
||||
if (node.observedAt) claim.observedAt = String(node.observedAt);
|
||||
if (node.run) claim.runId = String(node.run);
|
||||
if (node.source) claim.sources = (node.source as string[]).map(compact);
|
||||
if (node.evidence) claim.evidence = (node.evidence as string[]).map(compact);
|
||||
if (node.supersedes) claim.supersedes = compact(String(node.supersedes));
|
||||
if (node.disputes) claim.disputes = compact(String(node.disputes));
|
||||
claims.push(claim);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "prov:Entity" || !type) continue;
|
||||
|
||||
const entity: Entity = {
|
||||
openontology: OPENONTOLOGY_VERSION,
|
||||
kind: "Entity",
|
||||
id,
|
||||
type: compact(String(type)).split(":").pop() as string,
|
||||
canonicalName: String(node.label ?? id),
|
||||
createdAt: String(node["prov:generatedAtTime"] ?? ""),
|
||||
createdBy: String(node["prov:wasAttributedTo"] ?? "")
|
||||
};
|
||||
if (node.alias) entity.aliases = node.alias as string[];
|
||||
if (node.externalId) entity.externalIds = node.externalId as Record<string, string>;
|
||||
if (node.status && node.status !== "active") entity.status = node.status as Entity["status"];
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
return { entities, claims };
|
||||
}
|
||||
|
||||
function literal(object: { value: unknown; datatype?: string; language?: string }): unknown {
|
||||
if (object.language) return { "@value": object.value, "@language": object.language };
|
||||
return object.value;
|
||||
}
|
||||
|
||||
function languageArray(labels: Record<string, string>): Array<{ "@value": string; "@language": string }> {
|
||||
return Object.entries(labels).map(([language, value]) => ({ "@value": value, "@language": language }));
|
||||
}
|
||||
|
||||
function extraFields(object: Record<string, unknown>, lossless: Set<string>): string[] {
|
||||
return Object.keys(object).filter((key) => !lossless.has(key) && object[key] !== undefined);
|
||||
}
|
||||
|
||||
function entityTypePrefix(packageId: string): string {
|
||||
return packageId;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue