Compare commits

...
4 Commits
Author SHA1 Message Date
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 6ffed78fd9 Potential fix for pull request finding 'Module is imported with 'import' and 'import from''
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-27 18:39:09 +05:30
KaifAhmad1 f06de0dab2 fix(context): address PR #512 review blockers and bot findings
Merge blockers (ZohaibHassan16):
- fix: distance-matrix raises HTTP 503 when metric=semantic but no
  similarity backend is available, instead of silently returning hop
  distances labeled as semantic
- fix: distance-enriched export now requires node_subset (HTTP 422 if
  omitted), preventing unbounded all-pairs O(n^2) export over full graph
- fix: DistanceExportRequest default include corrected from ["hops",
  "distance_band"] to ["source_id", "target_id", "hop_count",
  "distance_band"] so default exports are unambiguous and use the correct
  column name
- fix: confidence decay edge weight index now reads graph_dict.get("edges")
  or graph_dict.get("relationships") to handle both graph dict shapes,
  fixing always-1.0 decay when session returns relationships key

Bot findings (github-code-quality / chatgpt-codex):
- fix: remove unused Iterable import from distance_exporter.py
- fix: remove unused Response import from graph.py
- fix: move logger init before optional KG import; replace empty
  except ImportError: pass with logger.debug in distance_exporter.py
- fix: replace two bare except Exception: pass in temporal.distance_history
  with logger.warning including source, target, metric, and timestamp context
- fix: remove mixed import style in test_qual003 — use only module import
  and reference CausalChainAnalyzer through it
2026-04-27 18:28:04 +05:30
KaifAhmad1 dd016744ce feat(context): add distance intelligence across context, API, and Explorer (#502)
- ContextGraph.get_neighbors() gains include_distance_metadata flag (backward-compat)
- get_neighbor_distances() returns neighbors sorted by hop and confidence decay
- AgentContext.retrieve/find_precedents support proximity-weighted blending
- FR-4: path enrichment (decay, similarity, coherence, bottleneck, interpretation)
- FR-6: POST /api/graph/distance-matrix (hops/weighted/semantic, upper-triangle)
- FR-3: GET /api/graph/node/{id}/semantic-neighborhood
- FR-8: GET /api/decisions/causal-distance (causal-edge-only BFS)
- FR-9: GET /api/temporal/distance-history (convergence/divergence events)
- FR-10: POST /api/export/distance-enriched (CSV/JSONL, 200-node cap)
- Explorer: PathDistanceIntelPanel, Ego Mode, Structural/Semantic overlay, Heatmap
- Fix 13 Qodo review issues: API param mismatch, O(E*L) decay, breaking change,
  schema key inconsistency, datetime arithmetic, id overwrite, sweep race,
  node_subset DoS, full-matrix redundancy, effect race, silent exceptions, duplication
- 57 new tests in test_distance_intelligence.py; 18 regression tests in _smoke_review_fixes.py
2026-04-27 11:07:27 +05:30
Mohd KaifandClaude Sonnet 4.6 7884d71e23 feat(explorer): add welcome screen and fix root path Invalid path error (#501)
- Add WelcomeScreen shown on app load; SKE brand button navigates back
- Fix serve_spa: empty root path was hitting dot-guard returning 400
  Invalid path instead of index.html or a welcome JSON response

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:41:13 +05:30
18 changed files with 2172 additions and 59 deletions
+25
View File
@@ -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 18), 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
View File
@@ -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);
}
},
};
}
+206 -6
View File
@@ -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.
+100
View File
@@ -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.",
}
+207 -24
View File
@@ -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."""
+15 -1
View File
@@ -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,
+56 -1
View File
@@ -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"'},
)
+303 -6
View File
@@ -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),
+127 -2
View File
@@ -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,
)
+105 -1
View File
@@ -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"],
)
+2
View File
@@ -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",
+226
View File
@@ -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
View File
@@ -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()
+210
View File
@@ -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")]