logicsrc/packages/openontology/src/rdf.test.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

114 lines
4.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { exportTurtle, importTurtle } from "./rdf.js";
import { constraintsToShacl } from "./shacl.js";
import { packagePrefix } from "./jsonld.js";
import { loadPrdFixturePackage } from "./test-helpers.js";
const pkg = loadPrdFixturePackage();
describe("RDF/Turtle", () => {
it("declares the prefixes it uses", () => {
const { turtle } = exportTurtle(pkg);
for (const prefix of ["oo:", "prov:", "rdf:", "rdfs:", "xsd:"]) {
expect(turtle).toContain(`@prefix ${prefix}`);
}
});
it("reifies claims so provenance survives the crossing", () => {
const { turtle } = exportTurtle(pkg);
expect(turtle).toContain("a oo:Claim");
expect(turtle).toContain("rdf:subject");
expect(turtle).toContain("prov:wasDerivedFrom");
expect(turtle).toContain("prov:wasAttributedTo");
});
it("also emits the plain triple for asserted relationships", () => {
const { turtle } = exportTurtle(pkg);
const worksOn = turtle.split("\n").filter((line) => line.includes("worksOn"));
// Reified (rdf:predicate) plus at least one direct triple.
expect(worksOn.some((line) => line.includes("rdf:predicate"))).toBe(true);
expect(worksOn.some((line) => !line.includes("rdf:predicate"))).toBe(true);
});
it("round-trips entities and claims through Turtle", () => {
const { turtle } = exportTurtle(pkg);
const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) });
expect(back.entities.map((e) => e.id).sort()).toEqual(pkg.data.entities.map((e) => e.id).sort());
expect(back.claims.map((c) => c.id).sort()).toEqual(pkg.data.claims.map((c) => c.id).sort());
expect(back.unsupported).toEqual([]);
const original = pkg.data.claims[0]!;
const restored = back.claims.find((c) => c.id === original.id)!;
expect(restored.subject).toBe(original.subject);
expect(restored.predicate).toBe(original.predicate);
expect(restored.object).toEqual(original.object);
expect(restored.sources).toEqual(original.sources);
expect(restored.confidence).toBe(original.confidence);
expect(restored.validTime?.from).toBe(original.validTime?.from);
});
it("preserves a scalar property claim's value and type", () => {
const { turtle } = exportTurtle(pkg);
const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) });
const homepage = back.claims.find((c) => c.predicate === "homepage")!;
expect(homepage.object).toEqual({ value: "https://example.org/zk-prover" });
});
it("reports fields the profile cannot carry", () => {
const copy = structuredClone(pkg);
copy.data.claims[0]!.tags = ["zk"];
copy.data.claims[0]!.license = "CC-BY-4.0";
const { lossy } = exportTurtle(copy);
const entry = lossy.find((l) => l.objectId === copy.data.claims[0]!.id);
expect(entry?.fields).toEqual(expect.arrayContaining(["tags", "license"]));
});
it("counts what crossed the boundary", () => {
const { counts } = exportTurtle(pkg);
expect(counts.entities).toBe(pkg.data.entities.length);
expect(counts.claims).toBe(pkg.data.claims.length);
expect(counts.triples).toBeGreaterThan(counts.claims);
});
});
describe("SHACL", () => {
it("maps a required-predicate constraint to a node shape", () => {
const { turtle, mapped } = constraintsToShacl(pkg);
expect(mapped.map((m) => m.constraint)).toContain("project-has-homepage");
expect(turtle).toContain("sh:NodeShape");
expect(turtle).toContain("sh:targetClass ns:Project");
expect(turtle).toContain("sh:minCount 1");
});
it("carries the constraint severity across", () => {
const { turtle } = constraintsToShacl(pkg);
expect(turtle).toContain("sh:Warning");
});
it("reports uniqueness and query constraints as unmappable rather than faking them", () => {
const copy = structuredClone(pkg);
copy.schema.constraints.push(
{
openontology: "0.1",
kind: "Constraint",
id: "unique-homepage",
description: "Homepages are unique.",
rule: { type: "unique", predicate: "homepage" }
},
{
openontology: "0.1",
kind: "Constraint",
id: "needs-review",
description: "No claims awaiting review.",
rule: { type: "query", query: "contributors" }
}
);
const { unmapped, turtle } = constraintsToShacl(copy);
expect(unmapped.map((u) => u.constraint)).toEqual(["unique-homepage", "needs-review"]);
expect(unmapped[0]!.reason).toMatch(/SPARQLConstraint/);
// The gap is stated in the output itself, not just in a return value.
expect(turtle).toContain("# unmapped: unique-homepage");
});
});