mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +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
367
packages/openontology/src/query.ts
Normal file
367
packages/openontology/src/query.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { isVariable } from "./ids.js";
|
||||
import type {
|
||||
Claim,
|
||||
ClaimStatus,
|
||||
Entity,
|
||||
Property,
|
||||
QueryBody,
|
||||
QueryExplanation,
|
||||
QueryResult,
|
||||
QueryRow,
|
||||
RelationshipType,
|
||||
TriplePattern,
|
||||
WhereClause
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* The read model a query runs against. Storage adapters build one of these;
|
||||
* the evaluator never talks to a database directly, which is what keeps the
|
||||
* portable AST portable.
|
||||
*
|
||||
* `claims` MUST already carry each claim's *effective* status (the store
|
||||
* resolves the append-only status log before handing claims over).
|
||||
*/
|
||||
export interface KnowledgeView {
|
||||
entities: Map<string, Entity>;
|
||||
claims: Claim[];
|
||||
relationships: Map<string, RelationshipType>;
|
||||
properties: Map<string, Property>;
|
||||
/** Follows merge redirects so a query for an old id still finds the survivor. */
|
||||
resolveEntityId?: (id: string) => string;
|
||||
}
|
||||
|
||||
export interface QueryLimits {
|
||||
maxRows: number;
|
||||
maxDepth: number;
|
||||
maxBindings: number;
|
||||
maxMatchedClaims: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMITS: QueryLimits = {
|
||||
maxRows: 1000,
|
||||
maxDepth: 8,
|
||||
maxBindings: 50_000,
|
||||
maxMatchedClaims: 200_000
|
||||
};
|
||||
|
||||
const DEFAULT_STATUSES: ClaimStatus[] = ["asserted"];
|
||||
|
||||
type Binding = { vars: Record<string, unknown>; claims: string[] };
|
||||
|
||||
export class QueryLimitError extends Error {
|
||||
readonly code = "OO-Q-LIMIT";
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "QueryLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateQuery(
|
||||
view: KnowledgeView,
|
||||
query: QueryBody,
|
||||
limits: Partial<QueryLimits> = {}
|
||||
): QueryResult {
|
||||
const lim = { ...DEFAULT_LIMITS, ...limits };
|
||||
const statuses = query.include?.claimStatus ?? DEFAULT_STATUSES;
|
||||
const derivedIncluded = query.include?.derived ?? statuses.includes("derived");
|
||||
const visibilities = query.include?.visibility;
|
||||
|
||||
if (query.match.length > (query.maxDepth ?? lim.maxDepth)) {
|
||||
throw new QueryLimitError(
|
||||
`Query depth ${query.match.length} exceeds the maximum of ${query.maxDepth ?? lim.maxDepth}`
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = view.claims.filter((claim) => {
|
||||
if (!statuses.includes(claim.status)) return false;
|
||||
if (!derivedIncluded && claim.status === "derived") return false;
|
||||
if (visibilities && !visibilities.includes(claim.visibility ?? "public")) return false;
|
||||
if (query.asOf && !validAt(claim, query.asOf)) return false;
|
||||
if (query.recordedAsOf && claim.assertedAt > query.recordedAsOf) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length > lim.maxMatchedClaims) {
|
||||
throw new QueryLimitError(
|
||||
`Query would scan ${candidates.length} claims, above the ${lim.maxMatchedClaims} limit`
|
||||
);
|
||||
}
|
||||
|
||||
const patternTrace: QueryExplanation["patterns"] = [];
|
||||
let bindings: Binding[] = [{ vars: {}, claims: [] }];
|
||||
|
||||
for (const pattern of query.match) {
|
||||
const next: Binding[] = [];
|
||||
const matchedClaims = new Set<string>();
|
||||
|
||||
for (const binding of bindings) {
|
||||
let extended = false;
|
||||
|
||||
for (const claim of candidates) {
|
||||
const merged = unify(view, pattern, claim, binding);
|
||||
if (!merged) continue;
|
||||
matchedClaims.add(claim.id);
|
||||
next.push(merged);
|
||||
extended = true;
|
||||
if (next.length > lim.maxBindings) {
|
||||
throw new QueryLimitError(
|
||||
`Query produced more than ${lim.maxBindings} intermediate bindings; add filters or a limit`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONAL keeps the row alive with the pattern's variables unbound.
|
||||
if (!extended && pattern.optional) next.push(binding);
|
||||
}
|
||||
|
||||
patternTrace.push({ pattern, matchedClaims: [...matchedClaims], bindingsAfter: next.length });
|
||||
bindings = next;
|
||||
if (bindings.length === 0) break;
|
||||
}
|
||||
|
||||
const filters = query.where ?? [];
|
||||
for (const clause of filters) {
|
||||
bindings = bindings.filter((binding) => passesWhere(view, binding, clause));
|
||||
}
|
||||
|
||||
let rows: QueryRow[] = bindings.map((binding) => ({
|
||||
bindings: project(view, binding, query),
|
||||
claims: [...new Set(binding.claims)]
|
||||
}));
|
||||
|
||||
if (query.distinct) rows = distinctRows(rows);
|
||||
if (query.orderBy?.length) rows = sortRows(rows, query.orderBy);
|
||||
|
||||
const offset = query.offset ?? 0;
|
||||
const hardLimit = Math.min(query.limit ?? lim.maxRows, lim.maxRows);
|
||||
const truncated = rows.length - offset > hardLimit;
|
||||
rows = rows.slice(offset, offset + hardLimit);
|
||||
|
||||
const columns =
|
||||
query.select && query.select.length > 0 ? query.select.slice() : inferColumns(query.match);
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
explanation: {
|
||||
asOf: query.asOf,
|
||||
recordedAsOf: query.recordedAsOf,
|
||||
claimStatus: statuses,
|
||||
derivedIncluded,
|
||||
patterns: patternTrace,
|
||||
filters,
|
||||
truncated
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A claim is valid at `instant` unless its declared valid time excludes it. */
|
||||
export function validAt(claim: Claim, instant: string): boolean {
|
||||
const from = claim.validTime?.from;
|
||||
const to = claim.validTime?.to;
|
||||
if (from && from > instant) return false;
|
||||
if (to && to <= instant) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function unify(
|
||||
view: KnowledgeView,
|
||||
pattern: TriplePattern,
|
||||
claim: Claim,
|
||||
binding: Binding
|
||||
): Binding | null {
|
||||
const vars = { ...binding.vars };
|
||||
|
||||
if (!bindTerm(view, pattern.subject, claim.subject, vars)) return null;
|
||||
if (!bindTerm(view, pattern.predicate, claim.predicate, vars)) return null;
|
||||
|
||||
const objectTerm = pattern.object;
|
||||
if (typeof objectTerm === "string") {
|
||||
const actual = "entity" in claim.object ? claim.object.entity : claim.object.value;
|
||||
if (!bindTerm(view, objectTerm, actual, vars)) return null;
|
||||
} else if (objectTerm.variable) {
|
||||
const actual = "entity" in claim.object ? claim.object.entity : claim.object.value;
|
||||
if (!bindTerm(view, objectTerm.variable, actual, vars)) return null;
|
||||
} else if (objectTerm.entity !== undefined) {
|
||||
if (!("entity" in claim.object)) return null;
|
||||
if (resolve(view, claim.object.entity) !== resolve(view, objectTerm.entity)) return null;
|
||||
} else if (objectTerm.value !== undefined) {
|
||||
if ("entity" in claim.object) return null;
|
||||
if (!looseEqual(claim.object.value, objectTerm.value)) return null;
|
||||
}
|
||||
|
||||
if (pattern.bindClaim) {
|
||||
if (!bindTerm(view, pattern.bindClaim, claim.id, vars)) return null;
|
||||
}
|
||||
|
||||
return { vars, claims: [...binding.claims, claim.id] };
|
||||
}
|
||||
|
||||
function bindTerm(
|
||||
view: KnowledgeView,
|
||||
term: string,
|
||||
actual: unknown,
|
||||
vars: Record<string, unknown>
|
||||
): boolean {
|
||||
if (isVariable(term)) {
|
||||
const existing = vars[term];
|
||||
if (existing !== undefined) {
|
||||
return typeof existing === "string" && typeof actual === "string"
|
||||
? resolve(view, existing) === resolve(view, actual)
|
||||
: looseEqual(existing, actual);
|
||||
}
|
||||
vars[term] = actual;
|
||||
return true;
|
||||
}
|
||||
if (typeof actual === "string") return resolve(view, term) === resolve(view, actual);
|
||||
return looseEqual(term, actual);
|
||||
}
|
||||
|
||||
function resolve(view: KnowledgeView, id: string): string {
|
||||
return view.resolveEntityId ? view.resolveEntityId(id) : id;
|
||||
}
|
||||
|
||||
function looseEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a === "number" && typeof b === "number") return a === b;
|
||||
if (a instanceof Date || b instanceof Date) return String(a) === String(b);
|
||||
if (typeof a === "object" && typeof b === "object" && a && b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `?var.field` for a WHERE clause. Entity fields win over property
|
||||
* claims so `status`/`type`/`canonicalName` always mean the entity's own
|
||||
* metadata; anything else falls back to a scalar property claim.
|
||||
*/
|
||||
function fieldValue(view: KnowledgeView, bound: unknown, field?: string): unknown {
|
||||
if (!field) return bound;
|
||||
if (typeof bound !== "string") return undefined;
|
||||
|
||||
const entity = view.entities.get(resolve(view, bound));
|
||||
if (entity) {
|
||||
if (field in entity) return (entity as unknown as Record<string, unknown>)[field];
|
||||
if (entity.externalIds && field in entity.externalIds) return entity.externalIds[field];
|
||||
}
|
||||
|
||||
const claim = view.claims.find(
|
||||
(c) => c.subject === bound && c.predicate === field && !("entity" in c.object)
|
||||
);
|
||||
if (claim && !("entity" in claim.object)) return claim.object.value;
|
||||
|
||||
const rel = view.claims.find((c) => c.subject === bound && c.predicate === field);
|
||||
if (rel && "entity" in rel.object) return rel.object.entity;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function passesWhere(view: KnowledgeView, binding: Binding, clause: WhereClause): boolean {
|
||||
const bound = binding.vars[clause.variable];
|
||||
const actual = fieldValue(view, bound, clause.field);
|
||||
const expected = clause.value;
|
||||
|
||||
switch (clause.operator) {
|
||||
case "eq":
|
||||
return looseEqual(actual, expected);
|
||||
case "neq":
|
||||
return !looseEqual(actual, expected);
|
||||
case "lt":
|
||||
return compare(actual, expected) < 0;
|
||||
case "lte":
|
||||
return compare(actual, expected) <= 0;
|
||||
case "gt":
|
||||
return compare(actual, expected) > 0;
|
||||
case "gte":
|
||||
return compare(actual, expected) >= 0;
|
||||
case "before":
|
||||
return String(actual) < String(expected);
|
||||
case "after":
|
||||
return String(actual) > String(expected);
|
||||
case "in":
|
||||
return Array.isArray(expected) && expected.some((v) => looseEqual(actual, v));
|
||||
case "not-in":
|
||||
return Array.isArray(expected) && !expected.some((v) => looseEqual(actual, v));
|
||||
case "exists":
|
||||
return actual !== undefined && actual !== null;
|
||||
case "not-exists":
|
||||
return actual === undefined || actual === null;
|
||||
case "contains":
|
||||
return Array.isArray(actual)
|
||||
? actual.some((v) => looseEqual(v, expected))
|
||||
: String(actual ?? "").includes(String(expected ?? ""));
|
||||
case "starts-with":
|
||||
return String(actual ?? "").startsWith(String(expected ?? ""));
|
||||
case "matches":
|
||||
return new RegExp(String(expected ?? "")).test(String(actual ?? ""));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: unknown, b: unknown): number {
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
const sa = String(a ?? "");
|
||||
const sb = String(b ?? "");
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
function project(view: KnowledgeView, binding: Binding, query: QueryBody): Record<string, unknown> {
|
||||
const select = query.select && query.select.length > 0 ? query.select : Object.keys(binding.vars);
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
for (const term of select) {
|
||||
const value = binding.vars[term];
|
||||
out[term] = value;
|
||||
|
||||
if (typeof value !== "string") continue;
|
||||
const entity = view.entities.get(resolve(view, value));
|
||||
if (!entity) continue;
|
||||
|
||||
if (query.include?.labels) out[`${term}.label`] = entity.canonicalName;
|
||||
for (const prop of query.include?.properties ?? []) {
|
||||
out[`${term}.${prop}`] = fieldValue(view, value, prop);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function distinctRows(rows: QueryRow[]): QueryRow[] {
|
||||
const seen = new Set<string>();
|
||||
const out: QueryRow[] = [];
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify(row.bindings);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sortRows(rows: QueryRow[], orderBy: NonNullable<QueryBody["orderBy"]>): QueryRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
for (const clause of orderBy) {
|
||||
const key = clause.field ? `${clause.variable}.${clause.field}` : clause.variable;
|
||||
const cmp = compare(left.bindings[key], right.bindings[key]);
|
||||
if (cmp !== 0) return clause.direction === "desc" ? -cmp : cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function inferColumns(match: TriplePattern[]): string[] {
|
||||
const columns: string[] = [];
|
||||
const add = (term: unknown) => {
|
||||
if (typeof term === "string" && isVariable(term) && !columns.includes(term)) columns.push(term);
|
||||
};
|
||||
for (const pattern of match) {
|
||||
add(pattern.subject);
|
||||
add(pattern.predicate);
|
||||
if (typeof pattern.object === "string") add(pattern.object);
|
||||
else if (pattern.object.variable) add(pattern.object.variable);
|
||||
add(pattern.bindClaim);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue