diff --git a/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl b/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl new file mode 100644 index 0000000..cfdbbed --- /dev/null +++ b/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl @@ -0,0 +1,41 @@ +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix ex: . + +ex:A a owl:Class ; + owl:equivalentClass ex:B . + +ex:EqAnd a owl:Class ; + owl:equivalentClass [ + owl:intersectionOf ( ex:B ex:C ) + ] . + +ex:SubOr a owl:Class ; + rdfs:subClassOf [ + owl:unionOf ( ex:B ex:C ex:D ) + ] . + +ex:SubNot a owl:Class ; + rdfs:subClassOf [ + owl:complementOf ex:B + ] . + +ex:Nested a owl:Class ; + owl:equivalentClass [ + owl:unionOf ( + [ owl:intersectionOf ( ex:B ex:C ) ] + [ owl:complementOf ex:D ] + ) + ] . + +ex:Broken a owl:Class ; + owl:equivalentClass _:brokenExpr . + +_:brokenExpr owl:unionOf _:loop . +_:loop rdf:first ex:B ; + rdf:rest _:loop . + +ex:B a owl:Class . +ex:C a owl:Class . +ex:D a owl:Class . diff --git a/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx b/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx index c8d7099..31ed98c 100644 --- a/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx +++ b/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx @@ -1,6 +1,13 @@ -import type { DatatypeProperty, OntologyClass, Restriction } from '@renderer/model/types'; +import type { + ClassExpression, + ClassExpressionAssertion, + DatatypeProperty, + OntologyClass, + Restriction, +} from '@renderer/model/types'; import { useOntologyStore } from '@renderer/store/ontology'; import { useUIStore } from '@renderer/store/ui'; +import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -44,6 +51,11 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { const updateDatatypeProperty = useOntologyStore((s) => s.updateDatatypeProperty); const ontology = useOntologyStore((s) => s.ontology); const setFocusNode = useUIStore((s) => s.setFocusNode); + const setSelectedNode = useUIStore((s) => s.setSelectedNode); + const focusClass = (uri: string): void => { + setSelectedNode(uri); + setFocusNode(uri); + }; const dtProps = Array.from(ontology.datatypeProperties.values()).filter((p) => p.domain.includes(cls.uri), @@ -92,7 +104,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={uri} className="text-xs bg-secondary rounded px-2 py-1 cursor-pointer hover:bg-accent transition-colors flex items-center gap-1.5 group" - onClick={() => setFocusNode(uri)} + onClick={() => focusClass(uri)} > {ontology.classes.get(uri)?.label || localName(uri)} @@ -136,7 +148,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={r} className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary cursor-pointer transition-colors" - onClick={() => setFocusNode(r)} + onClick={() => focusClass(r)} > {ontology.classes.get(r)?.label || localName(r)} @@ -151,7 +163,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={d} className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary cursor-pointer transition-colors" - onClick={() => setFocusNode(d)} + onClick={() => focusClass(d)} > {ontology.classes.get(d)?.label || localName(d)} @@ -173,12 +185,42 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { key={`${r.onProperty}-${r.type}-${r.value}`} restriction={r} ontology={ontology} - onFocus={setFocusNode} + onFocus={focusClass} /> ))} )} + + {cls.classExpressions && cls.classExpressions.length > 0 && ( +
+
+
Class Expressions
+
Logical definitions for this class
+
+
+ {(() => { + const seen = new Map(); + return cls.classExpressions.map((assertion) => { + const base = `${assertion.source}:${classExpressionKey(assertion.expression)}`; + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return ( + + ); + }); + })()} +
+
+ EQUIV = equivalent class · OR = union · AND = intersection · NOT = complement +
+
+ )} ); } @@ -280,6 +322,136 @@ function formatRestrictionLabel(r: Restriction): { text: string; targetUri?: str } } +function ClassExpressionRow({ + assertion, + ontology, + onFocus, +}: { + assertion: ClassExpressionAssertion; + ontology: import('@renderer/model/types').Ontology; + onFocus: (uri: string) => void; +}): React.JSX.Element { + return ( +
+
+ {assertion.source === 'equivalentClass' ? ( +
+ + EQUIV + + +
+ ) : ( + + )} +
+
+ ); +} + +function ClassExpressionTree({ + expression, + ontology, + onFocus, +}: { + expression: ClassExpression; + ontology: import('@renderer/model/types').Ontology; + onFocus: (uri: string) => void; +}): React.JSX.Element { + if (expression.kind === 'named') { + const label = ontology.classes.get(expression.uri)?.label || localName(expression.uri); + return ( + + ); + } + + if (expression.kind === 'unknown') { + return ( +
+ + Warning + + {expression.reason} +
+ ); + } + + if (expression.kind === 'complement') { + return ( +
+ + NOT + +
+ +
+
+ ); + } + + const operator = expression.kind === 'union' ? 'OR' : 'AND'; + return ( +
+ + {operator} + +
+ {(() => { + const seen = new Map(); + return expression.operands.map((operand) => { + const base = classExpressionKey(operand); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return ( + + ); + }); + })()} +
+
+ ); +} + +function classExpressionKey(expression: ClassExpression): string { + switch (expression.kind) { + case 'named': + return `named:${expression.uri}`; + case 'union': + return `union(${expression.operands.map(classExpressionKey).join('|')})`; + case 'intersection': + return `intersection(${expression.operands.map(classExpressionKey).join('|')})`; + case 'complement': + return `complement(${classExpressionKey(expression.operand)})`; + case 'unknown': + return `unknown:${expression.reason}`; + default: + return expression satisfies never; + } +} + function RestrictionPill({ restriction, ontology, diff --git a/apps/desktop/src/renderer/src/model/quads.ts b/apps/desktop/src/renderer/src/model/quads.ts index a0229d2..b8a3306 100644 --- a/apps/desktop/src/renderer/src/model/quads.ts +++ b/apps/desktop/src/renderer/src/model/quads.ts @@ -1,6 +1,8 @@ import type { Quad } from 'n3'; import type { AnnotationProperty, + ClassExpression, + ClassExpressionAssertion, DatatypeProperty, Individual, ObjectProperty, @@ -93,6 +95,11 @@ function getOrCreateAnnotationProperty(ontology: Ontology, uri: string): Annotat return prop; } +function localName(uri: string): string { + const idx = Math.max(uri.lastIndexOf('#'), uri.lastIndexOf('/')); + return idx >= 0 ? uri.substring(idx + 1) : uri; +} + export interface ParseWarning { message: string; severity: 'error' | 'warning'; @@ -118,6 +125,7 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu // Collect OWL characteristic type tokens per URI (order-independent) const pendingCharacteristics = new Map(); + const quadsBySubject = new Map(); // First pass: collect type declarations for (const quad of quads) { @@ -125,6 +133,10 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu const p = quad.predicate.value; const o = quad.object.value; + const bySubject = quadsBySubject.get(s); + if (bySubject) bySubject.push(quad); + else quadsBySubject.set(s, [quad]); + if (p === `${RDF}type`) { if (!declaredTypes.has(s)) declaredTypes.set(s, new Set()); declaredTypes.get(s)?.add(o); @@ -135,7 +147,7 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu pendingCharacteristics.get(s)?.push(charToken); } - if (o === `${OWL}Class`) { + if (o === `${OWL}Class` && quad.subject.termType === 'NamedNode') { getOrCreateClass(ontology, s); } else if (o === `${OWL}ObjectProperty`) { getOrCreateObjectProperty(ontology, s); @@ -223,6 +235,123 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu } } + const expressionCache = new Map(); + + function pushWarning(message: string): void { + warnings.push({ severity: 'warning', message }); + } + + function ensureClassExpression(cls: OntologyClass, assertion: ClassExpressionAssertion): void { + if (!cls.classExpressions) cls.classExpressions = []; + cls.classExpressions.push(assertion); + } + + function parseExpressionTerm( + term: Quad['object'], + trace: Set, + context: string, + ): ClassExpression | null { + if (term.termType === 'NamedNode') { + return { kind: 'named', uri: term.value }; + } + if (term.termType !== 'BlankNode') { + pushWarning(`Malformed class expression for ${context}: unsupported term "${term.value}"`); + return { kind: 'unknown', reason: `Unsupported term ${term.termType}` }; + } + return parseExpressionNode(term.value, trace, context); + } + + function parseRdfList( + head: string, + trace: Set, + context: string, + visited: Set = new Set(), + ): ClassExpression[] | null { + if (head === `${RDF}nil`) return []; + if (visited.has(head)) { + pushWarning(`Malformed RDF list cycle in class expression for ${context}`); + return null; + } + visited.add(head); + + const listQuads = quadsBySubject.get(head) ?? []; + const firstQuads = listQuads.filter((q) => q.predicate.value === `${RDF}first`); + const restQuads = listQuads.filter((q) => q.predicate.value === `${RDF}rest`); + if (firstQuads.length === 0 || restQuads.length === 0) { + pushWarning(`Malformed RDF list in class expression for ${context}`); + return null; + } + + const first = parseExpressionTerm(firstQuads[0].object, trace, context); + if (!first) return null; + const rest = restQuads[0].object; + if (rest.termType !== 'NamedNode' && rest.termType !== 'BlankNode') { + pushWarning(`Malformed RDF list in class expression for ${context}`); + return null; + } + const tail = parseRdfList(rest.value, trace, context, visited); + if (!tail) return null; + return [first, ...tail]; + } + + function parseExpressionNode( + nodeId: string, + trace: Set, + context: string, + ): ClassExpression | null { + if (trace.has(nodeId)) { + pushWarning(`Malformed class expression cycle for ${context}`); + return null; + } + const cached = expressionCache.get(nodeId); + if (cached !== undefined) return cached; + + trace.add(nodeId); + const nodeQuads = quadsBySubject.get(nodeId) ?? []; + + const union = nodeQuads.find((q) => q.predicate.value === `${OWL}unionOf`); + if (union) { + const operands = parseRdfList(union.object.value, trace, context); + const expr = operands + ? ({ kind: 'union', operands } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed unionOf list' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + const intersection = nodeQuads.find((q) => q.predicate.value === `${OWL}intersectionOf`); + if (intersection) { + const operands = parseRdfList(intersection.object.value, trace, context); + const expr = operands + ? ({ kind: 'intersection', operands } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed intersectionOf list' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + const complement = nodeQuads.find((q) => q.predicate.value === `${OWL}complementOf`); + if (complement) { + const operand = parseExpressionTerm(complement.object, trace, context); + const expr = operand + ? ({ kind: 'complement', operand } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed complementOf target' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + pushWarning(`Malformed class expression node for ${context}: ${localName(nodeId)}`); + const expr: ClassExpression = { + kind: 'unknown', + reason: `Unsupported expression node ${nodeId}`, + }; + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + // Second pass: process properties and relationships for (const quad of quads) { const s = quad.subject.value; @@ -333,24 +462,39 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu } continue; } + if (quad.object.termType === 'BlankNode') { + const expr = parseExpressionTerm(quad.object, new Set(), s); + if (expr) { + const cls = getOrCreateClass(ontology, s); + ensureClassExpression(cls, { source: 'subClassOf', expression: expr }); + } + continue; + } const cls = getOrCreateClass(ontology, s); - getOrCreateClass(ontology, o); + if (quad.object.termType === 'NamedNode') getOrCreateClass(ontology, o); if (!cls.subClassOf.includes(o)) { cls.subClassOf.push(o); } continue; } + if (p === `${OWL}equivalentClass`) { + const cls = getOrCreateClass(ontology, s); + const expr = parseExpressionTerm(quad.object, new Set(), s); + if (expr) ensureClassExpression(cls, { source: 'equivalentClass', expression: expr }); + continue; + } + if (p === `${OWL}disjointWith`) { const cls = getOrCreateClass(ontology, s); - if (!cls.disjointWith.includes(o)) { + if (quad.object.termType === 'NamedNode' && !cls.disjointWith.includes(o)) { cls.disjointWith.push(o); } continue; } if (p === `${RDFS}domain`) { - getOrCreateClass(ontology, o); + if (quad.object.termType === 'NamedNode') getOrCreateClass(ontology, o); const objProp = ontology.objectProperties.get(s); const dtProp = ontology.datatypeProperties.get(s); if (objProp) { @@ -365,8 +509,10 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu const objProp = ontology.objectProperties.get(s); const dtProp = ontology.datatypeProperties.get(s); if (objProp) { - getOrCreateClass(ontology, o); - if (!objProp.range.includes(o)) objProp.range.push(o); + if (quad.object.termType === 'NamedNode') { + getOrCreateClass(ontology, o); + if (!objProp.range.includes(o)) objProp.range.push(o); + } } else if (dtProp) { dtProp.range = o; } else if (isDatatypeURI(o)) { diff --git a/apps/desktop/src/renderer/src/model/types.ts b/apps/desktop/src/renderer/src/model/types.ts index 3debce4..e2966fc 100644 --- a/apps/desktop/src/renderer/src/model/types.ts +++ b/apps/desktop/src/renderer/src/model/types.ts @@ -36,6 +36,18 @@ export interface Restriction { value: string; } +export type ClassExpression = + | { kind: 'named'; uri: string } + | { kind: 'union'; operands: ClassExpression[] } + | { kind: 'intersection'; operands: ClassExpression[] } + | { kind: 'complement'; operand: ClassExpression } + | { kind: 'unknown'; reason: string }; + +export interface ClassExpressionAssertion { + source: 'equivalentClass' | 'subClassOf'; + expression: ClassExpression; +} + export interface OntologyClass { uri: string; label?: string; @@ -43,6 +55,7 @@ export interface OntologyClass { subClassOf: string[]; disjointWith: string[]; restrictions?: Restriction[]; + classExpressions?: ClassExpressionAssertion[]; } export type OWLCharacteristic = diff --git a/apps/desktop/src/renderer/src/store/ontology.ts b/apps/desktop/src/renderer/src/store/ontology.ts index e7ac64b..4a18eae 100644 --- a/apps/desktop/src/renderer/src/store/ontology.ts +++ b/apps/desktop/src/renderer/src/store/ontology.ts @@ -5,6 +5,7 @@ import { serializeToRdfXml } from '../model/formats/rdfxml'; import { type ParseWarning, parseTurtleWithWarnings } from '../model/parse'; import { serializeToTurtle } from '../model/serialize'; import type { + ClassExpression, DatatypeProperty, Individual, ObjectProperty, @@ -287,7 +288,16 @@ function cloneOntology(ontology: Ontology): Ontology { classes: new Map( Array.from(ontology.classes.entries()).map(([k, v]) => [ k, - { ...v, subClassOf: [...v.subClassOf], disjointWith: [...v.disjointWith] }, + { + ...v, + subClassOf: [...v.subClassOf], + disjointWith: [...v.disjointWith], + restrictions: v.restrictions?.map((r) => ({ ...r })), + classExpressions: v.classExpressions?.map((a) => ({ + source: a.source, + expression: cloneClassExpression(a.expression), + })), + }, ]), ), objectProperties: new Map( @@ -328,3 +338,20 @@ function cloneOntology(ontology: Ontology): Ontology { : undefined, }; } + +function cloneClassExpression(expr: ClassExpression): ClassExpression { + switch (expr.kind) { + case 'named': + return { kind: 'named', uri: expr.uri }; + case 'union': + return { kind: 'union', operands: expr.operands.map(cloneClassExpression) }; + case 'intersection': + return { kind: 'intersection', operands: expr.operands.map(cloneClassExpression) }; + case 'complement': + return { kind: 'complement', operand: cloneClassExpression(expr.operand) }; + case 'unknown': + return { kind: 'unknown', reason: expr.reason }; + default: + return expr satisfies never; + } +} diff --git a/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx b/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx new file mode 100644 index 0000000..f88e0f1 --- /dev/null +++ b/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { DetailPanel } from '@renderer/components/detail/DetailPanel'; +import { useOntologyStore } from '@renderer/store/ontology'; +import { useUIStore } from '@renderer/store/ui'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const EX = 'http://example.org/expr#'; + +const classExpressionTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/owl-class-expressions.ttl'), + 'utf-8', +); + +function resetStores() { + useUIStore.setState({ selectedNodeId: null, selectedEdgeId: null }); + useOntologyStore.getState().reset(); +} + +describe('ClassDetail — class expression rendering (ONT-83)', () => { + beforeEach(() => { + resetStores(); + useOntologyStore + .getState() + .loadFromTurtle(classExpressionTurtle, 'Sample: owl-class-expressions'); + }); + + afterEach(cleanup); + + it('shows the Class Expressions section with operator badges', () => { + useUIStore.setState({ selectedNodeId: `${EX}Nested` }); + + render(); + + expect(screen.getByText('Class Expressions')).toBeInTheDocument(); + expect(screen.getByText('Logical definitions for this class')).toBeInTheDocument(); + expect(screen.getByText('OR')).toBeInTheDocument(); + expect(screen.getByText('AND')).toBeInTheDocument(); + expect(screen.getByText('NOT')).toBeInTheDocument(); + }); + + it('focuses a target class when a class-expression class chip is clicked', () => { + useUIStore.setState({ selectedNodeId: `${EX}A` }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'B' })); + + expect(useUIStore.getState().selectedNodeId).toBe(`${EX}B`); + }); +}); diff --git a/apps/desktop/tests/model/owl-class-expressions.test.ts b/apps/desktop/tests/model/owl-class-expressions.test.ts new file mode 100644 index 0000000..94116f0 --- /dev/null +++ b/apps/desktop/tests/model/owl-class-expressions.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseTurtleWithWarnings } from '@renderer/model/parse'; +import type { OntologyClass } from '@renderer/model/types'; +import { describe, expect, it } from 'vitest'; + +const EX = 'http://example.org/expr#'; + +const classExpressionTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/owl-class-expressions.ttl'), + 'utf-8', +); + +function getClassExpressions(cls: OntologyClass): unknown[] { + return ((cls as unknown as { classExpressions?: unknown[] }).classExpressions ?? []) as unknown[]; +} + +describe('parseTurtle — OWL class expressions (ONT-83)', () => { + it('does not create anonymous class-expression blank nodes as standalone classes', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const blankNodeClasses = [...ontology.classes.keys()].filter( + (k) => k.startsWith('_:') || k.startsWith('n3-'), + ); + + expect(blankNodeClasses).toEqual([]); + }); + + it('extracts equivalentClass expressions for named and anonymous targets', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const named = ontology.classes.get(`${EX}A`) as OntologyClass; + const anonymous = ontology.classes.get(`${EX}EqAnd`) as OntologyClass; + + expect(getClassExpressions(named).length).toBeGreaterThan(0); + expect(getClassExpressions(anonymous).length).toBeGreaterThan(0); + }); + + it('extracts unionOf/intersectionOf/complementOf with nesting', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const subOr = ontology.classes.get(`${EX}SubOr`) as OntologyClass; + const subNot = ontology.classes.get(`${EX}SubNot`) as OntologyClass; + const nested = ontology.classes.get(`${EX}Nested`) as OntologyClass; + + expect(getClassExpressions(subOr).length).toBeGreaterThan(0); + expect(getClassExpressions(subNot).length).toBeGreaterThan(0); + expect(getClassExpressions(nested).length).toBeGreaterThan(0); + }); + + it('emits warning for malformed RDF list/cycle in class expressions without crashing', () => { + const { ontology, warnings } = parseTurtleWithWarnings(classExpressionTurtle); + const broken = ontology.classes.get(`${EX}Broken`) as OntologyClass; + + expect(broken).toBeDefined(); + expect(warnings.some((w) => /class expression|rdf list|cycle|malformed/i.test(w.message))).toBe( + true, + ); + }); +});