Compare commits

..
Author SHA1 Message Date
Sameer Kadam 702e392889 fix(explorer): remove stale 2030 temporal bound 2026-09-09 17:33:54 +05:30
22 changed files with 153 additions and 765 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/temporalScrubberBounds.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.test.ts tests/ontologyUrlState.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/temporalScrubberBounds.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:graph-legend-e2e": "node --import tsx --test tests/graphColorLegend.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
+9 -2
View File
@@ -19,7 +19,6 @@ import {
import { ErrorBoundary } from './ErrorBoundary';
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
import { hasOntologyUrlState } from './workspaces/OntologyWorkspace/ontologyUrlState';
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
@@ -97,7 +96,15 @@ const navItems: NavItem[] = [
];
function readInitialWorkspace(): WorkspaceId {
return hasOntologyUrlState() ? 'ontology-hub' : 'welcome';
try {
const params = new URLSearchParams(window.location.search);
if (params.has("ontologyTab") || params.has("ontologyEntity")) {
return "ontology-hub";
}
} catch {
// Default to the welcome screen when URL state is unavailable.
}
return "welcome";
}
const shellStyles = `
@@ -346,7 +346,7 @@ export function GraphInspectorPanel({
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? "Use Focus to resolve this grouped selection to its canonical node."
? "Activate Focused mode to resolve this grouped selection to its canonical node."
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
</div>
</div>
@@ -2791,7 +2791,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
},
{
id: "view-focused",
label: "Focus",
label: "Focused",
title: canActivateFocusedMode
? "Inspect the selected node in a focused local graph"
: (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"),
@@ -28,12 +28,11 @@ import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
import {
classifyNodeType,
inferOntologyUri,
isEditableEntityType,
ONTOLOGY_MINIMAP_THEME,
resolveEditorOntology,
} from "./ontologyEditorModel";
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
import { clearEntitySelection, readOntologyUrlState, writeEntitySelection } from "./ontologyUrlState";
type OntologyNodeData = {
label?: string;
@@ -138,7 +137,11 @@ interface DraftDiff {
}
function requestedEntityUri(): string {
return readOntologyUrlState().entityUri || "";
try {
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
} catch {
return "";
}
}
function nodeLabel(node: OntologyGraphNode): string {
@@ -222,7 +225,6 @@ export function OntologyEditor() {
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
const [graphError, setGraphError] = useState("");
const [unownedEntity, setUnownedEntity] = useState("");
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
added_classes: [],
removed_classes: [],
@@ -248,21 +250,11 @@ export function OntologyEditor() {
? loadOntologyEntityOwner(requested).catch(() => undefined)
: Promise.resolve(undefined),
])
.then(([entries, ownerVerdict]: [RegistryEntry[], string | null | undefined]) => {
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
if (cancelled) return;
setRegistry(entries);
const resolution = resolveEditorOntology(entries, requested, ownerVerdict);
// The registry default is the right landing place for "no entity asked
// for", but not for "the backend says nothing owns the entity that was
// asked for" — that would open an arbitrary ontology whose graph
// excludes the entity, and report nothing about why.
if (resolution.status === "unowned") {
setUnownedEntity(resolution.entityUri);
return;
}
setUnownedEntity("");
const resolvedOntology = resolution.status === "resolved" ? resolution.uri : undefined;
setOntologyUri((current) => current || resolvedOntology || entries[0]?.uri || "");
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
})
.catch((error) => {
console.error("Failed to load ontology registry:", error);
@@ -385,7 +377,14 @@ export function OntologyEditor() {
const selectNode = useCallback((node: OntologyNode) => {
setSelectedElement(node);
writeEntitySelection(node.id);
try {
const params = new URLSearchParams(window.location.search);
params.set("ontologyTab", "editor");
params.set("ontologyEntity", node.id);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; the editor selection still works without it.
}
}, []);
const saveDraft = useCallback(async () => {
@@ -555,8 +554,15 @@ export function OntologyEditor() {
onChange={(event) => {
setOntologyUri(event.target.value);
setSelectedElement(null);
setUnownedEntity("");
clearEntitySelection();
try {
// Drop the previous ontology's entity from the URL, or a reload
// would resolve the stale ID and jump back to that ontology.
const params = new URLSearchParams(window.location.search);
params.delete("ontologyEntity");
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; switching ontologies still works.
}
}}
style={selectStyle}
>
@@ -627,12 +633,7 @@ export function OntologyEditor() {
{!isLoadingGraph && graphError && (
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
)}
{!isLoadingGraph && !graphError && unownedEntity && (
<div style={{ ...canvasMessageStyle, color: "#f2b66d" }}>
No registered ontology owns {unownedEntity}. Pick an ontology above to start editing.
</div>
)}
{!isLoadingGraph && !graphError && !unownedEntity && ontologyUri && nodes.length === 0 && (
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
)}
@@ -32,11 +32,7 @@ export type OntologyGraphResponse = {
};
export type OntologyEntityOwner = {
// Optional on purpose, unlike OntologyGraphNode.entity_type. There, a missing
// field degrades to a read-only node — benign. Here it would be read as an
// authoritative "nothing owns this entity", which now suppresses selection
// outright, so presence has to be checked rather than assumed.
owning_ontology?: string | null;
source_ontology?: string;
};
async function parseResponse<T>(response: Response): Promise<T> {
@@ -67,21 +63,10 @@ export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Prom
);
}
// Three-state verdict: a string names the owner, null is the backend's
// authoritative "no known ontology owns this entity", and undefined means the
// request failed so there is no verdict to act on.
export type OntologyOwnerVerdict = string | null | undefined;
export async function loadOntologyEntityOwner(uri: string): Promise<OntologyOwnerVerdict> {
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
if (!response.ok) return undefined;
const owner = await response.json() as OntologyEntityOwner | null;
// Only a field that is actually there carries the verdict. Coercing an absent
// field to null would assert the strongest available claim — "nothing owns
// this" — on the weakest possible evidence, and that claim now stops the
// editor selecting an ontology at all.
const verdict = owner?.owning_ontology;
return verdict === undefined ? undefined : verdict;
return (await response.json() as OntologyEntityOwner).source_ontology;
}
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
@@ -13,7 +13,6 @@ import { OntologyManager } from "./OntologyManager";
import { OntologyEditor } from "./OntologyEditor";
import { ShaclStudio } from "./ShaclStudio";
import { VersionsTab } from "./VersionsTab";
import { readOntologyUrlState, writeEntitySelection, writeTab } from "./ontologyUrlState";
export type OntologyHubTab =
| "registry"
@@ -23,6 +22,8 @@ export type OntologyHubTab =
| "health"
| "shacl";
const TAB_PARAM = "ontologyTab";
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
{ id: "registry", label: "Registry", icon: BookMarked },
{ id: "editor", label: "Editor", icon: Sliders },
@@ -32,23 +33,37 @@ const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
{ id: "shacl", label: "SHACL", icon: Shield },
];
function readInitialTab(): OntologyHubTab {
const { tab, entityUri } = readOntologyUrlState();
const requested = TABS.find((candidate) => candidate.id === tab);
if (requested) return requested.id;
if (entityUri) return "editor";
function readTabParam(): OntologyHubTab {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get(TAB_PARAM);
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
if (params.get("ontologyEntity")) return "editor";
} catch {
// ignore
}
return "registry";
}
function writeTabParam(tab: OntologyHubTab) {
try {
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, tab);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// ignore
}
}
interface OntologyWorkspaceProps {
onJumpToGraphNode?: (nodeId: string) => void;
}
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readInitialTab);
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
useEffect(() => {
writeTab(activeTab);
writeTabParam(activeTab);
}, [activeTab]);
const handleTabChange = useCallback((tab: OntologyHubTab) => {
@@ -56,7 +71,10 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
}, []);
const handleFixInEditor = useCallback((entityUri: string) => {
writeEntitySelection(entityUri);
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, "editor");
params.set("ontologyEntity", entityUri);
window.history.replaceState(null, "", `?${params.toString()}`);
setActiveTab("editor");
}, []);
@@ -45,10 +45,6 @@ export function classifyNodeType(rawType: string): EditorEntityType {
return "external";
}
// Last-resort guess, reached only when the backend gave no verdict: it has no
// notion of nested vocabularies, so it can name a parent that does not contain
// the entity. Authority is owning_ontology from /api/ontology/entity
// (_resolve_owning_ontology in semantica/explorer/routes/ontology.py).
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
const stem = ontologyUri.replace(/[/#]+$/, "");
return entityUri === ontologyUri
@@ -56,50 +52,12 @@ function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|| entityUri.startsWith(`${stem}/`);
}
/**
* Three outcomes, deliberately not collapsed into `string | undefined`.
*
* `unowned` and `unresolved` both yield "no ontology to open", but they must
* not be treated alike: a caller that writes `resolve(...) || entries[0]` turns
* the backend's authoritative "nothing owns this entity" into "open an
* arbitrary ontology", which reintroduces the parent-selection bug this whole
* verdict exists to prevent. The union makes that collapse a type error.
*/
export type EditorOntologyResolution =
| { status: "resolved"; uri: string }
| { status: "unowned"; entityUri: string }
| { status: "unresolved" };
// Picks the registered ontology to open for a deep-linked entity. A null
// verdict is the backend's authoritative "nothing owns this entity": the
// namespace guess must stay suppressed, or an unregistered nested namespace
// would select its registered parent again. Only an unavailable verdict
// (undefined) may fall back to inference.
export function resolveEditorOntology(
entries: RegistryEntry[],
entityUri: string,
ownerVerdict: string | null | undefined,
): EditorOntologyResolution {
if (ownerVerdict === null) {
return { status: "unowned", entityUri };
}
const uri = inferOntologyUri(entries, entityUri, ownerVerdict);
return uri === undefined ? { status: "unresolved" } : { status: "resolved", uri };
}
// Picks the registered ontology to open for an entity: the backend-resolved
// explicitOwner wins outright, the namespace guess is only the fallback.
export function inferOntologyUri(
entries: RegistryEntry[],
entityUri: string,
explicitOwner?: string,
): string | undefined {
// Trusted even when the registry does not list it. Falling through to the
// namespace guess here would answer a question nobody asked — the backend
// named this entity's owner, and silently opening a *different* ontology is
// worse than opening one the registry has not been told about yet, which
// surfaces as an explicit error from /api/ontology/graph.
if (explicitOwner) {
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
return explicitOwner;
}
return [...entries]
@@ -1,94 +0,0 @@
// Sole owner of the Ontology Hub deep-link query parameters: the names below must not be
// spelled out anywhere else, so that the protocol can change in one place.
const TAB_PARAM = "ontologyTab";
const ENTITY_PARAM = "ontologyEntity";
const EDITOR_TAB = "editor";
export interface OntologyUrlState {
/** Raw parameter value; the set of legal tab ids belongs to the workspace, not this module. */
tab?: string;
entityUri?: string;
}
/** `undefined` means the parameter is absent; an empty string means it is present but blank. */
export function parseOntologyUrlState(search: string): OntologyUrlState {
const params = new URLSearchParams(search);
return {
tab: params.get(TAB_PARAM) ?? undefined,
entityUri: params.get(ENTITY_PARAM) ?? undefined,
};
}
export function applyTab(search: string, tab: string): string {
const params = new URLSearchParams(search);
params.set(TAB_PARAM, tab);
return `?${params.toString()}`;
}
// A selected entity is only addressable from the editor, so the tab moves with it.
export function applyEntitySelection(search: string, entityUri: string): string {
const params = new URLSearchParams(search);
params.set(TAB_PARAM, EDITOR_TAB);
params.set(ENTITY_PARAM, entityUri);
return `?${params.toString()}`;
}
// Pairs with applyEntitySelection: an entity URI is resolved back to its owning ontology on
// load, so leaving a stale one behind when the active ontology changes reopens the old ontology.
export function removeEntitySelection(search: string): string {
const params = new URLSearchParams(search);
params.delete(ENTITY_PARAM);
return `?${params.toString()}`;
}
/**
* Deliberately dual-role, and the argument is what selects the role: given a
* `search` string this is pure and total, delegating straight to
* `parseOntologyUrlState`; called with no argument it reads live `window`
* state and yields empty state if the URL is unreadable. Callers in render or
* effect paths use the no-argument form; tests and any caller that already
* holds a search string pass it, which is the only form that is testable.
*/
export function readOntologyUrlState(search?: string): OntologyUrlState {
if (search !== undefined) {
return parseOntologyUrlState(search);
}
try {
return parseOntologyUrlState(window.location.search);
} catch {
return {};
}
}
/** True when the URL addresses the Ontology Hub at all, even with blank parameter values. */
export function hasOntologyUrlState(search?: string): boolean {
const { tab, entityUri } = readOntologyUrlState(search);
return tab !== undefined || entityUri !== undefined;
}
// The transform returns a query string only, so the fragment has to be carried
// across explicitly: replaceState with a bare "?..." drops it. This is the one
// place that knows how the URL is written, so it is the only place that can.
function updateSearch(transform: (search: string) => string): void {
try {
window.history.replaceState(
null,
"",
`${transform(window.location.search)}${window.location.hash}`,
);
} catch {
// Deep-link state is a convenience; every caller stays correct without it.
}
}
export function writeTab(tab: string): void {
updateSearch((search) => applyTab(search, tab));
}
export function writeEntitySelection(entityUri: string): void {
updateSearch((search) => applyEntitySelection(search, entityUri));
}
export function clearEntitySelection(): void {
updateSearch(removeEntitySelection);
}
+1 -3
View File
@@ -101,9 +101,7 @@ test("visible legend follows loaded data, reloads, focused views, and distance m
await heatmap.click();
await legend.waitFor();
await assertLegendMatchesGraph(page);
const focusButton = page.getByRole("button", { name: "Focus", exact: true });
assert.equal(await focusButton.isDisabled(), false, "Focus is enabled once a node is selected");
await focusButton.click();
await page.getByRole("button", { name: "Focused", exact: true }).click();
await legend.getByText("Document", { exact: true }).waitFor({ state: "hidden" });
await assertLegendMatchesGraph(page, ["alice", "acme", "research"]);
assert.equal(await legend.getByText("Researcher", { exact: true }).count(), 1);
@@ -6,7 +6,6 @@ import {
compactNodeType,
inferOntologyUri,
isEditableEntityType,
resolveEditorOntology,
ONTOLOGY_MINIMAP_THEME,
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
@@ -30,20 +29,6 @@ test("explicit scheme ownership wins when an entity uses another namespace", ()
);
});
test("an explicit owner missing from the registry is used, not quietly replaced", () => {
// The entity sits under a registered namespace, so the guess has an answer
// ready; the backend naming a different, unregistered owner must still win,
// or the editor opens an ontology nobody said owned this entity.
assert.equal(
inferOntologyUri(
registry,
"https://example.test/foo#Class",
"https://unregistered.test/vocab",
),
"https://unregistered.test/vocab",
);
});
test("only draft-supported class and property nodes are editable", () => {
assert.equal(isEditableEntityType("class"), true);
assert.equal(isEditableEntityType("property"), true);
@@ -79,36 +64,3 @@ test("compactNodeType leaves unknown namespaces untouched", () => {
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
assert.equal(compactNodeType("owl:Class"), "owl:Class");
});
test("an authoritative no-owner verdict suppresses the namespace guess", () => {
// Without suppression the prefix guess would pick the registered parent
// for an unregistered nested entity — the deep link must not do that.
const nested = "https://example.test/foo/unregistered#Term";
assert.deepEqual(resolveEditorOntology(registry, nested, null), {
status: "unowned",
entityUri: nested,
});
// An unavailable verdict may still fall back to inference
assert.deepEqual(
resolveEditorOntology(registry, "https://example.test/foo#Class", undefined),
{ status: "resolved", uri: "https://example.test/foo" },
);
// A named owner wins outright
assert.deepEqual(
resolveEditorOntology(registry, nested, "https://example.test/foo/nested"),
{ status: "resolved", uri: "https://example.test/foo/nested" },
);
});
test("unowned is distinguishable from unresolved, so neither collapses to a default", () => {
// Both mean "no ontology to open", and the editor treats them oppositely:
// unresolved may land on the registry default, unowned must not. A caller
// writing `resolve(...) || entries[0]` reintroduced exactly the parent
// selection this verdict exists to prevent, so the difference is typed.
const unowned = resolveEditorOntology(registry, "https://example.test/foo/x#T", null);
const unresolved = resolveEditorOntology(registry, "https://elsewhere.test/T", undefined);
assert.equal(unowned.status, "unowned");
assert.equal(unresolved.status, "unresolved");
assert.notEqual(unowned.status, unresolved.status);
});
-102
View File
@@ -1,102 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyEntitySelection,
applyTab,
clearEntitySelection,
hasOntologyUrlState,
parseOntologyUrlState,
readOntologyUrlState,
removeEntitySelection,
writeEntitySelection,
writeTab,
} from "../src/workspaces/OntologyWorkspace/ontologyUrlState";
function withStubbedLocation(search: string, hash: string, body: () => void): string[] {
const written: string[] = [];
const original = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
location: { search, hash },
history: { replaceState: (_s: unknown, _t: string, url: string) => written.push(url) },
};
try {
body();
} finally {
(globalThis as { window?: unknown }).window = original;
}
return written;
}
test("selecting an entity round-trips and pins the editor tab", () => {
const search = applyEntitySelection("", "https://example.test/foo#Bar");
assert.deepEqual(parseOntologyUrlState(search), {
tab: "editor",
entityUri: "https://example.test/foo#Bar",
});
});
test("clearing the selection drops only the entity and keeps unrelated params", () => {
const search = applyEntitySelection("?view=graph&depth=2", "https://example.test/foo#Bar");
const cleared = parseOntologyUrlState(removeEntitySelection(search));
assert.equal(cleared.entityUri, undefined);
assert.equal(cleared.tab, "editor");
assert.equal(new URLSearchParams(removeEntitySelection(search)).get("depth"), "2");
});
test("writing a tab leaves an existing entity selection alone", () => {
const search = applyTab(applyEntitySelection("", "urn:x"), "health");
assert.deepEqual(parseOntologyUrlState(search), { tab: "health", entityUri: "urn:x" });
});
test("absent params read as undefined, blank params as empty strings", () => {
assert.deepEqual(parseOntologyUrlState(""), { tab: undefined, entityUri: undefined });
assert.deepEqual(parseOntologyUrlState("?other=1"), { tab: undefined, entityUri: undefined });
assert.deepEqual(parseOntologyUrlState("?ontologyTab=&ontologyEntity="), {
tab: "",
entityUri: "",
});
});
test("a present but blank param still counts as ontology deep-link state", () => {
assert.equal(hasOntologyUrlState("?ontologyEntity="), true);
assert.equal(hasOntologyUrlState("?ontologyTab="), true);
assert.equal(hasOntologyUrlState("?view=graph"), false);
assert.equal(hasOntologyUrlState(""), false);
});
test("malformed search strings degrade to plain values instead of throwing", () => {
assert.deepEqual(parseOntologyUrlState("???"), { tab: undefined, entityUri: undefined });
assert.deepEqual(parseOntologyUrlState("ontologyEntity=urn%3Ax&&=&"), {
tab: undefined,
entityUri: "urn:x",
});
});
test("entity URIs survive characters that need escaping", () => {
const entityUri = "https://example.test/vocab#Has Part/&?=";
const search = applyEntitySelection("?keep=1", entityUri);
assert.equal(parseOntologyUrlState(search).entityUri, entityUri);
});
test("every writer preserves the URL fragment", () => {
const written = withStubbedLocation("?view=graph", "#section-3", () => {
writeTab("health");
writeEntitySelection("urn:x");
clearEntitySelection();
});
assert.deepEqual(written, [
"?view=graph&ontologyTab=health#section-3",
"?view=graph&ontologyTab=editor&ontologyEntity=urn%3Ax#section-3",
"?view=graph#section-3",
]);
});
test("readOntologyUrlState with no argument reads live URL state", () => {
withStubbedLocation("?ontologyTab=editor&ontologyEntity=urn%3Ax", "", () => {
assert.deepEqual(readOntologyUrlState(), { tab: "editor", entityUri: "urn:x" });
assert.equal(hasOntologyUrlState(), true);
});
});
+14 -56
View File
@@ -1,80 +1,38 @@
---
name: change
description: Inspect graph changes over time and ontology version diffs in Semantica. Uses ContextGraph.state_at for point-in-time graph state and change_management.VersionManager for ontology versioning.
description: Track and inspect graph changes, diffs, temporal updates, and the impact of new data on Semantica knowledge graphs.
---
# /semantica:change
Track what changed. Usage: `/semantica:change <task> [args]`
Inspect changes over time and evaluate updates. Usage: `/semantica:change <task> [args]`
> Two distinct mechanisms cover this, and they are **not** interchangeable:
>
> | Question | Tool |
> | --- | --- |
> | "What did the *graph* look like on date X?" | `ContextGraph.state_at()` |
> | "What changed between *ontology* versions?" | `change_management.VersionManager` |
`$ARGUMENTS` = task + optional node, time window, or filter.
---
## `graph-at <timestamp>` — point-in-time graph state
## `diff [--from <ts>] [--to <ts>] [--node <id>]`
Compute graph diffs between two snapshots.
```python
import os
from semantica.provenance.change_tracker import ChangeTracker
from semantica.context import ContextGraph
graph = ContextGraph()
graph.load_from_file(os.path.expanduser("~/.semantica/kg.json")) # load_from_file does not expand ~
snapshot = graph.state_at("2026-06-01") # str | int | float | datetime
tracker = ChangeTracker()
diff = tracker.compute_diff(from_ts=from_ts, to_ts=to_ts, node_id=node_id)
```
Diff two moments by comparing node IDs — `state_at()["nodes"]` is a list of
dicts (unhashable), so compare the `id` fields, not the dicts themselves:
```python
before = graph.state_at("2026-06-01")
after = graph.state_at("2026-09-01")
before_ids = {n["id"] for n in before["nodes"]}
after_ids = {n["id"] for n in after["nodes"]}
added = after_ids - before_ids
```
For richer temporal work (scrubbing, evolution, temporal patterns) use
`/semantica:temporal`, which wraps the same layer.
Output: added/removed nodes and edges, attribute changes, and impact summary.
---
## `node-history <node_id>` — who touched this node
## `history <node_id> [--limit N]`
Node-level history is provenance, not change management:
Show the change history for a node or relationship.
```python
import os
from semantica.provenance import ProvenanceManager
db_path = os.path.expanduser("~/.semantica/prov.db") # storage_path is passed to
os.makedirs(os.path.dirname(db_path), exist_ok=True) # sqlite3.connect() unexpanded
pm = ProvenanceManager(storage_path=db_path)
history = pm.revision_history(node_id)
log = pm.audit_log(since="2026-01-01")
history = tracker.get_node_history(node_id=node_id, limit=limit)
```
---
## `versions` / `diff <v1> <v2>` — ontology versioning
```python
from semantica.change_management import VersionManager
vm = VersionManager()
vm.create_version("1.1.0", ontology)
vm.list_versions()
vm.get_latest_version()
delta = vm.compare_versions("1.0.0", "1.1.0")
delta = vm.diff_ontologies(base_ontology, target_ontology)
migrated = vm.migrate_ontology("1.0.0", "1.1.0", ontology)
```
`TemporalVersionManager` and `OntologyVersionManager` are also exported for
time-scoped and ontology-specific variants.
Return: revisions, timestamps, authors, and summary comments.
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: decision
description: Full decision lifecycle in Semantica — record, query, find precedents (hybrid/advanced), analyze influence, explain, insights dashboard, list, and record exceptions. Uses AgentContext, ContextGraph, DecisionQuery, CausalChainAnalyzer, DecisionRecorder.
description: Full decision lifecycle in Semantica — record, query, find precedents (hybrid/advanced), analyze influence, explain, insights dashboard, list, and record exceptions. Uses AgentContext, ContextGraph, DecisionQuery, CausalChainAnalyzer, DecisionRecorder.
---
# /semantica:decision
@@ -114,7 +114,7 @@ Output: Influence score + influenced decisions table + predicted new relationshi
## `explain <decision_id>`
Full explainability trace — reasoning steps, causal antecedents, policy compliance.
Full explainability trace — reasoning steps, causal antecedents, policy compliance.
```python
from semantica.context import AgentContext, ContextGraph
+2 -2
View File
@@ -21,8 +21,8 @@ Run the full extraction pipeline. Usage: `/semantica:extract [file_path | "inlin
**2. Clear the result cache** to prevent cross-invocation pollution:
```python
from semantica.semantic_extract.cache import extraction_cache
extraction_cache.clear()
from semantica.semantic_extract.cache import _result_cache
_result_cache.clear()
```
**3. Run the full pipeline:**
+13 -65
View File
@@ -1,89 +1,37 @@
---
name: ontology
description: Manage ontology schemas, concepts, alignments, and SHACL/OWL validation for Semantica knowledge graphs. Uses OntologyEngine and OntologyValidator.
description: Manage ontology schemas, concepts, relationships, and alignments for Semantica knowledge graphs.
---
# /semantica:ontology
Manage ontology definitions and validation. Usage: `/semantica:ontology <task> [args]`
> Entry points: `OntologyEngine` (authoring, export, alignments) and
> `OntologyValidator` (consistency checking).
`$ARGUMENTS` = task + optional ontology item or schema file.
---
## `concepts <scheme_uri>`
## `describe <concept>`
List SKOS concepts in a vocabulary scheme.
Show ontology concept details.
```python
from semantica.ontology import OntologyEngine
from semantica.triplet_store import TripletStore
from semantica.ontology import OntologyManager
store = TripletStore(backend="oxigraph") # needs semantica[tripletstore-oxigraph]
engine = OntologyEngine(store=store) # list_concepts/list_vocabularies need a
# configured store — raises ProcessingError without one
concepts = engine.list_concepts(scheme_uri)
vocabs = engine.list_vocabularies()
manager = OntologyManager()
concept = manager.get_concept(concept_name)
```
Output: properties, relationships, inherited types, and examples.
---
## `validate <ontology>`
## `validate [--schema <file>]`
Check an ontology for consistency and satisfiability.
Validate the graph or schema against the ontology.
```python
from semantica.ontology import OntologyValidator
validator = OntologyValidator(check_consistency=True, check_satisfiability=True)
result = validator.validate(ontology) # dict or path to an ontology file
# result.valid, result.errors, result.warnings
result = manager.validate_graph(graph=graph, schema_file=schema_file)
```
For SHACL shape validation of instance data use `SHACLGenerator` / `SHACLValidationReport`:
```python
from semantica.ontology import SHACLGenerator
```
---
## `build <text|data>`
Generate an ontology from unstructured text or structured records.
```python
engine = OntologyEngine()
onto = engine.from_text(text) # LLM-assisted (needs an llm-* extra + API key)
onto = engine.from_data(records) # deterministic, from structured data
```
---
## `export <ontology> <path> [--format turtle]`
```python
engine.export_owl(onto, path, format="turtle")
engine.export_shacl(onto, path, format="turtle")
```
---
## `align <source_uri> <target_uri> <predicate>`
```python
engine.create_alignment(source_uri, target_uri, predicate)
engine.get_alignments(entity_uri)
engine.list_alignments()
```
---
## `evaluate <ontology>`
Quality-gate an ontology (`OntologyEvaluator` / `OntologyQualityReport` under the hood).
```python
report = engine.evaluate(onto)
```
Return: validation status, errors, and correction suggestions.
+13 -42
View File
@@ -1,66 +1,37 @@
---
name: policy
description: Define and enforce decision policies, compliance rules, and exceptions over Semantica graphs. Uses ContextGraph.check_decision_rules/enforce_decision_policy and context.PolicyEngine.
description: Define and enforce policies, access controls, and compliance rules over Semantica knowledge graphs.
---
# /semantica:policy
Policy governance over recorded decisions. Usage: `/semantica:policy <task> [args]`
Apply policy rules and checks. Usage: `/semantica:policy <task> [args]`
> `PolicyEngine` lives in `semantica.context`. For most cases the two policy
> methods on `ContextGraph` itself are enough.
`$ARGUMENTS` = task + optional policy name, rule set, or target entity.
---
## `check <decision>` — the simple path
## `check [--rule <name>] [--target <id>]`
No policy store needed; rules default to a built-in policy set.
Run policy checks against the graph.
```python
from semantica.context import ContextGraph
from semantica.policy import PolicyEngine
graph = ContextGraph()
result = graph.check_decision_rules({
"category": "vendor_selection",
"outcome": "approved",
"confidence": 0.93,
"decision_maker": "gyro",
})
# {'compliant': bool, 'violations': [...], 'warnings': [...], 'policy_rules': {...}}
engine = PolicyEngine()
result = engine.check(rule_name=rule_name, target=target)
```
Default rules: `min_confidence=0.7`, `required_outcomes=['approved','rejected','flagged']`,
`required_metadata=['decision_maker']`, `max_reasoning_length=1000`. Override by
passing your own `rules=` dict.
## `enforce <decision> [--rules <dict>]`
```python
verdict = graph.enforce_decision_policy(decision_data, policy_rules=None)
```
Output: compliance status, failing rules, and remediation guidance.
---
## Managed policies — the full path
## `list`
`PolicyEngine` requires a graph store and versioned `Policy` objects.
List available policy rules and categories.
```python
from semantica.context import PolicyEngine
from semantica.context.decision_models import Policy
engine = PolicyEngine(graph_store)
policy_id = engine.add_policy(Policy(...))
policies = engine.get_applicable_policies(category="vendor_selection", entities=[...])
ok = engine.check_compliance(decision, policy_id)
history = engine.get_policy_history(policy_id)
engine.update_policy(policy_id, rules={...}, change_reason="tightened threshold")
engine.record_exception(decision_id, policy_id, reason="...", approver="...")
impact = engine.analyze_policy_impact(policy_id, proposed_rules={...})
affected = engine.get_affected_decisions(policy_id, from_version, to_version)
rules = engine.list_rules()
```
Note `check_compliance` takes a `Decision` object, not a dict — fetch it from the
graph rather than constructing one by hand.
Return: rule name, description, severity, and category.
+17 -50
View File
@@ -1,70 +1,37 @@
---
name: provenance
description: Trace data lineage, source attribution, audit trails, and W3C PROV-O export in Semantica graphs. Uses ProvenanceManager.
description: Trace data lineage, source attribution, audit trails, and provenance assertions in Semantica graphs.
---
# /semantica:provenance
Lineage and audit trails. Usage: `/semantica:provenance <task> [args]`
Inspect provenance metadata. Usage: `/semantica:provenance <task> [args]`
`$ARGUMENTS` = task + optional node, edge, or time range.
---
## `lineage <entity_id> [--depth N]`
## `trace <node_id> [--depth N]`
Trace the provenance of a node or fact.
```python
import os
from semantica.provenance import ProvenanceManager
from semantica.provenance import ProvenanceTracer
db_path = os.path.expanduser("~/.semantica/prov.db") # storage_path is passed to
os.makedirs(os.path.dirname(db_path), exist_ok=True) # sqlite3.connect() unexpanded
pm = ProvenanceManager(storage_path=db_path) # SQLite, or omit for in-memory
chain = pm.lineage(entity_id, depth=3)
full = pm.get_lineage(entity_id) # complete ancestry
down = pm.get_descendants(entity_id) # what this entity influenced
tracer = ProvenanceTracer()
trace = tracer.trace_node(node_id=node_id, depth=depth)
```
Output: source chain, authors, timestamps, and validation status.
---
## `sources <entity_id>`
## `audit [--since <ts>] [--actor <id>]`
View audit logs for graph changes.
```python
srcs = pm.get_all_sources(entity_id) # every source that contributed
prov = pm.get_provenance(entity_id) # the raw PROV entry
hist = pm.revision_history(entity_id)
audit_log = tracer.get_audit_log(since=since, actor=actor)
```
---
## `audit [--since <iso-date>] [--format table|json]`
```python
log = pm.audit_log(since="2026-01-01", format="table")
between = pm.query_recorded_between(start, end)
stats = pm.get_statistics()
```
---
## `export [--format turtle|json-ld|xml]`
W3C PROV-O export — this is the regulator-facing artifact.
```python
rdf = pm.export_prov(format="turtle", base_uri="https://example.org/prov/")
```
---
## `invalidate <entity_id> <agent_id> [--reason ...]`
Mark an entity superseded without deleting history.
```python
pm.invalidate(entity_id, agent_id, reason="source retracted")
```
## `check [--strict]`
```python
report = pm.check(strict=False) # integrity check over the provenance store
```
Return: change events, actor, affected objects, and action details.
+19 -54
View File
@@ -1,84 +1,49 @@
---
name: query
description: Query Semantica knowledge graphs — in-memory ContextGraph search, SPARQL over RDF triple stores, and Cypher over LPG backends.
description: Query the Semantica knowledge graph using SPARQL, Cypher, keyword search, and structured graph query patterns.
---
# /semantica:query
Query the graph. Usage: `/semantica:query <task> [args]`
Run graph queries and search. Usage: `/semantica:query <mode> [args]`
> Which API you want depends on where the graph lives.
`$ARGUMENTS` = query mode + query string or filter.
---
## `search "<keywords>"` — the in-memory ContextGraph
## `sparql <query>`
This is the one that works with no external server.
Execute a SPARQL query against the graph.
```python
import os
from semantica.context import ContextGraph
from semantica.query import QueryEngine
graph = ContextGraph()
graph.load_from_file(os.path.expanduser("~/.semantica/kg.json")) # load_from_file does not expand ~
results = graph.query("vendor selection", skip=0, limit=20)
engine = QueryEngine()
results = engine.query_sparql(query)
```
Related lookups on the same object:
```python
graph.find_nodes(...) graph.find_node(...)
graph.find_related_nodes(...) graph.get_neighbors(node_id)
graph.find_similar_nodes(...) graph.get_nodes_by_label(label)
```
Decision-specific queries belong to `/semantica:decision`.
Return: query bindings as a Markdown table.
---
## `sparql "<query>"` — RDF triple stores
## `cypher <query>`
Execute a Cypher-like query.
```python
from semantica.triplet_store import TripletStore
store = TripletStore(backend="oxigraph") # embedded; needs semantica[tripletstore-oxigraph]
# or backend="blazegraph" | "jena" | "rdf4j" with endpoint="http://..."
result = store.execute_query(sparql)
results = engine.query_cypher(query)
```
For query planning, optimisation, and caching over a backend:
```python
from semantica.triplet_store import QueryEngine, OxigraphStore
# QueryEngine needs an object exposing execute_sparql() — the raw backend,
# not the TripletStore wrapper above (which only exposes execute_query()).
backend = OxigraphStore()
qe = QueryEngine()
plan = qe.plan_query(sparql)
tuned = qe.optimize_query(sparql)
result = qe.execute_query(sparql, store_backend=backend)
stats = qe.get_query_statistics()
```
Blazegraph / Jena / RDF4J need **no** extra — `semantica.triplet_store` speaks
SPARQL over HTTP using the core `requests` dependency.
Output: node/relationship results and path summaries.
---
## `cypher "<query>"` — labeled property graphs
## `search <keywords> [--filter <type>]`
Search graph entities by keyword.
```python
from semantica.graph_store import Neo4jStore # needs semantica[graph-neo4j]
store = Neo4jStore(uri=..., user=..., password=...)
result = store.execute_query(query, parameters={...})
results = engine.search(keywords=keywords, filter_type=filter_type)
```
Also available: `FalkorDBStore`, `ApacheAgeStore`, `AmazonNeptuneStore`,
and `GraphManager` / `GraphStore` for backend-agnostic access.
**Not installed in this environment** — add the backend extra first, e.g.
`pip install "semantica[graph-neo4j]"`.
Return: ranked matches with entity types and relevance scores.
+2 -2
View File
@@ -119,9 +119,9 @@ from semantica.semantic_extract import (
NamedEntityRecognizer,
RelationExtractor,
)
from semantica.semantic_extract.cache import extraction_cache
from semantica.semantic_extract.cache import _result_cache
extraction_cache.clear() # prevent cross-invocation cache pollution
_result_cache.clear() # prevent cross-invocation cache pollution
text = open(file_path).read()
-60
View File
@@ -244,7 +244,6 @@ class EntityDetailResponse(BaseModel):
entity_type: str
definition: Optional[str] = None
source_ontology: Optional[str] = None
owning_ontology: Optional[str] = None
superclasses: List[str] = Field(default_factory=list)
subclasses: List[str] = Field(default_factory=list)
domain: List[str] = Field(default_factory=list)
@@ -815,55 +814,6 @@ def _node_belongs_to_ontology(
return "#" not in local_name and "/" not in local_name
def _resolve_owning_ontology(
node: Dict[str, Any],
known_ontology_uris: set[str],
) -> Optional[str]:
"""Return the one known ontology that owns this node, or None if none does.
The most specific (longest) match wins, so a nested vocabulary claims its
own terms instead of the parent absorbing them.
Kept agreeing with _node_belongs_to_ontology by construction — the same
three rules in the same order — but in one pass over the candidates rather
than one pass per candidate, each of which rescanned the whole set to find
the longest namespace. That made resolution quadratic in the number of
registered ontologies.
"""
nid = str(node.get("id", ""))
if not nid:
return None
# An ontology node owns itself, ahead of any scheme_uri it may carry.
if nid in known_ontology_uris:
return nid
# An explicit owner is authoritative even when it is not registered:
# naming a different ontology by namespace guess would be worse than
# reporting the one the node itself points at.
explicit_owner = _node_source_ontology(node)
if explicit_owner:
return explicit_owner
longest_namespace: Optional[str] = None
for candidate in known_ontology_uris:
stem = candidate.rstrip("#/")
if not nid.startswith((stem + "#", stem + "/")):
continue
if longest_namespace is None or len(candidate) > len(longest_namespace):
longest_namespace = candidate
if longest_namespace is None:
return None
# Prefix ownership only extends to names minted directly in the namespace.
# A further delimiter marks a nested vocabulary, which stays unowned until
# it is registered or carries an explicit owner.
local_name = nid[len(longest_namespace.rstrip("#/")) + 1 :]
if "#" in local_name or "/" in local_name:
return None
return longest_namespace
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
return _classify_node_type(node.get("type", "")) in {"class", "property", "concept", "scheme"}
@@ -2002,7 +1952,6 @@ async def get_ontology_graph(
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
async def get_entity_detail(
entity_uri: str,
request: Request,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, entity_uri)
@@ -2024,21 +1973,12 @@ async def get_entity_detail(
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
# Ownership must use the same candidate set as /graph, so it goes through the
# same helper rather than being derived from all_nodes above: that scan is
# capped at 999,999, and on a larger graph a truncated set would silently
# drop ontologies and make the two endpoints disagree about who owns a node.
# The helper iterates only the ontology node types, so it is not a full scan.
known_ontology_uris = await asyncio.to_thread(
_known_ontology_uris, session, _get_registry(request)
)
return EntityDetailResponse(
uri=entity_uri, label=label,
type=ntype, entity_type=_classify_node_type(ntype),
definition=definition,
source_ontology=props.get("scheme_uri"),
owning_ontology=_resolve_owning_ontology(node, known_ontology_uris),
superclasses=superclasses, subclasses=subclasses,
domain=domain, range=range_,
instance_count=instance_count, properties=props,
-84
View File
@@ -17,7 +17,6 @@ from semantica.explorer.routes.ontology import ( # noqa: E402
OntologyEntry,
_convert_ontology_to_graph,
_node_belongs_to_ontology,
_resolve_owning_ontology,
)
from semantica.explorer.session import GraphSession # noqa: E402
@@ -260,89 +259,6 @@ def test_node_belongs_to_ontology_nested_namespace_matrix():
assert _node_belongs_to_ontology(node(f"{child}/Term"), child, {parent, child})
def test_entity_detail_reports_explicit_owner(client):
response = client.get(
f"/api/ontology/entity/{quote('http://example.org/onto-a#Person', safe='')}"
)
assert response.status_code == 200
payload = response.json()
assert payload["source_ontology"] == "http://example.org/onto-a"
assert payload["owning_ontology"] == "http://example.org/onto-a"
def test_entity_detail_reports_namespace_owner_without_explicit_scheme(client):
graph = client.app.state.session.graph
minted_directly = "http://example.org/onto-a#Address"
graph.add_node(minted_directly, node_type="owl:Class", content="Address")
response = client.get(f"/api/ontology/entity/{quote(minted_directly, safe='')}")
assert response.status_code == 200
assert response.json()["owning_ontology"] == "http://example.org/onto-a"
def test_entity_detail_reports_no_owner_for_unregistered_nested_namespace(client):
graph = client.app.state.session.graph
nested_term = "http://example.org/onto-a/nested#Term"
graph.add_node(nested_term, node_type="owl:Class", content="Nested Term")
response = client.get(f"/api/ontology/entity/{quote(nested_term, safe='')}")
assert response.status_code == 200
# onto-a must not claim a nested vocabulary its own /graph response
# excludes, or a deep link selects onto-a and then finds nothing to select.
assert response.json()["owning_ontology"] is None
def test_entity_detail_reports_an_explicit_owner_outside_the_registry(client):
graph = client.app.state.session.graph
borrowed = "http://example.org/onto-a#Borrowed"
unregistered_owner = "http://unregistered.example/vocab"
# Sits directly in onto-a's namespace, so the namespace rule has an answer
# ready — the node's own scheme_uri still has to win, or /entity reports an
# owner that contradicts the node and the editor opens the wrong ontology.
graph.add_node(
borrowed,
node_type="owl:Class",
content="Borrowed",
scheme_uri=unregistered_owner,
)
response = client.get(f"/api/ontology/entity/{quote(borrowed, safe='')}")
assert response.status_code == 200
assert response.json()["owning_ontology"] == unregistered_owner
def test_owner_resolution_agrees_with_graph_membership(client):
"""_resolve_owning_ontology and _node_belongs_to_ontology must not diverge.
The two answer the same question from opposite directions, and /entity and
/graph each use one of them. If they disagree, a deep link opens an ontology
whose graph then excludes the entity it was opened for.
"""
parent = "http://example.org/onto-a"
nested = "http://example.org/onto-a/nested"
known = {parent, nested}
cases = [
{"id": parent},
{"id": f"{parent}#Direct"},
{"id": f"{parent}/Direct"},
{"id": f"{nested}#Term"},
{"id": f"{parent}/unregistered#Term"},
{"id": "http://elsewhere.example/Thing"},
{"id": f"{parent}#Explicit", "properties": {"scheme_uri": nested}},
]
for node in cases:
owner = _resolve_owning_ontology(node, known)
for candidate in known:
assert _node_belongs_to_ontology(node, candidate, known) == (
owner == candidate
), f"{node['id']} vs {candidate}: owner={owner}"
def test_load_fallback_import_without_declaration_is_editable(client):
turtle = """
@prefix ex: <http://data.example.org/people#> .