mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffed78fd9 | ||
|
|
f06de0dab2 | ||
|
|
dd016744ce | ||
|
|
7884d71e23 |
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Feature: Distance Intelligence** (closes #502 by @KaifAhmad1):
|
||||
- **Context layer** — `ContextGraph.get_neighbors()` gains `include_distance_metadata=False`; when enabled adds `distance_band`, `confidence_decay`, and `path_to_anchor` per result. New `get_neighbor_distances()` returns neighbors sorted by `(hop, -decay)` with optional `min_confidence` filter. `AgentContext.retrieve()` / `find_precedents()` accept `anchor_node`, `max_hops`, `proximity_weight`, `min_confidence_decay` and blend graph proximity with semantic score as `combined_score = (1 − w) × semantic + w × proximity`.
|
||||
- **Path enrichment (FR-4)** — `GET /api/graph/node/{id}/path` now returns `semantic_similarity`, `path_coherence_score`, `confidence_decay` (O(L) via pre-built edge-weight index), `bottleneck_node`, `alternative_path_count`, and `interpretation`. All fields optional; zero breaking changes.
|
||||
- **Distance matrix (FR-6)** — `POST /api/graph/distance-matrix` accepts up to 50 nodes and metric `hops | weighted | semantic`. Returns N × N matrix (upper-triangle computed, lower mirrored), unreachable pairs, and `computation_time_ms`.
|
||||
- **Semantic neighborhood (FR-3 backend)** — `GET /api/graph/node/{id}/semantic-neighborhood?top_k=N` returns the N most similar nodes with `id`, `type`, `content`, `similarity`, `hop_distance`.
|
||||
- **Causal distance (FR-8)** — `GET /api/decisions/causal-distance?source=&target=` traverses only causal-typed edges and returns `CausalDistanceReport` with path, hop count, `confidence_decay`, `weakest_link`, and interpretation.
|
||||
- **Temporal distance history (FR-9)** — `GET /api/temporal/distance-history` samples 11 evenly-spaced snapshots across the graph's time range and emits `convergence | divergence | disconnected | reconnected` events.
|
||||
- **Distance-enriched export (FR-10)** — `POST /api/export/distance-enriched` streams pairwise hop/weighted/semantic/band/centrality metrics as CSV or JSONL. `node_subset` capped at 200 nodes.
|
||||
- **Explorer UI** — Path inspector panel (`GraphInspectorPanel.tsx`) shows a distance band chip, progress-bar metric cards (decay, similarity, coherence), bottleneck node highlight, and interpretation text. Toolbar gains Ego Mode (client-side BFS depth-of-field fading, depth slider 1–8), Structural overlay (edges colored by hop distance), Semantic overlay (edges colored by cosine similarity), and Heatmap (nodes colored green → red by hop distance). Ego and heatmap share a single merged `useEffect` to prevent `restoreNodeColors()` races.
|
||||
- **Tests** — 57 new tests in `tests/context/test_distance_intelligence.py`; 18 targeted regression tests in `tests/_smoke_review_fixes.py`.
|
||||
|
||||
- **Fix: Distance Intelligence — code review regressions** (PR #502 follow-up by @KaifAhmad1):
|
||||
- `GraphWorkspace.tsx` semantic fetch used `?limit=50`; corrected to `?top_k=50` to match the backend param (bug_001). Response type widened to full `SemanticNeighborhoodResponse` shape (bug_002).
|
||||
- `ContextGraph.get_neighbors()` was embedding distance metadata unconditionally, breaking existing callers; gated behind `include_distance_metadata=False` default (bug_003).
|
||||
- `weakest_link` dict key standardised from `weight` → `edge_weight` across `CausalChainAnalyzer` and `CausalDistanceReport` (bug_004).
|
||||
- Temporal distance history sampling replaced `timetuple()[:6]` reconstruction with `min_bound + timedelta(seconds=...)` (bug_005).
|
||||
- Confidence decay in `find_path` was O(E × L); replaced with a single O(E) edge-weight index built before the hop loop, with undirected mirroring (bug_006).
|
||||
- `AgentContext._apply_proximity_metadata()` was overwriting the original record `"id"` with the graph node id; stored as `"graph_node_id"` instead (bug_007).
|
||||
- Path highlight sweep animation used a shared `sweepTimer`; stale callbacks fired after cancellation. Added `sweepGeneration` counter — callbacks no-op if generation no longer matches (bug_008).
|
||||
- `POST /api/export/distance-enriched` now rejects `node_subset` larger than 200 nodes with HTTP 413 (sec_001).
|
||||
- `POST /api/graph/distance-matrix` now computes only the upper triangle and mirrors results, halving computation cost (sec_002).
|
||||
- Ego mode and heatmap `useEffect` hooks merged into one to eliminate concurrent `restoreNodeColors()` race (qual_001).
|
||||
- Bare `except Exception: pass` blocks in `find_path` and `semantic_neighborhood` replaced with `logger.debug(...)` (qual_002).
|
||||
- Duplicated `_distance_band()` static method removed from `CausalChainAnalyzer` and `AgentContext`; both now use `classify_path_distance` from `semantica.utils.helpers` (qual_003).
|
||||
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
|
||||
+28
-3
@@ -15,7 +15,7 @@ const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/Enti
|
||||
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||
|
||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
@@ -311,14 +311,39 @@ function WorkspaceFallback() {
|
||||
return <div className="workspace-loading">Loading workspace…</div>;
|
||||
}
|
||||
|
||||
function WelcomeScreen() {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
|
||||
Welcome to Semantica
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 14 }}>
|
||||
Select a workspace from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'welcome') {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'explore') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
@@ -451,7 +476,7 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
@@ -17,6 +17,13 @@ export type PathResponse = {
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
// FR-1 distance intelligence enrichment
|
||||
semantic_similarity?: number | null;
|
||||
path_coherence_score?: number | null;
|
||||
confidence_decay?: number | null;
|
||||
bottleneck_node?: string | null;
|
||||
alternative_path_count?: number;
|
||||
interpretation?: string;
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
@@ -46,6 +53,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
/* ─── Path Distance Intelligence Panel ──────────────────────────── */
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
direct: "#3fb950",
|
||||
near: "#79c0ff",
|
||||
"mid-range": "#e3b341",
|
||||
distant: "#ff7b72",
|
||||
};
|
||||
|
||||
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
const hasMetrics =
|
||||
result.confidence_decay != null ||
|
||||
result.semantic_similarity != null ||
|
||||
result.path_coherence_score != null ||
|
||||
result.bottleneck_node ||
|
||||
result.interpretation;
|
||||
if (!hasMetrics) return null;
|
||||
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
{/* distance band + alt paths */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: withAlpha(bandColor, 0.14),
|
||||
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
|
||||
color: bandColor,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{(result.alternative_path_count ?? 0) > 0 && (
|
||||
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* metric grid */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{result.confidence_decay != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
>
|
||||
{(result.confidence_decay * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div
|
||||
style={{
|
||||
...metricBarFillStyle,
|
||||
width: `${result.confidence_decay * 100}%`,
|
||||
background:
|
||||
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.semantic_similarity != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Semantic Sim.</div>
|
||||
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
|
||||
{(result.semantic_similarity * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.path_coherence_score != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Path Coherence</div>
|
||||
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
|
||||
{(result.path_coherence_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.bottleneck_node && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Bottleneck</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: "#e3b341",
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={result.bottleneck_node}
|
||||
>
|
||||
{getNodeLabel(result.bottleneck_node)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* interpretation */}
|
||||
{result.interpretation && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
background: "rgba(88,166,255,0.06)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(88,166,255,0.14)",
|
||||
color: "#a0b4cc",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{result.interpretation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
@@ -83,11 +216,13 @@ function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
bottleneckNodeId,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
bottleneckNodeId?: string | null;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
@@ -110,10 +245,13 @@ function PathFlowViz({
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={`Focus: ${nodeId}`}
|
||||
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
...(nodeId === bottleneckNodeId
|
||||
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||
@@ -316,12 +454,16 @@ export function GraphInspectorPanel({
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<>
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
bottleneckNodeId={pathResult.bottleneck_node}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<PathDistanceIntelPanel result={pathResult} />
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
@@ -567,3 +709,41 @@ const pathEdgeLabelStyle: CSSProperties = {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const metricCardStyle: CSSProperties = {
|
||||
background: "rgba(0,0,0,0.18)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
const metricLabelStyle: CSSProperties = {
|
||||
color: "rgba(88,166,255,0.65)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const metricValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#e6edf3",
|
||||
};
|
||||
|
||||
const metricBarTrackStyle: CSSProperties = {
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
overflow: "hidden",
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const metricBarFillStyle: CSSProperties = {
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
transition: "width 300ms ease",
|
||||
};
|
||||
|
||||
@@ -717,6 +717,14 @@ export function GraphWorkspace() {
|
||||
const [graphAnalyticsState, setGraphAnalyticsState] = useState<GraphAnalyticsSnapshot | null>(null);
|
||||
const [loadedPlugins, setLoadedPlugins] = useState<Record<string, GraphPlugin>>({});
|
||||
|
||||
// FR-2: Egocentric depth-of-field
|
||||
const [egoModeEnabled, setEgoModeEnabled] = useState(false);
|
||||
const [egoMaxHops, setEgoMaxHops] = useState(3);
|
||||
// FR-3 frontend: Distance mode overlay
|
||||
const [distanceMode, setDistanceMode] = useState<"off" | "structural" | "semantic">("off");
|
||||
// FR-5: Distance heatmap layout
|
||||
const [heatmapEnabled, setHeatmapEnabled] = useState(false);
|
||||
|
||||
const debouncedTime = useDebounce(scrubberTime, 150);
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
const sceneRef = useRef<GraphSceneHandle>(null);
|
||||
@@ -1012,7 +1020,7 @@ export function GraphWorkspace() {
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, [viewMode]);
|
||||
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -1164,6 +1172,159 @@ export function GraphWorkspace() {
|
||||
setLastGroupedSelectedNodeId("");
|
||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||
|
||||
// ── Distance Intelligence helpers ──────────────────────────────
|
||||
// BFS over the in-memory graphology graph; returns hop distance from startId.
|
||||
function bfsDistances(startId: string, maxHops: number): Map<string, number> {
|
||||
const dist = new Map<string, number>();
|
||||
if (!graph.hasNode(startId)) return dist;
|
||||
const queue: [string, number][] = [[startId, 0]];
|
||||
dist.set(startId, 0);
|
||||
while (queue.length > 0) {
|
||||
const [nodeId, hop] = queue.shift()!;
|
||||
if (hop >= maxHops) continue;
|
||||
for (const nb of graph.neighbors(nodeId)) {
|
||||
if (!dist.has(nb)) {
|
||||
dist.set(nb, hop + 1);
|
||||
queue.push([nb, hop + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
function hopBandColor(hops: number): string {
|
||||
if (hops === 0) return "#3fb950"; // anchor – green
|
||||
if (hops === 1) return "#56d364"; // direct – light green
|
||||
if (hops <= 3) return "#e3b341"; // near – amber
|
||||
if (hops <= 6) return "#d29922"; // mid-range – orange
|
||||
return "#ff7b72"; // distant – red
|
||||
}
|
||||
|
||||
function restoreNodeColors() {
|
||||
graph.forEachNode((nodeId) => {
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { baseColor?: string; color?: string; baseSize?: number; size?: number };
|
||||
if (attrs.baseColor) graph.setNodeAttribute(nodeId, "color", attrs.baseColor);
|
||||
if (attrs.baseSize) graph.setNodeAttribute(nodeId, "size", attrs.baseSize);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreEdgeColors() {
|
||||
graph.forEachEdge((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as { baseColor?: string };
|
||||
if (attrs.baseColor) graph.setEdgeAttribute(edgeId, "color", attrs.baseColor);
|
||||
});
|
||||
}
|
||||
|
||||
// FR-2 + FR-5: Combined node styling effect — ego mode and heatmap share a single
|
||||
// effect so restoreNodeColors() is never called from two competing effects at once.
|
||||
useEffect(() => {
|
||||
const activeMode = egoModeEnabled ? "ego" : heatmapEnabled ? "heatmap" : "off";
|
||||
|
||||
if (activeMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
restoreNodeColors();
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeMode === "ego") {
|
||||
const dist = bfsDistances(selectedNodeId, egoMaxHops);
|
||||
const maxD = Math.max(1, egoMaxHops);
|
||||
graph.forEachNode((nodeId) => {
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { baseColor?: string; color?: string; baseSize?: number; size?: number };
|
||||
const baseColor = attrs.baseColor || attrs.color || "#58a6ff";
|
||||
const baseSize = attrs.baseSize || attrs.size || 8;
|
||||
const d = dist.get(nodeId);
|
||||
if (d === undefined) {
|
||||
graph.setNodeAttribute(nodeId, "color", withAlpha(baseColor, 0.06));
|
||||
graph.setNodeAttribute(nodeId, "size", Math.max(0.5, baseSize * 0.22));
|
||||
} else {
|
||||
const ratio = d / (maxD + 1);
|
||||
graph.setNodeAttribute(nodeId, "color", withAlpha(baseColor, 1 - ratio * 0.65));
|
||||
graph.setNodeAttribute(nodeId, "size", Math.max(1, baseSize * (1 - ratio * 0.38)));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// heatmap
|
||||
const dist = bfsDistances(selectedNodeId, 20);
|
||||
graph.forEachNode((nodeId) => {
|
||||
const d = dist.get(nodeId);
|
||||
graph.setNodeAttribute(nodeId, "color", d !== undefined ? hopBandColor(d) : "rgba(40,55,72,0.55)");
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId]);
|
||||
|
||||
// FR-3 frontend: Distance overlay — color edges by structural hop distance or semantic similarity
|
||||
useEffect(() => {
|
||||
if (distanceMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
if (distanceMode === "off") {
|
||||
restoreEdgeColors();
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (distanceMode === "structural") {
|
||||
const dist = bfsDistances(selectedNodeId, 20);
|
||||
graph.forEachEdge((edgeId, _attrs, source, target) => {
|
||||
const d = Math.min(dist.get(source) ?? 99, dist.get(target) ?? 99);
|
||||
const edgeColor = d <= 1 ? "rgba(86,211,100,0.55)" : d <= 3 ? "rgba(227,179,65,0.45)" : d <= 6 ? "rgba(210,153,34,0.35)" : "rgba(255,123,114,0.22)";
|
||||
graph.setEdgeAttribute(edgeId, "color", edgeColor);
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Semantic mode — fetch neighborhood and color edges by similarity score
|
||||
const fetchSemantic = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/semantic-neighborhood?top_k=50`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data: { anchor_node: string; neighbors: { id: string; type: string; content: string; similarity: number; hop_distance?: number | null }[]; total: number } = await response.json();
|
||||
const simMap = new Map(data.neighbors.map((n) => [n.id, n.similarity]));
|
||||
|
||||
graph.forEachEdge((edgeId, _attrs, source, target) => {
|
||||
const sim = simMap.get(source === selectedNodeId ? target : source);
|
||||
if (sim == null) {
|
||||
graph.setEdgeAttribute(edgeId, "color", "rgba(100,120,140,0.18)");
|
||||
return;
|
||||
}
|
||||
const edgeColor =
|
||||
sim > 0.7 ? `rgba(86,211,100,${0.3 + sim * 0.4})` :
|
||||
sim > 0.4 ? `rgba(227,179,65,${0.25 + sim * 0.35})` :
|
||||
`rgba(255,123,114,${0.2 + sim * 0.3})`;
|
||||
graph.setEdgeAttribute(edgeId, "color", edgeColor);
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
} catch {
|
||||
// semantic mode falls back to no coloring on fetch error
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSemantic();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [distanceMode, selectedNodeId]);
|
||||
|
||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||
@@ -1746,6 +1907,53 @@ export function GraphWorkspace() {
|
||||
],
|
||||
});
|
||||
|
||||
// FR-2/3/5: Distance intelligence controls
|
||||
if (hasGraphContent && selectedNodeId) {
|
||||
groups.push({
|
||||
id: "distance-intel",
|
||||
items: [
|
||||
{
|
||||
id: "ego-mode",
|
||||
label: egoModeEnabled ? `Ego (${egoMaxHops}h)` : "Ego Mode",
|
||||
title: egoModeEnabled
|
||||
? `Egocentric view: ${egoMaxHops} hops depth (click to toggle off)`
|
||||
: "Show depth-of-field fading around the selected node",
|
||||
active: egoModeEnabled,
|
||||
onClick: () => {
|
||||
setEgoModeEnabled((v) => !v);
|
||||
if (heatmapEnabled) setHeatmapEnabled(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "heatmap",
|
||||
label: "Heatmap",
|
||||
title: heatmapEnabled
|
||||
? "Distance heatmap active (click to toggle off)"
|
||||
: "Color nodes by hop distance from selected node",
|
||||
active: heatmapEnabled,
|
||||
onClick: () => {
|
||||
setHeatmapEnabled((v) => !v);
|
||||
if (egoModeEnabled) setEgoModeEnabled(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dist-structural",
|
||||
label: "Structural",
|
||||
title: "Color edges by structural (hop) distance",
|
||||
active: distanceMode === "structural",
|
||||
onClick: () => setDistanceMode((m) => (m === "structural" ? "off" : "structural")),
|
||||
},
|
||||
{
|
||||
id: "dist-semantic",
|
||||
label: "Semantic",
|
||||
title: "Color edges by semantic similarity to selected node",
|
||||
active: distanceMode === "semantic",
|
||||
onClick: () => setDistanceMode((m) => (m === "semantic" ? "off" : "semantic")),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginToolbarItems.length) {
|
||||
groups.push({
|
||||
id: "plugin-tools",
|
||||
@@ -1764,15 +1972,18 @@ export function GraphWorkspace() {
|
||||
canActivateFocusedMode,
|
||||
displayState.groupedViewAvailable,
|
||||
displayState.groupedViewReason,
|
||||
distanceMode,
|
||||
egoModeEnabled,
|
||||
egoMaxHops,
|
||||
focusedSelectionResolution.reason,
|
||||
handlePluginAction,
|
||||
hasGraphContent,
|
||||
heatmapEnabled,
|
||||
isLayoutRunning,
|
||||
pluginToolbarItems,
|
||||
reload,
|
||||
requestViewMode,
|
||||
searchQuery,
|
||||
canActivateFocusedMode,
|
||||
focusedSelectionResolution.reason,
|
||||
selectedNodeId,
|
||||
selectedNodeState,
|
||||
showLoadingOverlay,
|
||||
@@ -1875,6 +2086,22 @@ export function GraphWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{egoModeEnabled && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 12, color: "#a0b4cc" }}>
|
||||
<span style={{ fontWeight: 600, color: "#79c0ff" }}>Ego depth:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={8}
|
||||
value={egoMaxHops}
|
||||
onChange={(e) => setEgoMaxHops(Number(e.target.value))}
|
||||
style={{ width: 90, accentColor: "#79c0ff" }}
|
||||
title={`Ego depth: ${egoMaxHops} hops`}
|
||||
/>
|
||||
<span style={{ fontFamily: "monospace", color: "#e6f2ff" }}>{egoMaxHops} hop{egoMaxHops !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchError ? <div style={{ color: "#ff7b72", fontSize: 12 }}>{searchError}</div> : null}
|
||||
|
||||
{searchResults.length ? (
|
||||
@@ -2056,6 +2283,7 @@ export function GraphWorkspace() {
|
||||
onTracePath={() => void handleTracePath()}
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
onFocusNode={focusNode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
const SWEEP_TICKS = 6;
|
||||
const SWEEP_INTERVAL_MS = 60;
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepGeneration = 0;
|
||||
|
||||
function cancelSweep() {
|
||||
sweepGeneration++;
|
||||
if (sweepTimer !== null) {
|
||||
clearTimeout(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSweep(sigma: { refresh: () => void }, tick: number, gen: number) {
|
||||
if (tick >= SWEEP_TICKS) return;
|
||||
sweepTimer = setTimeout(() => {
|
||||
if (gen !== sweepGeneration) return;
|
||||
sigma.refresh();
|
||||
scheduleSweep(sigma, tick + 1, gen);
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
detach: (context) => {
|
||||
cancelSweep();
|
||||
lastPathSignature = "";
|
||||
context.sigma.refresh();
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
cancelSweep();
|
||||
context.sigma.refresh();
|
||||
|
||||
// Animate intermediate nodes lighting up sequentially
|
||||
if (interactionState.activePath.length > 2) {
|
||||
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ Production Use Cases:
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .agent_memory import AgentMemory
|
||||
from .context_retriever import ContextRetriever, RetrievedContext
|
||||
@@ -507,6 +508,10 @@ class AgentContext:
|
||||
include_relationships: bool = False,
|
||||
expand_graph: bool = True,
|
||||
deduplicate: bool = True,
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -560,17 +565,33 @@ class AgentContext:
|
||||
**kwargs,
|
||||
)
|
||||
# Convert RetrievedContext to dicts
|
||||
return [
|
||||
result_dicts = [
|
||||
self._context_to_dict(r, include_entities, include_relationships)
|
||||
for r in results
|
||||
]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
else:
|
||||
# Simple RAG: Use AgentMemory (vector + memory)
|
||||
results = self._memory.retrieve(
|
||||
query, max_results=max_results, min_score=min_score, **kwargs
|
||||
)
|
||||
# Convert to dicts
|
||||
return [self._memory_to_dict(r) for r in results]
|
||||
result_dicts = [self._memory_to_dict(r) for r in results]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
def query_with_reasoning(
|
||||
self,
|
||||
@@ -814,6 +835,77 @@ class AgentContext:
|
||||
|
||||
return result
|
||||
|
||||
def _apply_proximity_metadata(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
max_results: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enrich retrieval results with graph distance from an anchor node."""
|
||||
if not anchor_node or not self.knowledge_graph:
|
||||
return results
|
||||
if not hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
return results
|
||||
|
||||
search_hops = max_hops if max_hops is not None else 10
|
||||
distances = self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_node,
|
||||
hops=search_hops,
|
||||
min_confidence=min_confidence_decay,
|
||||
)
|
||||
by_node_id = {item.get("id"): item for item in distances}
|
||||
if anchor_node:
|
||||
by_node_id[anchor_node] = {
|
||||
"id": anchor_node,
|
||||
"hop": 0,
|
||||
"confidence_decay": 1.0,
|
||||
"distance_band": "direct",
|
||||
"path_to_anchor": [anchor_node],
|
||||
}
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata") or {}
|
||||
result_id = (
|
||||
result.get("id")
|
||||
or metadata.get("node_id")
|
||||
or metadata.get("id")
|
||||
or metadata.get("memory_id")
|
||||
)
|
||||
distance = by_node_id.get(result_id)
|
||||
if not distance:
|
||||
if max_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
enriched.append(result)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop")
|
||||
if max_hops is not None and hop_distance is not None and hop_distance > max_hops:
|
||||
continue
|
||||
|
||||
proximity_score = 1.0 if hop_distance == 0 else 1.0 / float(hop_distance or 1)
|
||||
score = float(result.get("score", 0.0))
|
||||
bounded_weight = min(max(float(proximity_weight), 0.0), 1.0)
|
||||
combined_score = (1.0 - bounded_weight) * score + bounded_weight * proximity_score
|
||||
enriched_result = {
|
||||
**result,
|
||||
"graph_node_id": result_id,
|
||||
"hop_distance": hop_distance,
|
||||
"confidence_decay": distance.get("confidence_decay"),
|
||||
"distance_band": distance.get("distance_band"),
|
||||
"path_to_anchor": distance.get("path_to_anchor"),
|
||||
"proximity_score": proximity_score,
|
||||
"combined_score": combined_score,
|
||||
}
|
||||
enriched.append(enriched_result)
|
||||
|
||||
if proximity_weight > 0:
|
||||
enriched.sort(key=lambda item: item.get("combined_score", item.get("score", 0.0)), reverse=True)
|
||||
return enriched[:max_results] if max_results is not None else enriched
|
||||
|
||||
def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert memory result to dict."""
|
||||
return {
|
||||
@@ -2228,7 +2320,10 @@ class AgentContext:
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
use_kg_features: bool = True,
|
||||
similarity_weights: Optional[Dict[str, float]] = None
|
||||
similarity_weights: Optional[Dict[str, float]] = None,
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
) -> List[Decision]:
|
||||
"""
|
||||
Find precedents using advanced KG and vector store features.
|
||||
@@ -2248,7 +2343,7 @@ class AgentContext:
|
||||
|
||||
try:
|
||||
if hasattr(self._decision_query, 'find_precedents_hybrid'):
|
||||
return self._decision_query.find_precedents_hybrid(
|
||||
precedents = self._decision_query.find_precedents_hybrid(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
@@ -2257,11 +2352,116 @@ class AgentContext:
|
||||
)
|
||||
else:
|
||||
# Fallback to basic method
|
||||
return self.find_precedents(scenario, category, limit)
|
||||
precedents = self.find_precedents(scenario, category, limit)
|
||||
return self._apply_causal_proximity_to_precedents(
|
||||
precedents,
|
||||
anchor_decision_id=anchor_decision_id,
|
||||
max_causal_hops=max_causal_hops,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
|
||||
def _apply_causal_proximity_to_precedents(
|
||||
self,
|
||||
precedents: List[Decision],
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
limit: int = 10,
|
||||
) -> List[Decision]:
|
||||
"""Attach causal-distance metadata to precedents and optionally filter."""
|
||||
if not anchor_decision_id or not self.knowledge_graph:
|
||||
return precedents
|
||||
|
||||
causal_types = ["causes", "influences", "leads_to", "supports"]
|
||||
max_hops = max_causal_hops if max_causal_hops is not None else 10
|
||||
distance_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
if hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
for item in self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_decision_id,
|
||||
hops=max_hops,
|
||||
relationship_types=causal_types,
|
||||
min_confidence=min_confidence_decay,
|
||||
):
|
||||
distance_by_id[item.get("id")] = item
|
||||
|
||||
annotated: List[Decision] = []
|
||||
for decision in precedents:
|
||||
decision_id = getattr(decision, "decision_id", None)
|
||||
distance = distance_by_id.get(decision_id)
|
||||
if distance is None and hasattr(self.knowledge_graph, "trace_decision_causality"):
|
||||
distance = self._distance_from_causality_trace(anchor_decision_id, decision_id, max_hops)
|
||||
|
||||
if distance is None:
|
||||
if max_causal_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
setattr(decision, "causal_hop_distance", None)
|
||||
setattr(decision, "path_confidence_decay", None)
|
||||
setattr(decision, "distance_band", None)
|
||||
annotated.append(decision)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop", distance.get("hop_count"))
|
||||
confidence_decay = distance.get("confidence_decay")
|
||||
if max_causal_hops is not None and hop_distance is not None and hop_distance > max_causal_hops:
|
||||
continue
|
||||
if confidence_decay is not None and confidence_decay < min_confidence_decay:
|
||||
continue
|
||||
|
||||
setattr(decision, "causal_hop_distance", hop_distance)
|
||||
setattr(decision, "path_confidence_decay", confidence_decay)
|
||||
setattr(decision, "distance_band", distance.get("distance_band"))
|
||||
annotated.append(decision)
|
||||
|
||||
annotated.sort(
|
||||
key=lambda decision: (
|
||||
getattr(decision, "causal_hop_distance", None) is None,
|
||||
getattr(decision, "causal_hop_distance", 10**9) or 10**9,
|
||||
-(getattr(decision, "path_confidence_decay", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return annotated[:limit]
|
||||
|
||||
def _distance_from_causality_trace(
|
||||
self,
|
||||
anchor_decision_id: str,
|
||||
target_decision_id: Optional[str],
|
||||
max_hops: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Infer anchor-to-target distance from ContextGraph causality reports."""
|
||||
if not target_decision_id:
|
||||
return None
|
||||
try:
|
||||
chains = self.knowledge_graph.trace_decision_causality(target_decision_id, max_depth=max_hops)
|
||||
except Exception:
|
||||
return None
|
||||
best: Optional[Dict[str, Any]] = None
|
||||
for chain in chains:
|
||||
hops = chain.get("hops", chain) if isinstance(chain, dict) else chain
|
||||
if not hops:
|
||||
continue
|
||||
starts_at_anchor = hops[0].get("from") == anchor_decision_id
|
||||
ends_at_target = hops[-1].get("to") == target_decision_id
|
||||
if starts_at_anchor and ends_at_target:
|
||||
candidate = {
|
||||
"hop_count": len(hops),
|
||||
"confidence_decay": chain.get("confidence_decay") if isinstance(chain, dict) else None,
|
||||
"distance_band": chain.get("distance_band") if isinstance(chain, dict) else None,
|
||||
}
|
||||
if candidate["confidence_decay"] is None:
|
||||
decay = 1.0
|
||||
for hop in hops:
|
||||
decay *= float(hop.get("edge_weight", 1.0))
|
||||
candidate["confidence_decay"] = decay
|
||||
if candidate["distance_band"] is None:
|
||||
candidate["distance_band"] = classify_path_distance(candidate["hop_count"])
|
||||
if best is None or candidate["hop_count"] < best["hop_count"]:
|
||||
best = candidate
|
||||
return best
|
||||
|
||||
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze decision influence using advanced graph algorithms.
|
||||
|
||||
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Set
|
||||
from collections import deque
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .decision_models import Decision
|
||||
|
||||
@@ -677,3 +678,102 @@ class CausalChainAnalyzer:
|
||||
else:
|
||||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
||||
return decisions
|
||||
|
||||
def interpret_causal_distance(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Traverse only causal-typed edges and return a structured distance report.
|
||||
|
||||
Returns a dict matching CausalDistanceReport with keys:
|
||||
source_id, target_id, causal_path, causal_hop_count,
|
||||
intermediate_decisions, confidence_decay, weakest_link, interpretation
|
||||
"""
|
||||
from collections import deque as _deque
|
||||
|
||||
CAUSAL_TYPES = {"causes", "influences", "leads_to", "supports",
|
||||
"CAUSED", "INFLUENCED", "PRECEDENT_FOR"}
|
||||
|
||||
graph = self.graph_store
|
||||
|
||||
# ContextGraph-native BFS over causal edges
|
||||
if hasattr(graph, "nodes") and hasattr(graph, "_adjacency"):
|
||||
if source_id not in graph.nodes:
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
queue = _deque([(source_id, [source_id], 1.0, None)])
|
||||
visited: Set[str] = {source_id}
|
||||
|
||||
while queue:
|
||||
current_id, path, decay, weakest = queue.popleft()
|
||||
if current_id == target_id:
|
||||
hop_count = len(path) - 1
|
||||
intermediates = [
|
||||
n for n in path[1:-1]
|
||||
if str(getattr(graph.nodes.get(n), "node_type", "")).lower() == "decision"
|
||||
]
|
||||
band = classify_path_distance(hop_count)
|
||||
interp = self._causal_interpretation(hop_count, decay, band)
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": path,
|
||||
"causal_hop_count": hop_count,
|
||||
"intermediate_decisions": intermediates,
|
||||
"confidence_decay": round(decay, 6),
|
||||
"weakest_link": weakest,
|
||||
"interpretation": interp,
|
||||
}
|
||||
|
||||
with graph._lock:
|
||||
outgoing = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing:
|
||||
if edge.edge_type not in CAUSAL_TYPES:
|
||||
continue
|
||||
nxt = edge.target_id
|
||||
if nxt in visited:
|
||||
continue
|
||||
visited.add(nxt)
|
||||
new_decay = decay * edge.weight
|
||||
new_weakest = weakest
|
||||
if weakest is None or edge.weight < weakest.get("edge_weight", 1.0):
|
||||
new_weakest = {"source": current_id, "target": nxt, "edge_weight": edge.weight}
|
||||
queue.append((nxt, path + [nxt], new_decay, new_weakest))
|
||||
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
# GraphStore fallback — return not-reachable; callers can use get_causal_chain instead
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
@staticmethod
|
||||
def _causal_interpretation(hop_count: int, decay: float, band: str) -> str:
|
||||
if band == "direct":
|
||||
base = f"Direct cause with confidence {decay:.2f}."
|
||||
elif band == "near":
|
||||
base = (
|
||||
f"Mediated through {hop_count - 1} decision(s); "
|
||||
f"confidence decays to {decay:.2f}"
|
||||
)
|
||||
base += " — moderate evidence." if decay > 0.4 else " — weak evidence."
|
||||
else:
|
||||
base = (
|
||||
f"Distal influence across {hop_count} causal steps; "
|
||||
f"confidence near {decay:.2f} — weak signal."
|
||||
)
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _unreachable_report(source_id: str, target_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": [],
|
||||
"causal_hop_count": 0,
|
||||
"intermediate_decisions": [],
|
||||
"confidence_decay": 0.0,
|
||||
"weakest_link": None,
|
||||
"interpretation": "No causal path found between the two nodes.",
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ import uuid
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from .entity_linker import EntityLinker
|
||||
|
||||
# Optional imports for advanced features
|
||||
@@ -130,6 +131,13 @@ except ImportError:
|
||||
KG_AVAILABLE = False
|
||||
|
||||
|
||||
class _CausalChain(dict):
|
||||
"""Dict response that still iterates over hops for legacy callers."""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.get("hops", []))
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||
|
||||
@@ -739,6 +747,7 @@ class ContextGraph:
|
||||
min_weight: float = 0.0,
|
||||
skip: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
include_distance_metadata: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get neighbors of a node.
|
||||
@@ -762,11 +771,11 @@ class ContextGraph:
|
||||
|
||||
neighbors: List[Dict[str, Any]] = []
|
||||
visited = {node_id}
|
||||
queue = deque([(node_id, 0)])
|
||||
queue = deque([(node_id, 0, [node_id], 1.0)])
|
||||
rel_filter = set(relationship_types) if relationship_types else None
|
||||
|
||||
while queue:
|
||||
current_id, current_hop = queue.popleft()
|
||||
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
|
||||
if current_hop >= hops:
|
||||
continue
|
||||
|
||||
@@ -780,26 +789,59 @@ class ContextGraph:
|
||||
if neighbor_id in visited:
|
||||
continue
|
||||
visited.add(neighbor_id)
|
||||
queue.append((neighbor_id, current_hop + 1))
|
||||
next_hop = current_hop + 1
|
||||
next_decay = decay_so_far * edge.weight
|
||||
next_path = path_so_far + [neighbor_id]
|
||||
queue.append((neighbor_id, next_hop, next_path, next_decay))
|
||||
|
||||
node = self.nodes.get(neighbor_id)
|
||||
if not node:
|
||||
continue
|
||||
neighbors.append(
|
||||
{
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": current_hop + 1,
|
||||
}
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": next_hop,
|
||||
}
|
||||
if include_distance_metadata:
|
||||
entry["distance_band"] = classify_path_distance(next_hop)
|
||||
entry["confidence_decay"] = next_decay
|
||||
entry["path_to_anchor"] = next_path
|
||||
neighbors.append(entry)
|
||||
|
||||
if limit is not None:
|
||||
return neighbors[skip: skip + limit]
|
||||
return neighbors[skip:]
|
||||
|
||||
def get_neighbor_distances(
|
||||
self,
|
||||
node_id: str,
|
||||
hops: int = 3,
|
||||
relationship_types: Optional[List[str]] = None,
|
||||
min_confidence: float = 0.0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return neighbors with distance metadata, filtered by confidence decay.
|
||||
|
||||
Results are ordered by nearest hop first, then by strongest path confidence.
|
||||
"""
|
||||
neighbors = self.get_neighbors(
|
||||
node_id,
|
||||
hops=hops,
|
||||
relationship_types=relationship_types,
|
||||
include_distance_metadata=True,
|
||||
)
|
||||
filtered = [
|
||||
item for item in neighbors
|
||||
if item.get("confidence_decay", 0.0) >= min_confidence
|
||||
]
|
||||
return sorted(
|
||||
filtered,
|
||||
key=lambda item: (item.get("hop", 0), -item.get("confidence_decay", 0.0)),
|
||||
)
|
||||
|
||||
def query(
|
||||
self, query: str, skip: int = 0, limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1187,6 +1229,90 @@ class ContextGraph:
|
||||
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
||||
return other_graph, target_node_id
|
||||
|
||||
def cross_graph_path(
|
||||
self,
|
||||
source_node_id: str,
|
||||
target_graph: "ContextGraph",
|
||||
target_node_id: str,
|
||||
max_hops: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find the shortest path across linked ContextGraph instances.
|
||||
"""
|
||||
start = (self.graph_id, source_node_id)
|
||||
goal = (target_graph.graph_id, target_node_id)
|
||||
if source_node_id not in self.nodes or target_node_id not in target_graph.nodes:
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
queue = deque([(self, source_node_id, [start], 0, 1.0, 0)])
|
||||
visited = {start}
|
||||
|
||||
while queue:
|
||||
graph, current_id, path, hop_count, decay, links_used = queue.popleft()
|
||||
current_key = (graph.graph_id, current_id)
|
||||
if current_key == goal:
|
||||
return {
|
||||
"path": path,
|
||||
"hop_count": hop_count,
|
||||
"cross_graph_links_used": links_used,
|
||||
"confidence_decay": decay,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"reachable": True,
|
||||
}
|
||||
if hop_count >= max_hops:
|
||||
continue
|
||||
|
||||
with graph._lock:
|
||||
outgoing_edges = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing_edges:
|
||||
marker = graph.nodes.get(edge.target_id)
|
||||
link_id = None
|
||||
if marker and marker.node_type == "cross_graph_link":
|
||||
link_id = marker.metadata.get("link_id")
|
||||
|
||||
if link_id:
|
||||
try:
|
||||
next_graph, next_node_id = graph.navigate_to(link_id)
|
||||
except KeyError:
|
||||
continue
|
||||
next_key = (next_graph.graph_id, next_node_id)
|
||||
next_links_used = links_used + 1
|
||||
else:
|
||||
next_graph, next_node_id = graph, edge.target_id
|
||||
next_key = (graph.graph_id, edge.target_id)
|
||||
next_links_used = links_used
|
||||
|
||||
if next_key in visited:
|
||||
continue
|
||||
visited.add(next_key)
|
||||
queue.append(
|
||||
(
|
||||
next_graph,
|
||||
next_node_id,
|
||||
path + [next_key],
|
||||
hop_count + 1,
|
||||
decay * edge.weight,
|
||||
next_links_used,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
|
||||
"""
|
||||
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
||||
@@ -2552,13 +2678,14 @@ class ContextGraph:
|
||||
# Calculate influence scores
|
||||
influence_scores = {}
|
||||
for influenced_id in direct_influence | indirect_influence:
|
||||
score = self._calculate_decision_influence_score(decision_id, influenced_id)
|
||||
influence_scores[influenced_id] = score
|
||||
influence_scores[influenced_id] = self._calculate_decision_influence_score(
|
||||
decision_id, influenced_id
|
||||
)
|
||||
|
||||
# Sort by influence score
|
||||
sorted_influence = sorted(
|
||||
influence_scores.items(),
|
||||
key=lambda x: x[1],
|
||||
key=lambda x: x[1].get("score", 0.0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
@@ -2576,11 +2703,22 @@ class ContextGraph:
|
||||
"direct_influence": [_enrich(did) for did in direct_influence],
|
||||
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
||||
"influence_scores": [
|
||||
{**_enrich(did), "score": score}
|
||||
for did, score in sorted_influence
|
||||
{
|
||||
**_enrich(did),
|
||||
"score": details.get("score", 0.0),
|
||||
"score_breakdown": {
|
||||
"entity_overlap": details.get("entity_score", 0.0),
|
||||
"category_match": details.get("category_score", 0.0),
|
||||
"temporal_proximity": details.get("time_score", 0.0),
|
||||
},
|
||||
"is_direct": did in direct_influence,
|
||||
}
|
||||
for did, details in sorted_influence
|
||||
],
|
||||
"total_influenced": len(influence_scores),
|
||||
"max_influence_score": max(influence_scores.values()) if influence_scores else 0.0
|
||||
"max_influence_score": max(
|
||||
details.get("score", 0.0) for details in influence_scores.values()
|
||||
) if influence_scores else 0.0
|
||||
}
|
||||
|
||||
def get_decision_insights(self) -> Dict[str, Any]:
|
||||
@@ -2677,15 +2815,17 @@ class ContextGraph:
|
||||
|
||||
for cause_id in potential_causes:
|
||||
cause_dec = self._decisions.get(cause_id, {})
|
||||
edge_weight = float(cause_dec.get("confidence", 1.0))
|
||||
hop = {
|
||||
"from": cause_id,
|
||||
"from_scenario": cause_dec.get("scenario", ""),
|
||||
"to": current_id,
|
||||
"to_scenario": current_decision.get("scenario", ""),
|
||||
"type": "influences",
|
||||
"edge_weight": edge_weight,
|
||||
}
|
||||
cause_path = path + [hop]
|
||||
causal_chain.append(cause_path)
|
||||
causal_chain.append(self._build_causal_chain_report(list(reversed(cause_path))))
|
||||
trace_recursive(cause_id, depth + 1, cause_path)
|
||||
|
||||
trace_recursive(decision_id, 0, [])
|
||||
@@ -2942,11 +3082,49 @@ class ContextGraph:
|
||||
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
||||
return set()
|
||||
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> float:
|
||||
def _build_causal_chain_report(self, hops: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Build an auditable causal-chain response from hop records."""
|
||||
hop_count = len(hops)
|
||||
confidence_decay = 1.0
|
||||
weakest_link = None
|
||||
for hop in hops:
|
||||
edge_weight = float(hop.get("edge_weight", 1.0))
|
||||
confidence_decay *= edge_weight
|
||||
if weakest_link is None or edge_weight < float(weakest_link.get("edge_weight", 1.0)):
|
||||
weakest_link = hop
|
||||
|
||||
if hop_count <= 1:
|
||||
interpretation = f"Direct influence with confidence {confidence_decay:.2f}."
|
||||
elif confidence_decay > 0.7:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) with high confidence "
|
||||
f"({confidence_decay:.2f})."
|
||||
)
|
||||
elif confidence_decay > 0.4:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) - confidence decays "
|
||||
f"to {confidence_decay:.2f}."
|
||||
)
|
||||
else:
|
||||
interpretation = (
|
||||
f"Distal influence across {hop_count} causal steps; confidence "
|
||||
f"{confidence_decay:.2f} is weak evidence."
|
||||
)
|
||||
|
||||
return _CausalChain({
|
||||
"hops": hops,
|
||||
"hop_count": hop_count,
|
||||
"confidence_decay": confidence_decay,
|
||||
"weakest_link": weakest_link,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"interpretation": interpretation,
|
||||
})
|
||||
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> Dict[str, float]:
|
||||
"""Calculate influence score between two decisions."""
|
||||
try:
|
||||
if not hasattr(self, '_decisions'):
|
||||
return 0.0
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
|
||||
source_decision = self._decisions[source_id]
|
||||
target_decision = self._decisions[target_id]
|
||||
@@ -2965,11 +3143,16 @@ class ContextGraph:
|
||||
# Combined score
|
||||
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_score
|
||||
|
||||
return combined_score
|
||||
return {
|
||||
"score": combined_score,
|
||||
"entity_score": entity_score,
|
||||
"category_score": category_score,
|
||||
"time_score": time_score,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Influence score calculation failed: {e}")
|
||||
return 0.0
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
|
||||
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
|
||||
"""Get temporal analysis of decisions."""
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||
from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
|
||||
@@ -125,6 +125,20 @@ async def get_precedents(
|
||||
return [_node_to_decision(decision) for _, decision in scored[:limit]]
|
||||
|
||||
|
||||
@router.get("/causal-distance", response_model=CausalDistanceReport)
|
||||
async def causal_distance(
|
||||
source: str = Query(..., description="Source node/decision ID"),
|
||||
target: str = Query(..., description="Target node/decision ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
|
||||
from ...context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(session.graph)
|
||||
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
|
||||
return CausalDistanceReport(**report)
|
||||
|
||||
|
||||
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||
async def check_compliance(
|
||||
decision_id: str,
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ExportRequest, ImportResponse
|
||||
from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -236,3 +236,58 @@ async def export_graph(
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
|
||||
)
|
||||
|
||||
|
||||
_DISTANCE_EXPORT_MAX_NODES = 200
|
||||
|
||||
|
||||
@router.post("/api/export/distance-enriched")
|
||||
async def export_distance_enriched(
|
||||
body: DistanceExportRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-10 — Export pairwise distance metrics as CSV or JSONL for ML pipelines."""
|
||||
if not body.node_subset:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"node_subset is required; provide up to {_DISTANCE_EXPORT_MAX_NODES} node IDs to export."
|
||||
),
|
||||
)
|
||||
if len(body.node_subset) > _DISTANCE_EXPORT_MAX_NODES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"node_subset exceeds limit: {len(body.node_subset)} nodes requested; "
|
||||
f"maximum is {_DISTANCE_EXPORT_MAX_NODES}."
|
||||
),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
from ...export.distance_exporter import DistanceExporter
|
||||
|
||||
exporter = DistanceExporter(session.graph)
|
||||
|
||||
if body.format == "csv":
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_csv_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.csv"'},
|
||||
)
|
||||
else:
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_jsonl_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.jsonl"'},
|
||||
)
|
||||
|
||||
@@ -3,14 +3,20 @@ Graph routes for explorer node, edge, path, and search APIs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ...utils.helpers import classify_path_distance
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
DistanceMatrixRequest,
|
||||
DistanceMatrixResponse,
|
||||
EdgeListResponse,
|
||||
EdgeResponse,
|
||||
GraphStatsResponse,
|
||||
@@ -21,12 +27,45 @@ from ..schemas import (
|
||||
SearchRequest,
|
||||
SearchResultItem,
|
||||
SearchResultResponse,
|
||||
SemanticNeighborItem,
|
||||
SemanticNeighborhoodResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/graph", tags=["Graph"])
|
||||
|
||||
|
||||
def _build_interpretation(
|
||||
distance_band: str,
|
||||
hop_count: int,
|
||||
bottleneck_node: Optional[str],
|
||||
confidence_decay: Optional[float],
|
||||
) -> str:
|
||||
if distance_band == "direct":
|
||||
base = "Direct relationship"
|
||||
elif distance_band == "near":
|
||||
base = f"Closely related via {hop_count - 1} intermediate node(s)"
|
||||
elif distance_band == "mid-range":
|
||||
base = f"Reachable in {hop_count} steps across topic boundaries"
|
||||
else:
|
||||
base = f"Distal connection spanning {hop_count} hops"
|
||||
|
||||
if bottleneck_node:
|
||||
base += f", routed through bottleneck '{bottleneck_node}'"
|
||||
|
||||
if confidence_decay is not None:
|
||||
if confidence_decay > 0.7:
|
||||
base += " — high confidence."
|
||||
elif confidence_decay > 0.4:
|
||||
base += " — moderate confidence."
|
||||
else:
|
||||
base += " — low confidence, treat as weak evidence."
|
||||
else:
|
||||
base += "."
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
|
||||
if not raw_bbox:
|
||||
return None
|
||||
@@ -175,6 +214,93 @@ async def find_path(
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
distance_band = classify_path_distance(hop_count)
|
||||
|
||||
# FR-4 enrichment — compute optional fields from existing session analytics
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
alternative_path_count: int = 0
|
||||
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
# Build edge weight index once in O(E) so each hop lookup is O(1).
|
||||
# graph_dict may use "edges" or "relationships" depending on the graph source.
|
||||
edge_weight_index: dict = {}
|
||||
for _e in graph_dict.get("edges") or graph_dict.get("relationships", []):
|
||||
_s, _t = _e.get("source"), _e.get("target")
|
||||
_w = float(_e.get("weight", 1.0))
|
||||
edge_weight_index[(_s, _t)] = _w
|
||||
if not directed:
|
||||
edge_weight_index.setdefault((_t, _s), _w)
|
||||
|
||||
# Confidence decay — product of edge weights along the path (O(L))
|
||||
decay = 1.0
|
||||
for i in range(len(path_nodes) - 1):
|
||||
decay *= edge_weight_index.get((path_nodes[i], path_nodes[i + 1]), 1.0)
|
||||
confidence_decay = decay
|
||||
|
||||
# Bottleneck — intermediate node with highest betweenness in subgraph
|
||||
intermediates = path_nodes[1:-1] if len(path_nodes) > 2 else []
|
||||
if intermediates and session.centrality is not None:
|
||||
sub_dict = await asyncio.to_thread(session.build_graph_dict, path_nodes)
|
||||
centrality_result = await asyncio.to_thread(
|
||||
session.centrality.calculate_betweenness_centrality, sub_dict
|
||||
)
|
||||
scores = centrality_result.get("betweenness", {}) if isinstance(centrality_result, dict) else {}
|
||||
if scores:
|
||||
bottleneck_node = max(
|
||||
(n for n in intermediates if n in scores),
|
||||
key=lambda n: scores.get(n, 0.0),
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Alternative paths — count simple paths within hop_count + 2
|
||||
if path_finder is not None and hop_count > 0:
|
||||
try:
|
||||
k_paths = await asyncio.to_thread(
|
||||
path_finder.find_k_shortest_paths,
|
||||
graph_dict, node_id, target, hop_count + 2, directed=directed
|
||||
)
|
||||
alternative_path_count = max(0, len(k_paths) - 1)
|
||||
except Exception as exc:
|
||||
logger.debug("k_shortest_paths unavailable for enrichment: %s", exc)
|
||||
|
||||
# Semantic similarity (source ↔ target)
|
||||
if session.similarity is not None:
|
||||
try:
|
||||
sim_result = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, node_id, target
|
||||
)
|
||||
if isinstance(sim_result, (int, float)):
|
||||
semantic_similarity = float(sim_result)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_similarity unavailable for enrichment: %s", exc)
|
||||
|
||||
# Path coherence — mean pairwise similarity of consecutive nodes
|
||||
if session.similarity is not None and len(path_nodes) >= 2:
|
||||
try:
|
||||
pair_sims: List[float] = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, path_nodes[i], path_nodes[i + 1]
|
||||
)
|
||||
if isinstance(sim, (int, float)):
|
||||
pair_sims.append(float(sim))
|
||||
if pair_sims:
|
||||
path_coherence_score = sum(pair_sims) / len(pair_sims)
|
||||
except Exception as exc:
|
||||
logger.debug("path_coherence unavailable for enrichment: %s", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("FR-4 enrichment skipped: %s", exc)
|
||||
|
||||
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
|
||||
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
target=target,
|
||||
@@ -184,7 +310,13 @@ async def find_path(
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
distance_band=distance_band,
|
||||
semantic_similarity=semantic_similarity,
|
||||
path_coherence_score=path_coherence_score,
|
||||
confidence_decay=confidence_decay,
|
||||
bottleneck_node=bottleneck_node,
|
||||
alternative_path_count=alternative_path_count,
|
||||
interpretation=interpretation,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,13 +326,178 @@ async def search_nodes(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
||||
items = [
|
||||
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
|
||||
for result in results
|
||||
]
|
||||
|
||||
# FR-7 — compute hop distances from anchor when requested
|
||||
hop_by_id: dict = {}
|
||||
if body.anchor_node:
|
||||
neighbors = await asyncio.to_thread(
|
||||
session.graph.get_neighbor_distances,
|
||||
body.anchor_node,
|
||||
hops=body.max_hops if body.max_hops is not None else 10,
|
||||
)
|
||||
hop_by_id = {n.get("id"): n.get("hop") for n in neighbors}
|
||||
hop_by_id[body.anchor_node] = 0
|
||||
|
||||
items: List[SearchResultItem] = []
|
||||
for result in results:
|
||||
node_data = result.get("node", {})
|
||||
node_id = node_data.get("id", "")
|
||||
raw_score = result.get("score", 0.0)
|
||||
|
||||
hop_distance: Optional[int] = hop_by_id.get(node_id) if body.anchor_node else None
|
||||
|
||||
# Drop results beyond max_hops
|
||||
if body.anchor_node and body.max_hops is not None:
|
||||
if hop_distance is None or hop_distance > body.max_hops:
|
||||
continue
|
||||
|
||||
# Compute combined ranking score
|
||||
final_score = raw_score
|
||||
if body.anchor_node and hop_distance is not None:
|
||||
proximity = 1.0 if hop_distance == 0 else 1.0 / hop_distance
|
||||
if body.rank_by == "proximity":
|
||||
final_score = proximity
|
||||
elif body.rank_by == "hybrid":
|
||||
final_score = 0.6 * raw_score + 0.4 * proximity
|
||||
|
||||
items.append(
|
||||
SearchResultItem(
|
||||
node=_node_response(node_data),
|
||||
score=final_score,
|
||||
hop_distance=hop_distance,
|
||||
)
|
||||
)
|
||||
|
||||
if body.rank_by in ("proximity", "hybrid") and body.anchor_node:
|
||||
items.sort(key=lambda item: item.score, reverse=True)
|
||||
|
||||
return SearchResultResponse(results=items, total=len(items), query=body.query)
|
||||
|
||||
|
||||
@router.post("/distance-matrix", response_model=DistanceMatrixResponse)
|
||||
async def distance_matrix(
|
||||
body: DistanceMatrixRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if len(body.node_ids) > 50:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Too many nodes: {len(body.node_ids)} requested; maximum is 50 per request.",
|
||||
)
|
||||
|
||||
if body.metric == "semantic" and session.similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="metric='semantic' requires an embedding backend which is not available in this session.",
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_finder = session.path_finder
|
||||
|
||||
n = len(body.node_ids)
|
||||
matrix: List[List[Optional[float]]] = [[None] * n for _ in range(n)]
|
||||
unreachable: List[tuple] = []
|
||||
|
||||
for i in range(n):
|
||||
matrix[i][i] = 0.0
|
||||
for j in range(i + 1, n):
|
||||
src, tgt = body.node_ids[i], body.node_ids[j]
|
||||
try:
|
||||
if body.metric == "semantic" and session.similarity is not None:
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity, graph_dict, src, tgt
|
||||
)
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
path_fn = (
|
||||
path_finder.dijkstra_shortest_path
|
||||
if body.metric == "weighted"
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, src, tgt)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if path_nodes:
|
||||
val = (
|
||||
float(result.get("total_weight", len(path_nodes) - 1))
|
||||
if body.metric == "weighted"
|
||||
else float(len(path_nodes) - 1)
|
||||
)
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
else:
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
except Exception as exc:
|
||||
logger.debug("distance_matrix pair (%s, %s) failed: %s", src, tgt, exc)
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
return DistanceMatrixResponse(
|
||||
nodes=body.node_ids,
|
||||
metric=body.metric,
|
||||
matrix=matrix,
|
||||
unreachable_pairs=unreachable,
|
||||
computation_time_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood(
|
||||
node_id: str,
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
neighbors: List[SemanticNeighborItem] = []
|
||||
if session.similarity is not None:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
try:
|
||||
similar = await asyncio.to_thread(
|
||||
session.similarity.find_most_similar,
|
||||
graph_dict, node_id, top_k=top_k * 2
|
||||
)
|
||||
# find_most_similar returns list of (node_id, score) or dicts
|
||||
for item in similar:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
nid, sim_score = item[0], item[1]
|
||||
elif isinstance(item, dict):
|
||||
nid = item.get("node_id") or item.get("id", "")
|
||||
sim_score = item.get("similarity", item.get("score", 0.0))
|
||||
else:
|
||||
continue
|
||||
if float(sim_score) < min_similarity or nid == node_id:
|
||||
continue
|
||||
neighbor_node = await asyncio.to_thread(session.get_node, nid)
|
||||
if neighbor_node is None:
|
||||
continue
|
||||
neighbors.append(
|
||||
SemanticNeighborItem(
|
||||
id=nid,
|
||||
type=neighbor_node.get("type", ""),
|
||||
content=neighbor_node.get("content", ""),
|
||||
similarity=float(sim_score),
|
||||
)
|
||||
)
|
||||
if len(neighbors) >= top_k:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
|
||||
|
||||
return SemanticNeighborhoodResponse(
|
||||
anchor_node=node_id,
|
||||
neighbors=neighbors,
|
||||
total=len(neighbors),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GraphStatsResponse)
|
||||
async def graph_stats(
|
||||
session: GraphSession = Depends(get_session),
|
||||
|
||||
@@ -5,14 +5,20 @@ Temporal routes for snapshots, diffs, and pattern detection.
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone, UTC
|
||||
from datetime import datetime, timedelta, timezone, UTC
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
|
||||
from ..schemas import (
|
||||
DistanceEvent,
|
||||
DistanceHistoryResponse,
|
||||
DistanceSnapshot,
|
||||
TemporalDiffResponse,
|
||||
TemporalPatternResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,3 +126,122 @@ async def temporal_bounds(
|
||||
):
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
return TemporalBoundsResponse(**bounds)
|
||||
|
||||
|
||||
@router.get("/distance-history", response_model=DistanceHistoryResponse)
|
||||
async def distance_history(
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
metric: str = Query("hops", description="Distance metric: hops | weighted"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-9 — Track distance changes between two nodes across temporal snapshots."""
|
||||
from ...utils.helpers import classify_path_distance
|
||||
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
min_bound_str = bounds.get("min")
|
||||
max_bound_str = bounds.get("max")
|
||||
|
||||
if not min_bound_str or not max_bound_str:
|
||||
# No temporal data — return current-only snapshot
|
||||
pf = session.path_finder
|
||||
hop_count: Optional[int] = None
|
||||
if pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r metric=%r: %s",
|
||||
source, target, metric, exc, exc_info=True,
|
||||
)
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
snap = DistanceSnapshot(
|
||||
timestamp=now,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count) if hop_count is not None else "distant",
|
||||
)
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=[snap], events=[],
|
||||
)
|
||||
|
||||
min_bound = _parse_query_dt(min_bound_str)
|
||||
max_bound = _parse_query_dt(max_bound_str)
|
||||
|
||||
# Sample up to 10 snapshots evenly between min and max
|
||||
total_seconds = max(1, int((max_bound - min_bound).total_seconds()))
|
||||
step = total_seconds / min(10, total_seconds)
|
||||
sample_times = [
|
||||
min_bound + timedelta(seconds=int(i * step))
|
||||
for i in range(11)
|
||||
]
|
||||
|
||||
pf = session.path_finder
|
||||
history: List[DistanceSnapshot] = []
|
||||
events: List[DistanceEvent] = []
|
||||
prev_hop: Optional[int] = None
|
||||
|
||||
for sample_time in sample_times:
|
||||
active_nodes = await asyncio.to_thread(session.get_active_nodes, at_time=sample_time)
|
||||
active_ids = {n.get("id") for n in active_nodes if n.get("id")}
|
||||
hop_count = None
|
||||
if source in active_ids and target in active_ids and pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(
|
||||
session.build_graph_dict, list(active_ids)
|
||||
)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r at=%s metric=%s: %s",
|
||||
source, target, sample_time.isoformat(), metric, exc, exc_info=True,
|
||||
)
|
||||
hop_count = None
|
||||
|
||||
band = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
snap = DistanceSnapshot(timestamp=sample_time, hop_count=hop_count, distance_band=band)
|
||||
history.append(snap)
|
||||
|
||||
# Detect events relative to previous snapshot
|
||||
if prev_hop is not None or hop_count is not None:
|
||||
if prev_hop is None and hop_count is not None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="reconnected",
|
||||
hop_count_before=None,
|
||||
hop_count_after=hop_count,
|
||||
description=f"Nodes reconnected at {hop_count} hop(s) on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="disconnected",
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=None,
|
||||
description=f"Nodes became unreachable on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is not None and hop_count != prev_hop:
|
||||
etype = "convergence" if hop_count < prev_hop else "divergence"
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type=etype,
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=hop_count,
|
||||
description=(
|
||||
f"Nodes {etype}d from {prev_hop} hops to {hop_count} hops "
|
||||
f"on {sample_time.date()}."
|
||||
),
|
||||
))
|
||||
prev_hop = hop_count
|
||||
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=history, events=events,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -70,6 +71,13 @@ class PathResponse(BaseModel):
|
||||
directed: bool = True
|
||||
hop_count: int = 0
|
||||
distance_band: str = "direct"
|
||||
# FR-4 enrichment fields — all optional; existing callers unaffected
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
alternative_path_count: int = 0
|
||||
interpretation: str = ""
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
@@ -84,11 +92,19 @@ class SearchRequest(BaseModel):
|
||||
query: str
|
||||
filters: Dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=20, ge=1, le=200)
|
||||
# FR-7 proximity constraint fields
|
||||
anchor_node: Optional[str] = None
|
||||
max_hops: Optional[int] = None
|
||||
min_semantic_similarity: Optional[float] = None
|
||||
rank_by: Literal["relevance", "proximity", "hybrid"] = "relevance"
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
node: NodeResponse
|
||||
score: float = 0.0
|
||||
# FR-7 distance metadata
|
||||
hop_distance: Optional[int] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
|
||||
|
||||
class SearchResultResponse(BaseModel):
|
||||
@@ -308,3 +324,91 @@ class ProvenanceEdge(BaseModel):
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-6 — Distance Matrix API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceMatrixRequest(BaseModel):
|
||||
node_ids: List[str]
|
||||
metric: Literal["hops", "weighted", "semantic"] = "hops"
|
||||
|
||||
|
||||
class DistanceMatrixResponse(BaseModel):
|
||||
nodes: List[str]
|
||||
metric: str
|
||||
matrix: List[List[Optional[float]]]
|
||||
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
|
||||
computation_time_ms: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-3 backend — Semantic Neighborhood
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SemanticNeighborItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
similarity: float
|
||||
hop_distance: Optional[int] = None
|
||||
|
||||
|
||||
class SemanticNeighborhoodResponse(BaseModel):
|
||||
anchor_node: str
|
||||
neighbors: List[SemanticNeighborItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-8 — Causal Distance Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CausalDistanceReport(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
causal_path: List[str]
|
||||
causal_hop_count: int
|
||||
intermediate_decisions: List[str]
|
||||
confidence_decay: float
|
||||
weakest_link: Optional[Dict[str, Any]] = None
|
||||
interpretation: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-9 — Temporal Distance Alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceSnapshot(BaseModel):
|
||||
timestamp: datetime
|
||||
hop_count: Optional[int] = None
|
||||
distance_band: str
|
||||
|
||||
|
||||
class DistanceEvent(BaseModel):
|
||||
timestamp: datetime
|
||||
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
|
||||
hop_count_before: Optional[int] = None
|
||||
hop_count_after: Optional[int] = None
|
||||
description: str
|
||||
|
||||
|
||||
class DistanceHistoryResponse(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
metric: str
|
||||
history: List[DistanceSnapshot]
|
||||
events: List[DistanceEvent]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-10 — Distance-Enriched Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceExportRequest(BaseModel):
|
||||
format: Literal["csv", "jsonl"] = "csv"
|
||||
node_subset: Optional[List[str]] = None
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
@@ -162,6 +162,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from .arango_aql_exporter import ArangoAQLExporter
|
||||
from .distance_exporter import DistanceExporter
|
||||
from .config import ExportConfig, export_config
|
||||
|
||||
try:
|
||||
@@ -220,6 +221,7 @@ __all__ = [
|
||||
# Core Exporters
|
||||
"ArrowExporter",
|
||||
"ArangoAQLExporter",
|
||||
"DistanceExporter",
|
||||
"RDFExporter",
|
||||
"RDFSerializer",
|
||||
"RDFValidator",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Distance-Enriched Export (FR-10)
|
||||
|
||||
Exports pairwise node distance metrics — hop count, weighted distance,
|
||||
semantic similarity, distance band, betweenness centrality — in CSV or
|
||||
JSONL format for downstream ML pipelines (GNN training, clustering,
|
||||
link prediction).
|
||||
|
||||
Python API:
|
||||
exporter = DistanceExporter(graph)
|
||||
df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"])
|
||||
exporter.to_csv("distances.csv")
|
||||
exporter.to_jsonl("distances.jsonl")
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
from ..kg import PathFinder, SimilarityCalculator, CentralityCalculator
|
||||
_KG_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
logger.debug("KG components not available; distance exporter will run in reduced mode: %s", exc)
|
||||
|
||||
_ALL_COLUMNS = [
|
||||
"source_id", "source_type", "target_id", "target_type",
|
||||
"hop_count", "weighted_distance", "semantic_similarity",
|
||||
"distance_band", "source_betweenness", "target_betweenness",
|
||||
]
|
||||
|
||||
|
||||
class DistanceExporter:
|
||||
"""Compute and export pairwise distance metrics for a ContextGraph."""
|
||||
|
||||
def __init__(self, graph: Any) -> None:
|
||||
self.graph = graph
|
||||
self._path_finder = PathFinder() if _KG_AVAILABLE else None
|
||||
self._similarity = SimilarityCalculator() if _KG_AVAILABLE else None
|
||||
self._centrality = CentralityCalculator() if _KG_AVAILABLE else None
|
||||
|
||||
def _build_graph_dict(self) -> Dict[str, Any]:
|
||||
nodes = [
|
||||
{"id": n.node_id, "type": n.node_type, "content": n.content, "properties": n.properties}
|
||||
for n in self.graph.nodes.values()
|
||||
]
|
||||
edges_raw = getattr(self.graph, "edges", [])
|
||||
edges = [
|
||||
{
|
||||
"id": e.edge_id, "source": e.source_id, "target": e.target_id,
|
||||
"type": e.edge_type, "weight": e.weight,
|
||||
}
|
||||
for e in edges_raw
|
||||
]
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
def _node_type(self, node_id: str) -> str:
|
||||
node = getattr(self.graph, "nodes", {}).get(node_id)
|
||||
return getattr(node, "node_type", "") if node else ""
|
||||
|
||||
def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]:
|
||||
if self._centrality is None:
|
||||
return {}
|
||||
try:
|
||||
result = self._centrality.calculate_betweenness_centrality(graph_dict)
|
||||
return result.get("betweenness", {}) if isinstance(result, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt)
|
||||
path = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
return len(path) - 1 if path else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt)
|
||||
if isinstance(result, dict):
|
||||
return float(result.get("total_weight", len(result.get("path", [])) - 1))
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._similarity is None:
|
||||
return None
|
||||
try:
|
||||
sim = self._similarity.cosine_similarity(graph_dict, src, tgt)
|
||||
return float(sim) if isinstance(sim, (int, float)) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def compute_pairs(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compute all pairwise distance metrics and return as a list of dicts."""
|
||||
include_set = set(include or _ALL_COLUMNS)
|
||||
graph_dict = self._build_graph_dict()
|
||||
|
||||
node_ids = node_subset or list(self.graph.nodes.keys())
|
||||
|
||||
betweenness: Dict[str, float] = {}
|
||||
if "source_betweenness" in include_set or "target_betweenness" in include_set:
|
||||
betweenness = self._betweenness(graph_dict)
|
||||
|
||||
rows = []
|
||||
for i, src in enumerate(node_ids):
|
||||
for tgt in node_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
row: Dict[str, Any] = {}
|
||||
if "source_id" in include_set:
|
||||
row["source_id"] = src
|
||||
if "source_type" in include_set:
|
||||
row["source_type"] = self._node_type(src)
|
||||
if "target_id" in include_set:
|
||||
row["target_id"] = tgt
|
||||
if "target_type" in include_set:
|
||||
row["target_type"] = self._node_type(tgt)
|
||||
|
||||
hop_count: Optional[int] = None
|
||||
if "hop_count" in include_set or "distance_band" in include_set:
|
||||
hop_count = self._hop_distance(graph_dict, src, tgt)
|
||||
if "hop_count" in include_set:
|
||||
row["hop_count"] = hop_count
|
||||
|
||||
if "weighted_distance" in include_set:
|
||||
row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt)
|
||||
|
||||
if "semantic_similarity" in include_set:
|
||||
row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt)
|
||||
|
||||
if "distance_band" in include_set:
|
||||
row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
|
||||
if "source_betweenness" in include_set:
|
||||
row["source_betweenness"] = betweenness.get(src)
|
||||
if "target_betweenness" in include_set:
|
||||
row["target_betweenness"] = betweenness.get(tgt)
|
||||
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def to_dataframe(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
"""Return a pandas DataFrame of pairwise distances."""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required for to_dataframe()") from exc
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def to_csv(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a CSV file."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
fh.write("")
|
||||
return
|
||||
fieldnames = list(rows[0].keys())
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
def to_jsonl(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a JSONL file (one JSON object per line)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, default=str) + "\n")
|
||||
|
||||
def to_csv_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return CSV as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
return ""
|
||||
buf = io.StringIO()
|
||||
fieldnames = list(rows[0].keys())
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
def to_jsonl_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return JSONL as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return "\n".join(json.dumps(row, default=str) for row in rows)
|
||||
+15
-3
@@ -188,10 +188,22 @@ async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="API route not found")
|
||||
|
||||
# Root path — serve index.html if built, otherwise a welcome JSON response
|
||||
if full_path in ("", "/"):
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.is_file():
|
||||
return FileResponse(index_file)
|
||||
return JSONResponse({
|
||||
"name": "Semantica Knowledge Explorer",
|
||||
"version": __version__,
|
||||
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
})
|
||||
|
||||
normalized_path = os.path.normpath(full_path)
|
||||
if (
|
||||
normalized_path in ("", ".")
|
||||
or os.path.isabs(normalized_path)
|
||||
os.path.isabs(normalized_path)
|
||||
or normalized_path == ".."
|
||||
or normalized_path.startswith(".." + os.sep)
|
||||
):
|
||||
@@ -200,7 +212,7 @@ async def serve_spa(full_path: str):
|
||||
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
||||
safe_rel_path = normalized_path.lstrip("/\\")
|
||||
rel_parts = Path(safe_rel_path).parts
|
||||
if any(part in ("", ".", "..") for part in rel_parts):
|
||||
if any(part in (".", "..") for part in rel_parts):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
static_dir_resolved = STATIC_DIR.resolve()
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Targeted regression tests for all 13 Qodo review fixes on the Distance Intelligence PR."""
|
||||
import re
|
||||
import inspect
|
||||
|
||||
|
||||
# ── bug_003: include_distance_metadata=False is the backward-compat default ───
|
||||
|
||||
def test_bug003_metadata_absent_by_default():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A")
|
||||
assert len(neighbors) == 1
|
||||
assert "hop" in neighbors[0]
|
||||
assert "distance_band" not in neighbors[0], (
|
||||
f"distance_band should be absent by default; got keys: {list(neighbors[0].keys())}"
|
||||
)
|
||||
assert "confidence_decay" not in neighbors[0]
|
||||
assert "path_to_anchor" not in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_metadata_present_with_flag():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A", include_distance_metadata=True)
|
||||
assert len(neighbors) == 1
|
||||
assert "distance_band" in neighbors[0]
|
||||
assert "confidence_decay" in neighbors[0]
|
||||
assert "path_to_anchor" in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_get_neighbor_distances_still_works():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related", weight=0.9)
|
||||
nd = g.get_neighbor_distances("A")
|
||||
assert len(nd) == 1
|
||||
assert nd[0]["distance_band"] == "direct"
|
||||
assert abs(nd[0]["confidence_decay"] - 0.9) < 1e-9
|
||||
assert "path_to_anchor" in nd[0]
|
||||
|
||||
|
||||
# ── bug_004: weakest_link standardized to edge_weight key ─────────────────────
|
||||
|
||||
def test_bug004_weakest_link_uses_edge_weight_key():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "decision")
|
||||
g.add_node("B", "decision")
|
||||
g.add_node("C", "decision")
|
||||
g.add_edge("A", "B", "causes", weight=0.8)
|
||||
g.add_edge("B", "C", "causes", weight=0.5)
|
||||
analyzer = CausalChainAnalyzer(g)
|
||||
report = analyzer.interpret_causal_distance("A", "C")
|
||||
wl = report.get("weakest_link")
|
||||
assert wl is not None, "weakest_link must be set for a 2-hop causal path"
|
||||
assert "edge_weight" in wl, f"Expected edge_weight key, got: {list(wl.keys())}"
|
||||
assert "weight" not in wl, f"Old key 'weight' should be absent; got: {list(wl.keys())}"
|
||||
assert wl["edge_weight"] == 0.5
|
||||
|
||||
|
||||
def test_bug004_causal_distance_report_schema_validates():
|
||||
from semantica.explorer.schemas import CausalDistanceReport
|
||||
report = CausalDistanceReport(
|
||||
source_id="A",
|
||||
target_id="C",
|
||||
causal_path=["A", "B", "C"],
|
||||
causal_hop_count=2,
|
||||
intermediate_decisions=["B"],
|
||||
confidence_decay=0.4,
|
||||
weakest_link={"source": "A", "target": "B", "edge_weight": 0.5},
|
||||
interpretation="Test path",
|
||||
)
|
||||
assert report.weakest_link["edge_weight"] == 0.5
|
||||
|
||||
|
||||
# ── qual_003: _distance_band static methods removed; classify_path_distance used ─
|
||||
|
||||
def test_qual003_distance_band_removed_from_causal_analyzer():
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
assert not hasattr(CausalChainAnalyzer, "_distance_band")
|
||||
ca_src = inspect.getsource(CausalChainAnalyzer)
|
||||
assert "def _distance_band" not in ca_src
|
||||
assert "classify_path_distance" in ca_src
|
||||
|
||||
|
||||
def test_qual003_distance_band_removed_from_agent_context():
|
||||
import semantica.context.agent_context as ac_mod
|
||||
ac_src = inspect.getsource(ac_mod)
|
||||
assert "def _distance_band" not in ac_src
|
||||
assert "classify_path_distance" in ac_src
|
||||
|
||||
|
||||
# ── bug_005: timedelta arithmetic — no timetuple reconstruction ───────────────
|
||||
|
||||
def test_bug005_no_timetuple_hack_in_distance_history():
|
||||
from semantica.explorer.routes import temporal
|
||||
src = inspect.getsource(temporal.distance_history)
|
||||
assert "timetuple" not in src, "Old timetuple hack should be gone"
|
||||
assert "__import__" not in src, "Dynamic import hack should be gone"
|
||||
assert "timedelta(seconds" in src
|
||||
|
||||
|
||||
# ── sec_001: node_subset capped at 200 ────────────────────────────────────────
|
||||
|
||||
def test_sec001_node_subset_limit_constant_exists():
|
||||
from semantica.explorer.routes.export_import import _DISTANCE_EXPORT_MAX_NODES
|
||||
assert _DISTANCE_EXPORT_MAX_NODES == 200
|
||||
|
||||
|
||||
def test_sec001_export_endpoint_validates_subset_size():
|
||||
from semantica.explorer.routes import export_import
|
||||
src = inspect.getsource(export_import.export_distance_enriched)
|
||||
assert "_DISTANCE_EXPORT_MAX_NODES" in src
|
||||
assert "status_code=413" in src
|
||||
|
||||
|
||||
# ── sec_002: distance matrix upper-triangle only ──────────────────────────────
|
||||
|
||||
def test_sec002_distance_matrix_upper_triangle_loop():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.distance_matrix)
|
||||
assert "range(i + 1, n)" in src, "Should use upper-triangle loop"
|
||||
assert "matrix[j][i]" in src, "Should mirror lower triangle"
|
||||
|
||||
|
||||
# ── bug_006: O(L) edge weight index built once ────────────────────────────────
|
||||
|
||||
def test_bug006_edge_weight_index_built_once():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.find_path)
|
||||
assert "edge_weight_index" in src
|
||||
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
|
||||
|
||||
|
||||
# ── bug_007: original result id not overwritten ───────────────────────────────
|
||||
|
||||
def test_bug007_original_id_not_overwritten():
|
||||
from semantica.context import agent_context
|
||||
src = inspect.getsource(agent_context.AgentContext._apply_proximity_metadata)
|
||||
assert (
|
||||
'"graph_node_id": result_id' in src
|
||||
or "'graph_node_id': result_id" in src
|
||||
)
|
||||
assert '"id": result_id' not in src, "id should not be overwritten by result_id"
|
||||
|
||||
|
||||
# ── qual_002: no bare except:pass in enrichment blocks ───────────────────────
|
||||
|
||||
def test_qual002_no_bare_except_pass_in_find_path():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.find_path)
|
||||
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
|
||||
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
|
||||
assert "logger.debug" in src
|
||||
|
||||
|
||||
# ── TypeScript fixes — checked via raw file reads ─────────────────────────────
|
||||
|
||||
TS_BEHAVIOR = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\behaviors\pathHighlightBehavior.ts"
|
||||
)
|
||||
TS_WORKSPACE = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\GraphWorkspace.tsx"
|
||||
)
|
||||
|
||||
|
||||
def test_bug008_sweep_generation_counter():
|
||||
with open(TS_BEHAVIOR, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "sweepGeneration" in src, "Generation counter variable must exist"
|
||||
assert "gen !== sweepGeneration" in src, "Stale-callback guard must exist"
|
||||
assert "sweepGeneration++" in src, "Counter must be incremented on cancel"
|
||||
|
||||
|
||||
def test_bug001_semantic_neighborhood_uses_top_k():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "top_k=50" in src, "Should use top_k (not limit) to match backend param"
|
||||
idx = src.find("semantic-neighborhood?")
|
||||
snippet = src[idx: idx + 100]
|
||||
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
|
||||
|
||||
|
||||
def test_bug002_semantic_neighborhood_response_type_complete():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "anchor_node: string" in src
|
||||
assert "hop_distance?" in src
|
||||
|
||||
|
||||
def test_qual001_ego_heatmap_merged_into_single_effect():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId" in src, (
|
||||
"Combined dep array must be present"
|
||||
)
|
||||
# The old separate dep arrays must not exist
|
||||
assert "], [egoModeEnabled, egoMaxHops, selectedNodeId]" not in src
|
||||
assert "], [heatmapEnabled, selectedNodeId]" not in src
|
||||
@@ -0,0 +1,97 @@
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
|
||||
def test_get_neighbor_distances_tracks_path_decay_and_band():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("A", "entity", "Anchor")
|
||||
graph.add_node("B", "entity", "Bridge")
|
||||
graph.add_node("C", "decision", "Decision")
|
||||
graph.add_edge("A", "B", "influences", weight=0.9)
|
||||
graph.add_edge("B", "C", "influences", weight=0.7)
|
||||
|
||||
neighbors = graph.get_neighbor_distances("A", hops=2, min_confidence=0.5)
|
||||
c_neighbor = next(item for item in neighbors if item["id"] == "C")
|
||||
|
||||
assert c_neighbor["hop"] == 2
|
||||
assert c_neighbor["distance_band"] == "near"
|
||||
assert c_neighbor["confidence_decay"] == 0.63
|
||||
assert c_neighbor["path_to_anchor"] == ["A", "B", "C"]
|
||||
|
||||
|
||||
def test_trace_decision_causality_returns_auditable_chain_dicts():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
first = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve initial risk policy",
|
||||
reasoning="Baseline risk controls look sound",
|
||||
outcome="approved",
|
||||
confidence=0.8,
|
||||
entities=["account_123"],
|
||||
)
|
||||
second = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve follow-up risk exception",
|
||||
reasoning="Prior account controls still apply",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["account_123"],
|
||||
)
|
||||
graph._decisions[first]["timestamp"] = 1
|
||||
graph._decisions[second]["timestamp"] = 2
|
||||
|
||||
chains = graph.trace_decision_causality(second, max_depth=2)
|
||||
|
||||
assert chains
|
||||
assert chains[0]["hop_count"] == 1
|
||||
assert chains[0]["distance_band"] == "direct"
|
||||
assert chains[0]["weakest_link"]["from"] == first
|
||||
assert chains[0]["hops"][0]["to"] == second
|
||||
assert "confidence" in chains[0]["interpretation"]
|
||||
assert list(chains[0])[0]["from"] == first
|
||||
|
||||
|
||||
def test_analyze_decision_influence_exposes_score_breakdown():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
source = graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Approve secured loan",
|
||||
reasoning="Collateral and income verified",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Review related refinance",
|
||||
reasoning="Same borrower and collateral",
|
||||
outcome="review",
|
||||
confidence=0.8,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
|
||||
result = graph.analyze_decision_influence(source)
|
||||
|
||||
assert result["influence_scores"]
|
||||
score = result["influence_scores"][0]
|
||||
assert set(score["score_breakdown"]) == {
|
||||
"entity_overlap",
|
||||
"category_match",
|
||||
"temporal_proximity",
|
||||
}
|
||||
assert score["is_direct"] is True
|
||||
|
||||
|
||||
def test_cross_graph_path_traverses_link_boundary():
|
||||
left = ContextGraph(advanced_analytics=False)
|
||||
right = ContextGraph(advanced_analytics=False)
|
||||
left.add_node("A", "entity", "Left")
|
||||
right.add_node("B", "entity", "Right")
|
||||
left.link_graph(right, "A", "B")
|
||||
|
||||
path = left.cross_graph_path("A", right, "B")
|
||||
|
||||
assert path["reachable"] is True
|
||||
assert path["hop_count"] == 1
|
||||
assert path["cross_graph_links_used"] == 1
|
||||
assert path["distance_band"] == "direct"
|
||||
assert path["path"] == [(left.graph_id, "A"), (right.graph_id, "B")]
|
||||
Reference in New Issue
Block a user