logicsrc/packages/openontology/src/query.ts
Anthony Ettinger 58c942c67f
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
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>
2026-07-26 02:10:13 -07:00

367 lines
12 KiB
TypeScript

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;
}