mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-12 04:01:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d550efde |
@@ -155,20 +155,12 @@ jobs:
|
||||
|
||||
# Vulnerability IDs reviewed and accepted as non-actionable for this
|
||||
# project:
|
||||
# - GHSA-4j2p-28q2-5m79 (aka CVE-2026-69112): accelerate<=1.14.0
|
||||
# (transitive via docling-slim). Path traversal in sharded checkpoint
|
||||
# index loading (load_checkpoint_in_model). 1.14.0 is the latest
|
||||
# available PyPI release; no upstream patch exists yet. Semantica does
|
||||
# not load arbitrary user checkpoints. Re-evaluate once accelerate
|
||||
# releases a fixed version.
|
||||
# NOTE: pip-audit's OSV-backed report may surface either identifier as
|
||||
# the primary `id` (with the other listed under `aliases`) depending on
|
||||
# which alias the backing database picks as canonical, so both need to
|
||||
# be listed here and the matching below checks aliases too - see
|
||||
# https://github.com/semantica-agi/semantica/actions/runs/34296586683
|
||||
# where this ignore list had only the GHSA id but the report's `id`
|
||||
# was the CVE, so the gate still failed.
|
||||
IGNORED_VULN_IDS="GHSA-4j2p-28q2-5m79,CVE-2026-69112"
|
||||
# - GHSA-4j2p-28q2-5m79: accelerate<=1.14.0 (transitive via docling-slim).
|
||||
# Path traversal in sharded checkpoint index loading (load_checkpoint_in_model).
|
||||
# 1.14.0 is the latest available PyPI release; no upstream patch exists yet.
|
||||
# Semantica does not load arbitrary user checkpoints. Re-evaluate once
|
||||
# accelerate releases a fixed version.
|
||||
IGNORED_VULN_IDS="GHSA-4j2p-28q2-5m79"
|
||||
|
||||
# Exported so the "Comment PR with Security Results" step below can
|
||||
# apply the same exclusion list to the raw report - it reads
|
||||
@@ -184,16 +176,9 @@ jobs:
|
||||
# no vulns field at all (see the skip_reason handling above) -
|
||||
# without the fallback, iterating `null[]` raises inside jq and
|
||||
# this whole computation silently evaluates to empty.
|
||||
#
|
||||
# Matching checks `.id` AND `.aliases` (pip-audit includes aliases by
|
||||
# default for JSON output): the OSV-backed report can surface either
|
||||
# the GHSA or the CVE identifier as the canonical `id` for the same
|
||||
# advisory, with the other one demoted to an alias, so matching on
|
||||
# `.id` alone is not reliable.
|
||||
VULNS=$(jq --arg ignored "$IGNORED_VULN_IDS" '
|
||||
($ignored | split(",") | map(select(length > 0))) as $ignore_list
|
||||
| [.dependencies[] | (.vulns // [])[]
|
||||
| select(([.id] + (.aliases // [])) as $ids | ($ids - $ignore_list | length) == ($ids | length))]
|
||||
| [.dependencies[] | (.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)]
|
||||
| length
|
||||
' pip-audit-report.json 2>/dev/null)
|
||||
|
||||
@@ -214,8 +199,7 @@ jobs:
|
||||
jq --arg ignored "$IGNORED_VULN_IDS" -r '
|
||||
($ignored | split(",") | map(select(length > 0))) as $ignore_list
|
||||
| .dependencies[] as $dependency
|
||||
| ($dependency.vulns // [])[]
|
||||
| select(([.id] + (.aliases // [])) as $ids | ($ids - $ignore_list | length) == ($ids | length))
|
||||
| ($dependency.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)
|
||||
| "- \($dependency.name)==\($dependency.version): \(.id)"
|
||||
' pip-audit-report.json || true
|
||||
exit 1
|
||||
@@ -361,16 +345,9 @@ jobs:
|
||||
return null;
|
||||
}
|
||||
|
||||
// A vuln's canonical `id` and its `aliases` (e.g. GHSA vs. CVE
|
||||
// for the same advisory) are checked together - mirrors the
|
||||
// shell gate above, which needs the same fallback because
|
||||
// pip-audit's OSV-backed report doesn't consistently pick the
|
||||
// same identifier as canonical across advisories.
|
||||
return data.dependencies.flatMap((dependency) =>
|
||||
(dependency.vulns || [])
|
||||
.filter((vulnerability) =>
|
||||
![vulnerability.id, ...(vulnerability.aliases || [])].some((id) => ignoredVulnIds.includes(id))
|
||||
)
|
||||
.filter((vulnerability) => !ignoredVulnIds.includes(vulnerability.id))
|
||||
.map(
|
||||
(vulnerability) => `- \`${dependency.name}==${dependency.version}\`: ${vulnerability.id}` +
|
||||
(vulnerability.fix_versions?.length ? ` (fixed by ${vulnerability.fix_versions.join(', ')})` : '')
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ RUN mkdir -p /app/semantica && npm run build
|
||||
# `pip index versions gensim` / the project's PyPI files page, not just
|
||||
# whether `uv pip compile` resolves (resolution only reads sdist metadata,
|
||||
# it doesn't attempt the build that fails here).
|
||||
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS runtime
|
||||
FROM python:3.13-slim@sha256:7ce4b6dfe35e55397b7cda544f8a13f191b7ae28dc5aad71fe664dbc9bc2623f AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
@@ -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/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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { DEFAULT_MIN_DATE, resolvePlayStepMs, resolveScrubberBounds } from "./temporalScrubberBounds";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
@@ -12,9 +11,11 @@ export interface TimelinePanelProps {
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const DEFAULT_MAX_DATE = new Date("2030-01-01T00:00:00Z");
|
||||
const PLAYHEAD_ID = "playhead";
|
||||
const PLAY_INTERVAL_MS = 500;
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
const PLAY_STEP_MONTHS = 6;
|
||||
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
@@ -53,6 +54,12 @@ const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
`;
|
||||
|
||||
function safeDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function formatPlayheadLabel(value: Date): string {
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -65,13 +72,9 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [displayDate, setDisplayDate] = useState(formatPlayheadLabel(DEFAULT_MIN_DATE));
|
||||
|
||||
// Captured once per mount so re-renders keep the same reference and do not
|
||||
// retrigger the timeline effect below.
|
||||
const now = useMemo(() => new Date(), []);
|
||||
const { minBound, maxBound, defaultTime } = useMemo(
|
||||
() => resolveScrubberBounds({ minDate, maxDate, now }),
|
||||
[maxDate, minDate, now],
|
||||
);
|
||||
const minBound = useMemo(() => safeDate(minDate, DEFAULT_MIN_DATE), [minDate]);
|
||||
const maxBound = useMemo(() => safeDate(maxDate, DEFAULT_MAX_DATE), [maxDate]);
|
||||
const defaultTime = useMemo(() => new Date(Math.round((minBound.getTime() + maxBound.getTime()) / 2)), [maxBound, minBound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -88,10 +91,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
showCurrentTime: false,
|
||||
zoomable: true,
|
||||
moveable: true,
|
||||
zoomMin: ONE_DAY_MS,
|
||||
zoomMin: 1000 * 60 * 60 * 24 * 365,
|
||||
zoomMax: 1000 * 60 * 60 * 24 * 365 * 80,
|
||||
showMajorLabels: true,
|
||||
showMinorLabels: true,
|
||||
timeAxis: { scale: "year", step: 5 },
|
||||
format: { minorLabels: { year: "YYYY" }, majorLabels: { year: "YYYY" } },
|
||||
orientation: { axis: "bottom" },
|
||||
margin: { item: 0, axis: 0 },
|
||||
selectable: false,
|
||||
@@ -129,7 +134,8 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
playIntervalRef.current = setInterval(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const next = new Date(playheadRef.current.getTime() + resolvePlayStepMs(minBound, maxBound));
|
||||
const next = new Date(playheadRef.current);
|
||||
next.setMonth(next.getMonth() + PLAY_STEP_MONTHS);
|
||||
if (next >= maxBound) {
|
||||
next.setTime(minBound.getTime());
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const temporalOverlayPlugin: GraphPlugin = {
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Bounds</span>
|
||||
<span style={detailValueStyle}>
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "now")}
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "2030")}
|
||||
</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
export interface ScrubberBoundsInput {
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface ScrubberBounds {
|
||||
minBound: Date;
|
||||
maxBound: Date;
|
||||
defaultTime: Date;
|
||||
}
|
||||
|
||||
export const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
const PLAY_FRAMES = 60;
|
||||
|
||||
function parseBound(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function clamp(value: Date, minBound: Date, maxBound: Date): Date {
|
||||
if (value < minBound) return minBound;
|
||||
if (value > maxBound) return maxBound;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/temporal/bounds` reports `max: null` for graphs whose nodes carry
|
||||
* `valid_from` instants and no `valid_until`, which is the common case rather
|
||||
* than malformed data. Such a graph is known up to the present and no further,
|
||||
* so `now` is the honest upper bound and the honest starting playhead.
|
||||
*/
|
||||
export function resolveScrubberBounds({ minDate, maxDate, now }: ScrubberBoundsInput): ScrubberBounds {
|
||||
const minBound = parseBound(minDate, DEFAULT_MIN_DATE);
|
||||
const maxBound = parseBound(maxDate, now);
|
||||
const orderedMax = maxBound > minBound ? maxBound : minBound;
|
||||
return { minBound, maxBound: orderedMax, defaultTime: clamp(now, minBound, orderedMax) };
|
||||
}
|
||||
|
||||
/** Keeps a play-through at ~PLAY_FRAMES steps whatever the span, with a one-day floor. */
|
||||
export function resolvePlayStepMs(minBound: Date, maxBound: Date): number {
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
return Math.max(ONE_DAY_MS, Math.round(span / PLAY_FRAMES));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_MIN_DATE,
|
||||
resolvePlayStepMs,
|
||||
resolveScrubberBounds,
|
||||
} from "../src/workspaces/GraphWorkspace/temporalScrubberBounds.ts";
|
||||
|
||||
const NOW = new Date("2026-09-09T10:30:00Z");
|
||||
|
||||
// ── resolveScrubberBounds ────────────────────────────────────────────────────
|
||||
|
||||
test("scrubber bounds: open max ends the window at now, not at a future year", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "2026-01-15T00:00:00Z", now: NOW });
|
||||
|
||||
assert.equal(minBound.toISOString(), "2026-01-15T00:00:00.000Z");
|
||||
assert.equal(
|
||||
maxBound.getTime(),
|
||||
NOW.getTime(),
|
||||
"a graph carrying only valid_from instants is known up to the present and no further",
|
||||
);
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead starts at now so the first snapshot describes the present", () => {
|
||||
const { defaultTime } = resolveScrubberBounds({ minDate: "2026-01-15T00:00:00Z", now: NOW });
|
||||
|
||||
assert.equal(defaultTime.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead is not the midpoint of the range", () => {
|
||||
const { minBound, maxBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2020-01-01T00:00:00Z",
|
||||
maxDate: "2030-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
const midpoint = Math.round((minBound.getTime() + maxBound.getTime()) / 2);
|
||||
|
||||
assert.notEqual(defaultTime.getTime(), midpoint, "the midpoint was the source of the future start time");
|
||||
assert.equal(defaultTime.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: reported max is honoured when the data supplies one", () => {
|
||||
const { maxBound } = resolveScrubberBounds({
|
||||
minDate: "2020-01-01T00:00:00Z",
|
||||
maxDate: "2030-06-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(maxBound.toISOString(), "2030-06-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead clamps into a range that ends before now", () => {
|
||||
const { maxBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2019-01-01T00:00:00Z",
|
||||
maxDate: "2020-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(defaultTime.getTime(), maxBound.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead clamps into a range that starts after now", () => {
|
||||
const { minBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2030-01-01T00:00:00Z",
|
||||
maxDate: "2031-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(defaultTime.getTime(), minBound.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: min ahead of an open max keeps the window ordered", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "2031-01-01T00:00:00Z", now: NOW });
|
||||
|
||||
assert.ok(maxBound >= minBound, "vis-timeline requires min <= max");
|
||||
});
|
||||
|
||||
test("scrubber bounds: malformed and missing dates fall back without producing Invalid Date", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "not-a-date", maxDate: "also-bad", now: NOW });
|
||||
|
||||
assert.equal(minBound.getTime(), DEFAULT_MIN_DATE.getTime());
|
||||
assert.equal(maxBound.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
// ── resolvePlayStepMs ────────────────────────────────────────────────────────
|
||||
|
||||
test("play step: a one-year span advances in ~60 frames, not 2", () => {
|
||||
const minBound = new Date("2026-01-01T00:00:00Z");
|
||||
const maxBound = new Date("2027-01-01T00:00:00Z");
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
|
||||
const frames = span / resolvePlayStepMs(minBound, maxBound);
|
||||
|
||||
assert.ok(frames > 50 && frames < 70, `expected ~60 frames, got ${frames}`);
|
||||
});
|
||||
|
||||
test("play step: a decade-long span also advances in ~60 frames", () => {
|
||||
const minBound = new Date("2016-01-01T00:00:00Z");
|
||||
const maxBound = new Date("2026-01-01T00:00:00Z");
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
|
||||
const frames = span / resolvePlayStepMs(minBound, maxBound);
|
||||
|
||||
assert.ok(frames > 50 && frames < 70, `expected ~60 frames, got ${frames}`);
|
||||
});
|
||||
|
||||
test("play step: a span of hours still advances by at least a day", () => {
|
||||
const minBound = new Date("2026-09-09T00:00:00Z");
|
||||
const maxBound = new Date("2026-09-09T06:00:00Z");
|
||||
|
||||
assert.equal(resolvePlayStepMs(minBound, maxBound), 1000 * 60 * 60 * 24);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
# OSV-Scanner ignore config (also consumed by OpenSSF Scorecard's
|
||||
# "Vulnerabilities" check, which reports advisories found in this repo's
|
||||
# dependency manifests via https://osv.dev).
|
||||
#
|
||||
# See https://github.com/google/osv-scanner#ignore-vulnerabilities-by-id for
|
||||
# the file format.
|
||||
|
||||
[[IgnoredVulns]]
|
||||
id = "GHSA-4j2p-28q2-5m79"
|
||||
reason = """
|
||||
accelerate<=1.14.0 (transitive dependency via docling-slim, pinned in
|
||||
requirements-ci.txt) has an open path traversal / DoS advisory (also tracked
|
||||
as CVE-2026-69112) in load_checkpoint_in_model / load_checkpoint_and_dispatch,
|
||||
which fail to sanitize weight_map entries from sharded checkpoint indexes.
|
||||
1.14.0 is the latest release on PyPI; no patched version exists yet.
|
||||
Semantica does not call either function or load arbitrary/untrusted sharded
|
||||
checkpoints, so the vulnerable code path is not reachable. Re-evaluate once
|
||||
accelerate ships a fix - see .github/workflows/security-scan.yml for the
|
||||
matching pip-audit exclusion.
|
||||
"""
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,7 +68,6 @@ License: MIT
|
||||
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
@@ -143,14 +142,6 @@ class VectorStore:
|
||||
if self.backend == "inmemory":
|
||||
self.vectors: Dict[str, np.ndarray] = {}
|
||||
self.metadata: Dict[str, Dict[str, Any]] = {}
|
||||
# Monotonic counter for default ID generation. Never decremented
|
||||
# on deletion, so IDs generated by consecutive store_vectors calls
|
||||
# can never collide with surviving IDs (fixes #1029).
|
||||
self._next_id: int = 0
|
||||
# Reentrant lock protecting all in-memory state mutations:
|
||||
# _next_id, vectors, metadata, and index rebuilds. Matches the
|
||||
# threading model used by SQLiteVecStore and AgentMemory.
|
||||
self._inmemory_lock = threading.RLock()
|
||||
|
||||
# Initialize backend-specific indexer
|
||||
# Avoid duplicate dimension argument
|
||||
@@ -566,47 +557,18 @@ class VectorStore:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Storing vectors..."
|
||||
)
|
||||
# Hold the lock for the entire ID-allocation → dict-write →
|
||||
# index-rebuild sequence so concurrent callers cannot observe
|
||||
# half-written state or generate the same candidate ID.
|
||||
with self._inmemory_lock:
|
||||
# Snapshot the live key-set at lock-entry so the generator
|
||||
# and the within-batch de-dupe use a consistent view.
|
||||
pre_existing = set(self.vectors)
|
||||
# within_batch tracks IDs chosen during *this* call so the
|
||||
# same candidate is never returned twice in one batch.
|
||||
within_batch: set = set()
|
||||
start_idx = len(self.vectors)
|
||||
for i, (vector, meta) in enumerate(zip(vectors, metadata)):
|
||||
vector_id = f"vec_{start_idx + i}"
|
||||
self.vectors[vector_id] = vector
|
||||
self.metadata[vector_id] = meta
|
||||
vector_ids.append(vector_id)
|
||||
|
||||
for vector, meta in zip(vectors, metadata):
|
||||
# Advance the monotonic counter until we find a candidate
|
||||
# that is free both in the live store and in this batch.
|
||||
#
|
||||
# The counter is never decremented on deletion, so under
|
||||
# normal operation every candidate it produces is genuinely
|
||||
# fresh. The only reason a candidate can be occupied is
|
||||
# that a caller pre-inserted a ``vec_N`` key ahead of the
|
||||
# counter (e.g. manually writing to self.vectors). Skipping
|
||||
# over such keys is intentional and matches FAISSStore's
|
||||
# identical behaviour. Nothing is overwritten: the loop
|
||||
# breaks only on a candidate that is absent from both
|
||||
# pre_existing and within_batch.
|
||||
while True:
|
||||
candidate = f"vec_{self._next_id}"
|
||||
self._next_id += 1
|
||||
if candidate not in pre_existing and candidate not in within_batch:
|
||||
break
|
||||
|
||||
within_batch.add(candidate)
|
||||
self.vectors[candidate] = vector
|
||||
self.metadata[candidate] = meta
|
||||
vector_ids.append(candidate)
|
||||
|
||||
# Update index inside the lock so readers always see a
|
||||
# consistent (vectors, index) pair.
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Updating vector index..."
|
||||
)
|
||||
self.indexer.create_index(list(self.vectors.values()), list(self.vectors.keys()))
|
||||
# Update index
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Updating vector index..."
|
||||
)
|
||||
self.indexer.create_index(list(self.vectors.values()), vector_ids)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
@@ -648,11 +610,7 @@ class VectorStore:
|
||||
"metadata": getattr(self, "metadata", {}),
|
||||
"config": self.config,
|
||||
"backend": self.backend,
|
||||
"dimension": self.dimension,
|
||||
# Persist the monotonic counter so that load() can restore it
|
||||
# rather than re-deriving it from len(vectors), which would be
|
||||
# too small after a deletion and cause ID collisions (issue #1029).
|
||||
"next_id": getattr(self, "_next_id", None),
|
||||
"dimension": self.dimension
|
||||
}
|
||||
|
||||
with open(os.path.join(path, "store_data.json"), "w", encoding="utf-8") as f:
|
||||
@@ -699,25 +657,6 @@ class VectorStore:
|
||||
self.config = data.get("config", {})
|
||||
self.backend = data.get("backend", "faiss")
|
||||
self.dimension = data.get("dimension", 768)
|
||||
|
||||
# Restore the monotonic ID counter. Always clamp to at least
|
||||
# max(vec_N suffix)+1 so a stale or missing persisted value (e.g.
|
||||
# written before this field was added, or written before a deletion
|
||||
# that lowered the count) cannot produce IDs that collide with
|
||||
# existing vectors (issue #1029).
|
||||
if self.backend == "inmemory":
|
||||
_vec_nums = [
|
||||
int(v[4:]) + 1
|
||||
for v in self.vectors
|
||||
if v.startswith("vec_") and v[4:].isdigit()
|
||||
]
|
||||
_inferred = max(_vec_nums) if _vec_nums else 0
|
||||
persisted_next_id = data.get("next_id")
|
||||
if persisted_next_id is not None:
|
||||
self._next_id = max(int(persisted_next_id), _inferred)
|
||||
else:
|
||||
# Older store files lack this field; use the safe inferred value.
|
||||
self._next_id = _inferred
|
||||
|
||||
# Restore backend-specific index
|
||||
indexer = getattr(self, "indexer", None)
|
||||
@@ -798,25 +737,14 @@ class VectorStore:
|
||||
)
|
||||
return []
|
||||
|
||||
# Snapshot vectors and metadata together under the lock so a
|
||||
# concurrent delete_vectors / store_vectors cannot cause
|
||||
# "RuntimeError: dictionary changed size during iteration" and
|
||||
# cannot produce an inconsistent (values, keys) pair where one
|
||||
# list is shorter than the other. The lock is released before
|
||||
# the (potentially slow) similarity computation.
|
||||
with self._inmemory_lock:
|
||||
snapshot_vectors = list(self.vectors.values())
|
||||
snapshot_keys = list(self.vectors.keys())
|
||||
snapshot_metadata = dict(self.metadata)
|
||||
|
||||
# Use retriever for similarity search
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Performing similarity search..."
|
||||
)
|
||||
results = self.retriever.search_similar(
|
||||
query_vector,
|
||||
snapshot_vectors,
|
||||
snapshot_keys,
|
||||
list(self.vectors.values()),
|
||||
list(self.vectors.keys()),
|
||||
k=k,
|
||||
**options,
|
||||
)
|
||||
@@ -824,8 +752,8 @@ class VectorStore:
|
||||
# Add metadata to results; guarantee the key always exists.
|
||||
for result in results:
|
||||
vector_id = result.get("id")
|
||||
if vector_id and vector_id in snapshot_metadata:
|
||||
result["metadata"] = snapshot_metadata[vector_id]
|
||||
if vector_id and vector_id in self.metadata:
|
||||
result["metadata"] = self.metadata[vector_id]
|
||||
elif "metadata" not in result:
|
||||
result["metadata"] = {}
|
||||
|
||||
@@ -854,21 +782,19 @@ class VectorStore:
|
||||
else:
|
||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not have update or update_vectors method")
|
||||
|
||||
with self._inmemory_lock:
|
||||
for vec_id, new_vec in zip(vector_ids, new_vectors):
|
||||
if vec_id in self.vectors:
|
||||
self.vectors[vec_id] = new_vec
|
||||
for vec_id, new_vec in zip(vector_ids, new_vectors):
|
||||
if vec_id in self.vectors:
|
||||
self.vectors[vec_id] = new_vec
|
||||
|
||||
if metadata:
|
||||
for vec_id, meta in zip(vector_ids, metadata):
|
||||
if vec_id in self.metadata:
|
||||
self.metadata[vec_id] = meta
|
||||
if metadata:
|
||||
for vec_id, meta in zip(vector_ids, metadata):
|
||||
if vec_id in self.metadata:
|
||||
self.metadata[vec_id] = meta
|
||||
|
||||
# Rebuild index under the lock so readers see a consistent
|
||||
# (vectors, index) pair, matching store_vectors and delete_vectors.
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
# Rebuild index
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -883,17 +809,15 @@ class VectorStore:
|
||||
else:
|
||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not have delete or delete_vectors method")
|
||||
|
||||
with self._inmemory_lock:
|
||||
for vec_id in vector_ids:
|
||||
self.vectors.pop(vec_id, None)
|
||||
self.metadata.pop(vec_id, None)
|
||||
for vec_id in vector_ids:
|
||||
self.vectors.pop(vec_id, None)
|
||||
self.metadata.pop(vec_id, None)
|
||||
|
||||
# Rebuild index under the lock so a concurrent search cannot
|
||||
# see vectors without a corresponding index entry.
|
||||
if self.vectors:
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
# Rebuild index
|
||||
if self.vectors:
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -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#> .
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
"""Regression tests for issue #1029: VectorStore inmemory backend must not
|
||||
reissue live vector IDs after a deletion.
|
||||
|
||||
Before the fix, `store_vectors()` derived new IDs as
|
||||
``f"vec_{len(self.vectors) + i}"``. After any deletion `len` decreases,
|
||||
so the next insertion generates an ID that already belongs to a surviving
|
||||
vector, silently overwriting its embedding and metadata.
|
||||
|
||||
Three test groups:
|
||||
|
||||
1. ``TestInmemoryIdNoReuseAfterDelete`` — direct VectorStore path
|
||||
2. ``TestAgentMemoryIdNoReuseAfterDelete`` — AgentMemory path
|
||||
3. ``TestInmemoryIdPersistence`` — save / load / delete / store cycle
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build a lightweight VectorStore(backend="inmemory") without
|
||||
# triggering the real EmbeddingGenerator or VectorIndexer.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_store(dim: int = 4) -> "VectorStore": # noqa: F821
|
||||
"""Return an inmemory VectorStore with heavy components mocked out."""
|
||||
from semantica.vector_store.vector_store import VectorStore
|
||||
|
||||
with patch("semantica.vector_store.vector_store.get_logger",
|
||||
return_value=MagicMock()), \
|
||||
patch("semantica.vector_store.vector_store.get_progress_tracker",
|
||||
return_value=MagicMock()), \
|
||||
patch("semantica.vector_store.vector_store.VectorIndexer"), \
|
||||
patch("semantica.vector_store.vector_store.VectorRetriever"), \
|
||||
patch("semantica.vector_store.vector_store.EmbeddingGenerator"):
|
||||
store = VectorStore(backend="inmemory", config={"dimension": dim})
|
||||
store.embedder = None
|
||||
return store
|
||||
|
||||
|
||||
_RNG = np.random.default_rng(seed=1029)
|
||||
|
||||
|
||||
def _vec(dim: int = 4) -> np.ndarray:
|
||||
return _RNG.random(dim).astype(np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Direct VectorStore path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInmemoryIdNoReuseAfterDelete(unittest.TestCase):
|
||||
"""Store A+B, delete A, store C — B and C must have distinct IDs and both
|
||||
survive with correct metadata."""
|
||||
|
||||
def _store(self) -> "VectorStore": # noqa: F821
|
||||
return _make_store()
|
||||
|
||||
def test_b_and_c_have_different_ids(self):
|
||||
"""Core regression: id_c must not equal id_b."""
|
||||
store = self._store()
|
||||
id_a, id_b = store.store_vectors(
|
||||
[_vec(), _vec()], [{"label": "A"}, {"label": "B"}]
|
||||
)
|
||||
store.delete_vectors([id_a])
|
||||
(id_c,) = store.store_vectors([_vec()], [{"label": "C"}])
|
||||
|
||||
self.assertNotEqual(
|
||||
id_b, id_c,
|
||||
f"ID collision: both B and C got id={id_b!r}",
|
||||
)
|
||||
|
||||
def test_both_vectors_remain_after_delete_reinsert(self):
|
||||
"""B's entry must survive the deletion of A and the insertion of C."""
|
||||
store = self._store()
|
||||
id_a, id_b = store.store_vectors(
|
||||
[_vec(), _vec()], [{"label": "A"}, {"label": "B"}]
|
||||
)
|
||||
store.delete_vectors([id_a])
|
||||
(id_c,) = store.store_vectors([_vec()], [{"label": "C"}])
|
||||
|
||||
self.assertIn(id_b, store.vectors, "B's vector was lost")
|
||||
self.assertIn(id_c, store.vectors, "C's vector was not stored")
|
||||
self.assertIn(id_b, store.metadata, "B's metadata was lost")
|
||||
self.assertIn(id_c, store.metadata, "C's metadata was not stored")
|
||||
|
||||
def test_count_is_two_after_store_delete_store(self):
|
||||
"""After storing 2, deleting 1, storing 1 the count must be 2."""
|
||||
store = self._store()
|
||||
id_a, _ = store.store_vectors(
|
||||
[_vec(), _vec()], [{}, {}]
|
||||
)
|
||||
store.delete_vectors([id_a])
|
||||
store.store_vectors([_vec()], [{}])
|
||||
|
||||
self.assertEqual(len(store.vectors), 2)
|
||||
|
||||
def test_b_metadata_not_overwritten_by_c(self):
|
||||
"""B's metadata must be unchanged after C is stored."""
|
||||
store = self._store()
|
||||
id_a, id_b = store.store_vectors(
|
||||
[_vec(), _vec()],
|
||||
[{"label": "A"}, {"label": "B", "sentinel": True}],
|
||||
)
|
||||
store.delete_vectors([id_a])
|
||||
store.store_vectors([_vec()], [{"label": "C"}])
|
||||
|
||||
self.assertEqual(
|
||||
store.metadata[id_b],
|
||||
{"label": "B", "sentinel": True},
|
||||
"B's metadata was silently overwritten",
|
||||
)
|
||||
|
||||
def test_id_uniqueness_across_multiple_delete_reinsert_cycles(self):
|
||||
"""Each cycle of delete-then-store must produce a fresh, unique ID."""
|
||||
store = self._store()
|
||||
seen_ids: set = set()
|
||||
|
||||
# Initial batch
|
||||
batch = store.store_vectors([_vec() for _ in range(3)], [{} for _ in range(3)])
|
||||
seen_ids.update(batch)
|
||||
|
||||
# Three delete-then-store cycles
|
||||
for current_id in list(batch):
|
||||
store.delete_vectors([current_id])
|
||||
(new_id,) = store.store_vectors([_vec()], [{}])
|
||||
self.assertNotIn(
|
||||
new_id, seen_ids,
|
||||
f"Generated ID {new_id!r} collides with a previously used ID",
|
||||
)
|
||||
seen_ids.add(new_id)
|
||||
|
||||
def test_counter_skips_explicit_vec_n_ids(self):
|
||||
"""The monotonic counter must skip over any explicit ``vec_N`` already
|
||||
present so it never collides with a manually supplied ID."""
|
||||
store = self._store()
|
||||
|
||||
# Manually insert vec_1 so the counter must skip it
|
||||
store.vectors["vec_1"] = _vec()
|
||||
store.metadata["vec_1"] = {"explicit": True}
|
||||
|
||||
# Ask for two auto-generated IDs; one would be vec_1 if the counter
|
||||
# did not skip it.
|
||||
ids = store.store_vectors([_vec(), _vec()], [{}, {}])
|
||||
|
||||
self.assertNotIn("vec_1", ids, "Monotonic counter re-generated an explicit ID")
|
||||
# vec_1's explicit entry must be intact
|
||||
self.assertEqual(store.metadata["vec_1"], {"explicit": True})
|
||||
# Both new vectors must actually be in the store
|
||||
for new_id in ids:
|
||||
self.assertIn(new_id, store.vectors)
|
||||
|
||||
def test_auto_generated_id_never_silently_overwrites_live_vector(self):
|
||||
"""An automatically generated ID must never land on top of a live
|
||||
auto-generated vector, regardless of deletion history.
|
||||
|
||||
This is the core of 'Never overwrite an existing live vector id
|
||||
silently' from issue #1029: after any sequence of stores and deletes
|
||||
every auto-generated ID must map to exactly one vector.
|
||||
"""
|
||||
store = self._store()
|
||||
# Store 5 vectors — auto-ids vec_0..vec_4
|
||||
first_batch = store.store_vectors([_vec() for _ in range(5)], [{} for _ in range(5)])
|
||||
# Delete vec_0, vec_1, vec_2 — _next_id stays at 5, so the next
|
||||
# auto-id should be vec_5, vec_6 … NOT vec_2/vec_3/vec_4.
|
||||
store.delete_vectors(first_batch[:3])
|
||||
surviving = set(store.vectors.keys()) # {vec_3, vec_4}
|
||||
|
||||
second_batch = store.store_vectors([_vec(), _vec()], [{}, {}])
|
||||
|
||||
# None of the new IDs must collide with surviving ones
|
||||
for new_id in second_batch:
|
||||
self.assertNotIn(
|
||||
new_id, surviving,
|
||||
f"Auto-generated ID {new_id!r} silently landed on a live vector",
|
||||
)
|
||||
# Both new vectors must be independently present
|
||||
for new_id in second_batch:
|
||||
self.assertIn(new_id, store.vectors)
|
||||
self.assertIn(new_id, store.metadata)
|
||||
# Total count: 2 surviving + 2 new
|
||||
self.assertEqual(len(store.vectors), 4)
|
||||
|
||||
def test_collision_detection_no_silent_overwrite_even_with_corrupted_counter(self):
|
||||
"""Even if _next_id is externally wound back (simulating a corrupt
|
||||
load), the generator must never silently overwrite a live vector.
|
||||
|
||||
The while-loop guarantees this by skipping every occupied candidate
|
||||
until it finds a free slot. The existing vector and its metadata
|
||||
must be completely unchanged after the call.
|
||||
"""
|
||||
store = self._store()
|
||||
# Store vec_0 and vec_1 (_next_id advances to 2)
|
||||
ids = store.store_vectors([_vec(), _vec()], [{"orig": 0}, {"orig": 1}])
|
||||
id_b = ids[1] # vec_1
|
||||
vec_b_before = store.vectors[id_b].copy()
|
||||
meta_b_before = dict(store.metadata[id_b])
|
||||
|
||||
# Corrupt the counter: reset to 0 so candidates start at vec_0/vec_1
|
||||
store._next_id = 0
|
||||
|
||||
# store_vectors must succeed without raising and without overwriting
|
||||
new_ids = store.store_vectors([_vec()], [{"new": True}])
|
||||
|
||||
# The new ID must be some other slot — not vec_0 or vec_1
|
||||
self.assertNotIn(
|
||||
new_ids[0], {ids[0], ids[1]},
|
||||
f"New vector landed on a live ID {new_ids[0]!r} "
|
||||
"(no-silent-overwrite invariant violated)",
|
||||
)
|
||||
# vec_1 must be completely unchanged
|
||||
np.testing.assert_array_equal(
|
||||
store.vectors[id_b], vec_b_before,
|
||||
err_msg="Live vector vec_1 was overwritten by the post-corruption store call",
|
||||
)
|
||||
self.assertEqual(
|
||||
store.metadata[id_b], meta_b_before,
|
||||
"Live metadata for vec_1 was overwritten by the post-corruption store call",
|
||||
)
|
||||
# New vector must actually be in the store
|
||||
self.assertIn(new_ids[0], store.vectors)
|
||||
self.assertEqual(store.metadata[new_ids[0]], {"new": True})
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentMemoryIdNoReuseAfterDelete(unittest.TestCase):
|
||||
"""Exercise the ID-collision fix through AgentMemory.store() /
|
||||
delete_memory() rather than VectorStore directly."""
|
||||
|
||||
def setUp(self):
|
||||
"""Build a real VectorStore(inmemory) and bind it to AgentMemory.
|
||||
|
||||
EmbeddingGenerator is mocked so the test doesn't need a model.
|
||||
"""
|
||||
from semantica.context.agent_memory import AgentMemory
|
||||
from semantica.vector_store.vector_store import VectorStore
|
||||
|
||||
self._embedding_patch = patch(
|
||||
"semantica.context.agent_memory.AgentMemory._generate_embedding",
|
||||
side_effect=lambda text: np.ones(8, dtype=np.float32),
|
||||
)
|
||||
self._embedding_patch.start()
|
||||
|
||||
self.store = VectorStore(backend="inmemory", config={"dimension": 8})
|
||||
# Suppress real EmbeddingGenerator on the store itself
|
||||
self.store.embedder = None
|
||||
|
||||
self.memory = AgentMemory(vector_store=self.store)
|
||||
|
||||
def tearDown(self):
|
||||
self._embedding_patch.stop()
|
||||
|
||||
def test_b_and_c_have_different_vector_ids(self):
|
||||
"""After storing A+B, deleting A, storing C, B and C must have
|
||||
distinct vector IDs."""
|
||||
self.memory.store("content A", memory_id="mem_a", skip_graph=True)
|
||||
self.memory.store("content B", memory_id="mem_b", skip_graph=True)
|
||||
|
||||
vid_b_before = self.memory.vector_ids_for("mem_b")
|
||||
|
||||
self.memory.delete_memory("mem_a")
|
||||
self.memory.store("content C", memory_id="mem_c", skip_graph=True)
|
||||
|
||||
vid_b = self.memory.vector_ids_for("mem_b")
|
||||
vid_c = self.memory.vector_ids_for("mem_c")
|
||||
|
||||
# B's vector IDs must be unchanged — it was never touched.
|
||||
self.assertEqual(vid_b, vid_b_before, "mem_b's vector IDs changed unexpectedly")
|
||||
self.assertTrue(vid_b, "mem_b has no tracked vector IDs")
|
||||
self.assertTrue(vid_c, "mem_c has no tracked vector IDs")
|
||||
self.assertTrue(
|
||||
set(vid_b).isdisjoint(set(vid_c)),
|
||||
f"B and C share vector IDs: {set(vid_b) & set(vid_c)}",
|
||||
)
|
||||
|
||||
def test_both_memories_remain_independently_retrievable(self):
|
||||
"""mem_b and mem_c must both survive and report correct vector-store
|
||||
embeddings after the delete-reinsert cycle."""
|
||||
self.memory.store("content B", memory_id="mem_b", skip_graph=True)
|
||||
self.memory.store("content A", memory_id="mem_a", skip_graph=True)
|
||||
self.memory.delete_memory("mem_a")
|
||||
self.memory.store("content C", memory_id="mem_c", skip_graph=True)
|
||||
|
||||
self.assertIn("mem_b", self.memory.memory_items)
|
||||
self.assertIn("mem_c", self.memory.memory_items)
|
||||
self.assertNotIn("mem_a", self.memory.memory_items)
|
||||
|
||||
# Each surviving memory must have its own live vector in the store
|
||||
for mid in ("mem_b", "mem_c"):
|
||||
vids = self.memory.vector_ids_for(mid)
|
||||
for vid in vids:
|
||||
self.assertIn(
|
||||
vid, self.store.vectors,
|
||||
f"{mid!r} vector id {vid!r} is missing from the store",
|
||||
)
|
||||
|
||||
def test_vector_store_count_is_correct(self):
|
||||
"""After storing 2, deleting 1, storing 1 the vector count must be 2."""
|
||||
self.memory.store("content A", memory_id="mem_a", skip_graph=True)
|
||||
self.memory.store("content B", memory_id="mem_b", skip_graph=True)
|
||||
self.memory.delete_memory("mem_a")
|
||||
self.memory.store("content C", memory_id="mem_c", skip_graph=True)
|
||||
|
||||
self.assertEqual(self.store.count(), 2)
|
||||
|
||||
def test_b_embedding_not_overwritten(self):
|
||||
"""mem_b's vector must be the original embedding, not C's."""
|
||||
# Give B a distinct embedding so we can detect overwriting
|
||||
call_order: list = []
|
||||
|
||||
def _side_effect(text: str) -> np.ndarray:
|
||||
call_order.append(text)
|
||||
# Unique per-call vector based on call order length
|
||||
v = np.zeros(8, dtype=np.float32)
|
||||
v[len(call_order) % 8] = float(len(call_order))
|
||||
return v
|
||||
|
||||
with patch(
|
||||
"semantica.context.agent_memory.AgentMemory._generate_embedding",
|
||||
side_effect=_side_effect,
|
||||
):
|
||||
mem2 = __import__(
|
||||
"semantica.context.agent_memory", fromlist=["AgentMemory"]
|
||||
).AgentMemory(vector_store=self.store)
|
||||
mem2.store("content A", memory_id="mem_a2", skip_graph=True)
|
||||
mem2.store("content B", memory_id="mem_b2", skip_graph=True)
|
||||
b_embedding = mem2.memory_items["mem_b2"].embedding
|
||||
|
||||
mem2.delete_memory("mem_a2")
|
||||
mem2.store("content C", memory_id="mem_c2", skip_graph=True)
|
||||
|
||||
vid_b = mem2.vector_ids_for("mem_b2")
|
||||
self.assertTrue(vid_b, "mem_b2 has no tracked vector ID")
|
||||
stored_b_vec = self.store.vectors.get(vid_b[0])
|
||||
self.assertIsNotNone(stored_b_vec)
|
||||
np.testing.assert_array_equal(
|
||||
stored_b_vec,
|
||||
b_embedding if hasattr(b_embedding, "__len__") else np.array(b_embedding),
|
||||
err_msg="B's stored vector was silently overwritten by C's embedding",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Persistence: save → load → delete → store must not collide
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInmemoryIdPersistence(unittest.TestCase):
|
||||
"""The _next_id counter must survive save/load so that inserting after a
|
||||
delete-and-reload cycle cannot generate an ID already held by a surviving
|
||||
vector."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def _fresh_store(self, dim: int = 4):
|
||||
return _make_store(dim=dim)
|
||||
|
||||
def test_next_id_is_persisted_in_json(self):
|
||||
"""save() must write ``next_id`` into store_data.json."""
|
||||
store = self._fresh_store()
|
||||
store.store_vectors([_vec(), _vec(), _vec()], [{}, {}, {}])
|
||||
store.save(self.tmpdir)
|
||||
|
||||
with open(f"{self.tmpdir}/store_data.json", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
self.assertIn("next_id", data, "save() did not write 'next_id' to JSON")
|
||||
self.assertGreaterEqual(data["next_id"], 3)
|
||||
|
||||
def test_load_restores_next_id(self):
|
||||
"""load() must restore _next_id so the counter does not restart at 0."""
|
||||
store = self._fresh_store()
|
||||
store.store_vectors([_vec(), _vec(), _vec()], [{}, {}, {}])
|
||||
store.save(self.tmpdir)
|
||||
|
||||
loaded = self._fresh_store()
|
||||
loaded.load(self.tmpdir)
|
||||
|
||||
self.assertGreaterEqual(
|
||||
loaded._next_id, 3,
|
||||
f"load() set _next_id={loaded._next_id}, expected >= 3",
|
||||
)
|
||||
|
||||
def test_no_collision_after_save_load_delete_store(self):
|
||||
"""Full lifecycle: save → load → delete one → store one new vector.
|
||||
The new ID must not collide with any surviving vector."""
|
||||
store = self._fresh_store()
|
||||
# Store vec_0, vec_1, vec_2
|
||||
ids = store.store_vectors([_vec() for _ in range(3)], [{} for _ in range(3)])
|
||||
store.save(self.tmpdir)
|
||||
|
||||
# Load fresh instance
|
||||
loaded = self._fresh_store()
|
||||
loaded.load(self.tmpdir)
|
||||
|
||||
# Delete vec_0 (ntotal drops to 2; without the fix, next id = vec_2)
|
||||
loaded.delete_vectors([ids[0]])
|
||||
surviving = set(loaded.vectors.keys())
|
||||
|
||||
# Store a new vector — must not reuse any surviving ID
|
||||
(new_id,) = loaded.store_vectors([_vec()], [{"new": True}])
|
||||
|
||||
self.assertNotIn(
|
||||
new_id, surviving,
|
||||
f"Generated ID {new_id!r} collides with a surviving ID "
|
||||
f"(surviving={sorted(surviving)})",
|
||||
)
|
||||
self.assertEqual(len(loaded.vectors), 3)
|
||||
|
||||
def test_stale_next_id_in_json_is_clamped(self):
|
||||
"""load() must clamp a stale persisted next_id to at least
|
||||
max(vec_N suffix)+1, guarding against corrupted saves."""
|
||||
store = self._fresh_store()
|
||||
# Stores vec_0, vec_1, vec_2
|
||||
store.store_vectors([_vec() for _ in range(3)], [{}, {}, {}])
|
||||
store.save(self.tmpdir)
|
||||
|
||||
# Corrupt the JSON: set next_id to 1 (below vec_2's suffix+1 = 3)
|
||||
json_path = f"{self.tmpdir}/store_data.json"
|
||||
with open(json_path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
data["next_id"] = 1
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
|
||||
loaded = self._fresh_store()
|
||||
loaded.load(self.tmpdir)
|
||||
|
||||
self.assertGreaterEqual(
|
||||
loaded._next_id, 3,
|
||||
f"Stale next_id=1 was not clamped; got {loaded._next_id} (expected >= 3)",
|
||||
)
|
||||
|
||||
def test_missing_next_id_in_old_json_inferred_from_vec_suffixes(self):
|
||||
"""Older store files without 'next_id' must have the counter inferred
|
||||
from the highest ``vec_N`` suffix so that loading them is safe."""
|
||||
store = self._fresh_store()
|
||||
store.store_vectors([_vec() for _ in range(4)], [{} for _ in range(4)])
|
||||
store.save(self.tmpdir)
|
||||
|
||||
# Remove next_id to simulate an older save file
|
||||
json_path = f"{self.tmpdir}/store_data.json"
|
||||
with open(json_path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
data.pop("next_id", None)
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh)
|
||||
|
||||
loaded = self._fresh_store()
|
||||
loaded.load(self.tmpdir)
|
||||
|
||||
# _next_id must be at least 4 (vec_0..vec_3 → max suffix+1 = 4)
|
||||
self.assertGreaterEqual(
|
||||
loaded._next_id, 4,
|
||||
f"Missing next_id not inferred correctly; got {loaded._next_id}",
|
||||
)
|
||||
|
||||
# And a subsequent insert must not collide
|
||||
surviving = set(loaded.vectors.keys())
|
||||
(new_id,) = loaded.store_vectors([_vec()], [{}])
|
||||
self.assertNotIn(
|
||||
new_id, surviving,
|
||||
f"Post-load insert collided: {new_id!r} already in {sorted(surviving)}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Concurrency: concurrent store_vectors calls must not produce duplicate IDs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInmemoryIdConcurrency(unittest.TestCase):
|
||||
"""Concurrent store_vectors calls on the same VectorStore must each get
|
||||
unique IDs and all vectors must survive (no silent overwrites)."""
|
||||
|
||||
def test_concurrent_store_vectors_produce_unique_ids(self):
|
||||
"""Two threads storing vectors simultaneously must not collide."""
|
||||
import threading as _threading
|
||||
|
||||
store = _make_store(dim=4)
|
||||
results: list = []
|
||||
errors: list = []
|
||||
|
||||
def _store_batch(n: int) -> None:
|
||||
try:
|
||||
ids = store.store_vectors(
|
||||
[_vec() for _ in range(n)],
|
||||
[{"batch": n, "idx": i} for i in range(n)],
|
||||
)
|
||||
results.extend(ids)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [_threading.Thread(target=_store_batch, args=(5,)) for _ in range(6)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertFalse(errors, f"Threads raised: {errors}")
|
||||
total = 6 * 5
|
||||
self.assertEqual(len(results), total, "Some store calls lost vectors")
|
||||
# All returned IDs must be unique — no two threads got the same ID
|
||||
self.assertEqual(
|
||||
len(set(results)), total,
|
||||
f"Duplicate IDs produced under concurrency: "
|
||||
f"{[x for x in results if results.count(x) > 1]}",
|
||||
)
|
||||
# Every returned ID must be present in the store
|
||||
for vid in results:
|
||||
self.assertIn(vid, store.vectors, f"ID {vid!r} not in store after concurrent insert")
|
||||
|
||||
def test_concurrent_delete_and_store_no_phantom_ids(self):
|
||||
"""A thread deleting while another is storing must not leave the store
|
||||
with stale index entries or inconsistent counts."""
|
||||
import threading as _threading
|
||||
|
||||
store = _make_store(dim=4)
|
||||
initial = store.store_vectors([_vec() for _ in range(4)], [{} for _ in range(4)])
|
||||
errors: list = []
|
||||
|
||||
def _deleter():
|
||||
try:
|
||||
store.delete_vectors(initial[:2])
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
def _storer():
|
||||
try:
|
||||
store.store_vectors([_vec(), _vec()], [{}, {}])
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t1 = _threading.Thread(target=_deleter)
|
||||
t2 = _threading.Thread(target=_storer)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
self.assertFalse(errors, f"Threads raised: {errors}")
|
||||
# After the dust settles the vectors dict and metadata must agree
|
||||
self.assertEqual(
|
||||
set(store.vectors.keys()), set(store.metadata.keys()),
|
||||
"vectors and metadata dicts are out of sync after concurrent delete+store",
|
||||
)
|
||||
|
||||
def test_concurrent_search_and_delete_no_runtime_error(self):
|
||||
"""search_vectors() must not raise RuntimeError when a concurrent
|
||||
delete_vectors() modifies the store during iteration.
|
||||
|
||||
Uses threading.Barrier to make the race deterministic: the search
|
||||
thread announces it is ready just before calling search_similar, and
|
||||
the delete thread fires only after that signal has been received.
|
||||
Without the lock-protected snapshot in search_vectors(), the delete
|
||||
would mutate self.vectors while list() is iterating it, reliably
|
||||
causing 'RuntimeError: dictionary changed size during iteration'.
|
||||
"""
|
||||
import threading as _threading
|
||||
|
||||
store = _make_store(dim=4)
|
||||
vecs = store.store_vectors([_vec() for _ in range(8)], [{} for _ in range(8)])
|
||||
query = _vec()
|
||||
errors: list = []
|
||||
|
||||
# Barrier with 2 parties: searcher + deleter.
|
||||
barrier = _threading.Barrier(2)
|
||||
|
||||
original_search_similar = store.retriever.search_similar
|
||||
|
||||
def _patched_search_similar(q, vectors, keys, k, **kw):
|
||||
# Signal the deleter that iteration is about to begin, then wait
|
||||
# for it to be ready too. Both threads proceed together.
|
||||
barrier.wait(timeout=5)
|
||||
return original_search_similar(q, vectors, keys, k, **kw)
|
||||
|
||||
store.retriever.search_similar = _patched_search_similar
|
||||
|
||||
def _searcher():
|
||||
try:
|
||||
store.search_vectors(query, k=4)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
def _deleter():
|
||||
barrier.wait(timeout=5) # wait until searcher is mid-search
|
||||
try:
|
||||
store.delete_vectors(vecs[:4])
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t1 = _threading.Thread(target=_searcher)
|
||||
t2 = _threading.Thread(target=_deleter)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=10)
|
||||
t2.join(timeout=10)
|
||||
|
||||
self.assertFalse(
|
||||
errors,
|
||||
f"Concurrent search+delete raised: {errors}",
|
||||
)
|
||||
Reference in New Issue
Block a user