mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +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]
|
## [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):
|
- **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 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.
|
- 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 KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
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 ExploreView = 'graph' | 'vocabulary';
|
||||||
type AnalyzeView = 'sparql' | 'reasoning';
|
type AnalyzeView = 'sparql' | 'reasoning';
|
||||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||||
@@ -311,14 +311,39 @@ function WorkspaceFallback() {
|
|||||||
return <div className="workspace-loading">Loading workspace…</div>;
|
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() {
|
export default function App() {
|
||||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||||
|
|
||||||
const renderWorkspace = () => {
|
const renderWorkspace = () => {
|
||||||
|
if (activeWorkspace === 'welcome') {
|
||||||
|
return <WelcomeScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeWorkspace === 'explore') {
|
if (activeWorkspace === 'explore') {
|
||||||
return (
|
return (
|
||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
@@ -451,7 +476,7 @@ export default function App() {
|
|||||||
<style>{shellStyles}</style>
|
<style>{shellStyles}</style>
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside className="app-rail">
|
<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 }) => (
|
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { graph } from "../../store/graphStore";
|
import { graph } from "../../store/graphStore";
|
||||||
import { GRAPH_THEME } from "./graphTheme";
|
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||||
import type { GraphSelectedNodeKind } from "./types";
|
import type { GraphSelectedNodeKind } from "./types";
|
||||||
|
|
||||||
export type LinkPrediction = {
|
export type LinkPrediction = {
|
||||||
@@ -17,6 +17,13 @@ export type PathResponse = {
|
|||||||
total_weight: number;
|
total_weight: number;
|
||||||
hop_count: number;
|
hop_count: number;
|
||||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
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 {
|
export interface GraphInspectorPanelProps {
|
||||||
@@ -46,6 +53,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
|||||||
.map((key) => ({ key, value: properties[key] }));
|
.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 ──────────────────────────────────────── */
|
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||||
|
|
||||||
function getNodeLabel(nodeId: string): string {
|
function getNodeLabel(nodeId: string): string {
|
||||||
@@ -83,11 +216,13 @@ function PathFlowViz({
|
|||||||
path,
|
path,
|
||||||
edgeIds,
|
edgeIds,
|
||||||
totalWeight,
|
totalWeight,
|
||||||
|
bottleneckNodeId,
|
||||||
onFocusNode,
|
onFocusNode,
|
||||||
}: {
|
}: {
|
||||||
path: string[];
|
path: string[];
|
||||||
edgeIds?: string[];
|
edgeIds?: string[];
|
||||||
totalWeight: number;
|
totalWeight: number;
|
||||||
|
bottleneckNodeId?: string | null;
|
||||||
onFocusNode?: (nodeId: string) => void;
|
onFocusNode?: (nodeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (path.length === 0) {
|
if (path.length === 0) {
|
||||||
@@ -110,10 +245,13 @@ function PathFlowViz({
|
|||||||
{/* Node chip */}
|
{/* Node chip */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onFocusNode?.(nodeId)}
|
onClick={() => onFocusNode?.(nodeId)}
|
||||||
title={`Focus: ${nodeId}`}
|
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||||
style={{
|
style={{
|
||||||
...pathNodeChipStyle,
|
...pathNodeChipStyle,
|
||||||
cursor: onFocusNode ? "pointer" : "default",
|
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>
|
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||||
@@ -316,12 +454,16 @@ export function GraphInspectorPanel({
|
|||||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||||
|
|
||||||
{pathResult?.path?.length ? (
|
{pathResult?.path?.length ? (
|
||||||
|
<>
|
||||||
<PathFlowViz
|
<PathFlowViz
|
||||||
path={pathResult.path}
|
path={pathResult.path}
|
||||||
edgeIds={pathResult.edge_ids}
|
edgeIds={pathResult.edge_ids}
|
||||||
totalWeight={pathResult.total_weight}
|
totalWeight={pathResult.total_weight}
|
||||||
|
bottleneckNodeId={pathResult.bottleneck_node}
|
||||||
onFocusNode={onFocusNode}
|
onFocusNode={onFocusNode}
|
||||||
/>
|
/>
|
||||||
|
<PathDistanceIntelPanel result={pathResult} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={emptyTextStyle}>
|
<div style={emptyTextStyle}>
|
||||||
Choose a target or click a candidate prediction to prepare a path trace.
|
Choose a target or click a candidate prediction to prepare a path trace.
|
||||||
@@ -567,3 +709,41 @@ const pathEdgeLabelStyle: CSSProperties = {
|
|||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
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 [graphAnalyticsState, setGraphAnalyticsState] = useState<GraphAnalyticsSnapshot | null>(null);
|
||||||
const [loadedPlugins, setLoadedPlugins] = useState<Record<string, GraphPlugin>>({});
|
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 debouncedTime = useDebounce(scrubberTime, 150);
|
||||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||||
const sceneRef = useRef<GraphSceneHandle>(null);
|
const sceneRef = useRef<GraphSceneHandle>(null);
|
||||||
@@ -1012,7 +1020,7 @@ export function GraphWorkspace() {
|
|||||||
setFocusedNodeId(nextSelectedNodeId);
|
setFocusedNodeId(nextSelectedNodeId);
|
||||||
setIsLayoutRunning(false);
|
setIsLayoutRunning(false);
|
||||||
}
|
}
|
||||||
}, [viewMode]);
|
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||||
|
|
||||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||||
setSelectedEdgeId(edgeId);
|
setSelectedEdgeId(edgeId);
|
||||||
@@ -1164,6 +1172,159 @@ export function GraphWorkspace() {
|
|||||||
setLastGroupedSelectedNodeId("");
|
setLastGroupedSelectedNodeId("");
|
||||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
}, [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 showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||||
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
||||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
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) {
|
if (pluginToolbarItems.length) {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: "plugin-tools",
|
id: "plugin-tools",
|
||||||
@@ -1764,15 +1972,18 @@ export function GraphWorkspace() {
|
|||||||
canActivateFocusedMode,
|
canActivateFocusedMode,
|
||||||
displayState.groupedViewAvailable,
|
displayState.groupedViewAvailable,
|
||||||
displayState.groupedViewReason,
|
displayState.groupedViewReason,
|
||||||
|
distanceMode,
|
||||||
|
egoModeEnabled,
|
||||||
|
egoMaxHops,
|
||||||
|
focusedSelectionResolution.reason,
|
||||||
handlePluginAction,
|
handlePluginAction,
|
||||||
hasGraphContent,
|
hasGraphContent,
|
||||||
|
heatmapEnabled,
|
||||||
isLayoutRunning,
|
isLayoutRunning,
|
||||||
pluginToolbarItems,
|
pluginToolbarItems,
|
||||||
reload,
|
reload,
|
||||||
requestViewMode,
|
requestViewMode,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
canActivateFocusedMode,
|
|
||||||
focusedSelectionResolution.reason,
|
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectedNodeState,
|
selectedNodeState,
|
||||||
showLoadingOverlay,
|
showLoadingOverlay,
|
||||||
@@ -1875,6 +2086,22 @@ export function GraphWorkspace() {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{searchError ? <div style={{ color: "#ff7b72", fontSize: 12 }}>{searchError}</div> : null}
|
||||||
|
|
||||||
{searchResults.length ? (
|
{searchResults.length ? (
|
||||||
@@ -2056,6 +2283,7 @@ export function GraphWorkspace() {
|
|||||||
onTracePath={() => void handleTracePath()}
|
onTracePath={() => void handleTracePath()}
|
||||||
pathResult={pathResult}
|
pathResult={pathResult}
|
||||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||||
|
onFocusNode={focusNode}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
import type { GraphBehavior } from "./types";
|
import type { GraphBehavior } from "./types";
|
||||||
|
|
||||||
|
const SWEEP_TICKS = 6;
|
||||||
|
const SWEEP_INTERVAL_MS = 60;
|
||||||
|
|
||||||
export function createPathHighlightBehavior(): GraphBehavior {
|
export function createPathHighlightBehavior(): GraphBehavior {
|
||||||
let lastPathSignature = "";
|
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 {
|
return {
|
||||||
id: "path-highlight",
|
id: "path-highlight",
|
||||||
attach: () => {},
|
attach: () => {},
|
||||||
detach: () => {
|
detach: (context) => {
|
||||||
|
cancelSweep();
|
||||||
lastPathSignature = "";
|
lastPathSignature = "";
|
||||||
|
context.sigma.refresh();
|
||||||
},
|
},
|
||||||
onStateChange: (context, interactionState) => {
|
onStateChange: (context, interactionState) => {
|
||||||
const nextPathSignature = interactionState.activePath.join("::");
|
const nextPathSignature = interactionState.activePath.join("::");
|
||||||
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastPathSignature = nextPathSignature;
|
lastPathSignature = nextPathSignature;
|
||||||
|
cancelSweep();
|
||||||
context.sigma.refresh();
|
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 datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from ..utils.helpers import classify_path_distance
|
||||||
from ..utils.logging import get_logger
|
from ..utils.logging import get_logger
|
||||||
from .agent_memory import AgentMemory
|
from .agent_memory import AgentMemory
|
||||||
from .context_retriever import ContextRetriever, RetrievedContext
|
from .context_retriever import ContextRetriever, RetrievedContext
|
||||||
@@ -507,6 +508,10 @@ class AgentContext:
|
|||||||
include_relationships: bool = False,
|
include_relationships: bool = False,
|
||||||
expand_graph: bool = True,
|
expand_graph: bool = True,
|
||||||
deduplicate: 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,
|
**kwargs,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -560,17 +565,33 @@ class AgentContext:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
# Convert RetrievedContext to dicts
|
# Convert RetrievedContext to dicts
|
||||||
return [
|
result_dicts = [
|
||||||
self._context_to_dict(r, include_entities, include_relationships)
|
self._context_to_dict(r, include_entities, include_relationships)
|
||||||
for r in results
|
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:
|
else:
|
||||||
# Simple RAG: Use AgentMemory (vector + memory)
|
# Simple RAG: Use AgentMemory (vector + memory)
|
||||||
results = self._memory.retrieve(
|
results = self._memory.retrieve(
|
||||||
query, max_results=max_results, min_score=min_score, **kwargs
|
query, max_results=max_results, min_score=min_score, **kwargs
|
||||||
)
|
)
|
||||||
# Convert to dicts
|
# 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(
|
def query_with_reasoning(
|
||||||
self,
|
self,
|
||||||
@@ -814,6 +835,77 @@ class AgentContext:
|
|||||||
|
|
||||||
return result
|
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]:
|
def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Convert memory result to dict."""
|
"""Convert memory result to dict."""
|
||||||
return {
|
return {
|
||||||
@@ -2228,7 +2320,10 @@ class AgentContext:
|
|||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
use_kg_features: bool = True,
|
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]:
|
) -> List[Decision]:
|
||||||
"""
|
"""
|
||||||
Find precedents using advanced KG and vector store features.
|
Find precedents using advanced KG and vector store features.
|
||||||
@@ -2248,7 +2343,7 @@ class AgentContext:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if hasattr(self._decision_query, 'find_precedents_hybrid'):
|
if hasattr(self._decision_query, 'find_precedents_hybrid'):
|
||||||
return self._decision_query.find_precedents_hybrid(
|
precedents = self._decision_query.find_precedents_hybrid(
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
category=category,
|
category=category,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -2257,11 +2352,116 @@ class AgentContext:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to basic method
|
# 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:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
||||||
return []
|
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]:
|
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Analyze decision influence using advanced graph algorithms.
|
Analyze decision influence using advanced graph algorithms.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Set
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from ..graph_store import GraphStore
|
from ..graph_store import GraphStore
|
||||||
|
from ..utils.helpers import classify_path_distance
|
||||||
from ..utils.logging import get_logger
|
from ..utils.logging import get_logger
|
||||||
from .decision_models import Decision
|
from .decision_models import Decision
|
||||||
|
|
||||||
@@ -677,3 +678,102 @@ class CausalChainAnalyzer:
|
|||||||
else:
|
else:
|
||||||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
||||||
return decisions
|
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.logging import get_logger
|
||||||
from ..utils.progress_tracker import get_progress_tracker
|
from ..utils.progress_tracker import get_progress_tracker
|
||||||
|
from ..utils.helpers import classify_path_distance
|
||||||
from .entity_linker import EntityLinker
|
from .entity_linker import EntityLinker
|
||||||
|
|
||||||
# Optional imports for advanced features
|
# Optional imports for advanced features
|
||||||
@@ -130,6 +131,13 @@ except ImportError:
|
|||||||
KG_AVAILABLE = False
|
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]:
|
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||||
|
|
||||||
@@ -739,6 +747,7 @@ class ContextGraph:
|
|||||||
min_weight: float = 0.0,
|
min_weight: float = 0.0,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
|
include_distance_metadata: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get neighbors of a node.
|
Get neighbors of a node.
|
||||||
@@ -762,11 +771,11 @@ class ContextGraph:
|
|||||||
|
|
||||||
neighbors: List[Dict[str, Any]] = []
|
neighbors: List[Dict[str, Any]] = []
|
||||||
visited = {node_id}
|
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
|
rel_filter = set(relationship_types) if relationship_types else None
|
||||||
|
|
||||||
while queue:
|
while queue:
|
||||||
current_id, current_hop = queue.popleft()
|
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
|
||||||
if current_hop >= hops:
|
if current_hop >= hops:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -780,26 +789,59 @@ class ContextGraph:
|
|||||||
if neighbor_id in visited:
|
if neighbor_id in visited:
|
||||||
continue
|
continue
|
||||||
visited.add(neighbor_id)
|
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)
|
node = self.nodes.get(neighbor_id)
|
||||||
if not node:
|
if not node:
|
||||||
continue
|
continue
|
||||||
neighbors.append(
|
entry: Dict[str, Any] = {
|
||||||
{
|
|
||||||
"id": node.node_id,
|
"id": node.node_id,
|
||||||
"type": node.node_type,
|
"type": node.node_type,
|
||||||
"content": node.content,
|
"content": node.content,
|
||||||
"relationship": edge.edge_type,
|
"relationship": edge.edge_type,
|
||||||
"weight": edge.weight,
|
"weight": edge.weight,
|
||||||
"hop": current_hop + 1,
|
"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:
|
if limit is not None:
|
||||||
return neighbors[skip: skip + limit]
|
return neighbors[skip: skip + limit]
|
||||||
return neighbors[skip:]
|
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(
|
def query(
|
||||||
self, query: str, skip: int = 0, limit: Optional[int] = None
|
self, query: str, skip: int = 0, limit: Optional[int] = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
@@ -1187,6 +1229,90 @@ class ContextGraph:
|
|||||||
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
||||||
return other_graph, target_node_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:
|
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
|
||||||
"""
|
"""
|
||||||
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
||||||
@@ -2552,13 +2678,14 @@ class ContextGraph:
|
|||||||
# Calculate influence scores
|
# Calculate influence scores
|
||||||
influence_scores = {}
|
influence_scores = {}
|
||||||
for influenced_id in direct_influence | indirect_influence:
|
for influenced_id in direct_influence | indirect_influence:
|
||||||
score = self._calculate_decision_influence_score(decision_id, influenced_id)
|
influence_scores[influenced_id] = self._calculate_decision_influence_score(
|
||||||
influence_scores[influenced_id] = score
|
decision_id, influenced_id
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by influence score
|
# Sort by influence score
|
||||||
sorted_influence = sorted(
|
sorted_influence = sorted(
|
||||||
influence_scores.items(),
|
influence_scores.items(),
|
||||||
key=lambda x: x[1],
|
key=lambda x: x[1].get("score", 0.0),
|
||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2576,11 +2703,22 @@ class ContextGraph:
|
|||||||
"direct_influence": [_enrich(did) for did in direct_influence],
|
"direct_influence": [_enrich(did) for did in direct_influence],
|
||||||
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
||||||
"influence_scores": [
|
"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),
|
"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]:
|
def get_decision_insights(self) -> Dict[str, Any]:
|
||||||
@@ -2677,15 +2815,17 @@ class ContextGraph:
|
|||||||
|
|
||||||
for cause_id in potential_causes:
|
for cause_id in potential_causes:
|
||||||
cause_dec = self._decisions.get(cause_id, {})
|
cause_dec = self._decisions.get(cause_id, {})
|
||||||
|
edge_weight = float(cause_dec.get("confidence", 1.0))
|
||||||
hop = {
|
hop = {
|
||||||
"from": cause_id,
|
"from": cause_id,
|
||||||
"from_scenario": cause_dec.get("scenario", ""),
|
"from_scenario": cause_dec.get("scenario", ""),
|
||||||
"to": current_id,
|
"to": current_id,
|
||||||
"to_scenario": current_decision.get("scenario", ""),
|
"to_scenario": current_decision.get("scenario", ""),
|
||||||
"type": "influences",
|
"type": "influences",
|
||||||
|
"edge_weight": edge_weight,
|
||||||
}
|
}
|
||||||
cause_path = path + [hop]
|
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(cause_id, depth + 1, cause_path)
|
||||||
|
|
||||||
trace_recursive(decision_id, 0, [])
|
trace_recursive(decision_id, 0, [])
|
||||||
@@ -2942,11 +3082,49 @@ class ContextGraph:
|
|||||||
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
||||||
return set()
|
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."""
|
"""Calculate influence score between two decisions."""
|
||||||
try:
|
try:
|
||||||
if not hasattr(self, '_decisions'):
|
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]
|
source_decision = self._decisions[source_id]
|
||||||
target_decision = self._decisions[target_id]
|
target_decision = self._decisions[target_id]
|
||||||
@@ -2965,11 +3143,16 @@ class ContextGraph:
|
|||||||
# Combined score
|
# Combined score
|
||||||
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_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:
|
except Exception as e:
|
||||||
self.logger.warning(f"Influence score calculation failed: {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]:
|
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
|
||||||
"""Get temporal analysis of decisions."""
|
"""Get temporal analysis of decisions."""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
|
||||||
from ..session import GraphSession
|
from ..session import GraphSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
|
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]]
|
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)
|
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||||
async def check_compliance(
|
async def check_compliance(
|
||||||
decision_id: str,
|
decision_id: str,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import ExportRequest, ImportResponse
|
from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
|
||||||
from ..session import GraphSession
|
from ..session import GraphSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -236,3 +236,58 @@ async def export_graph(
|
|||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
|
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 asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from enum import Enum
|
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 fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ...utils.helpers import classify_path_distance
|
from ...utils.helpers import classify_path_distance
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
DistanceMatrixRequest,
|
||||||
|
DistanceMatrixResponse,
|
||||||
EdgeListResponse,
|
EdgeListResponse,
|
||||||
EdgeResponse,
|
EdgeResponse,
|
||||||
GraphStatsResponse,
|
GraphStatsResponse,
|
||||||
@@ -21,12 +27,45 @@ from ..schemas import (
|
|||||||
SearchRequest,
|
SearchRequest,
|
||||||
SearchResultItem,
|
SearchResultItem,
|
||||||
SearchResultResponse,
|
SearchResultResponse,
|
||||||
|
SemanticNeighborItem,
|
||||||
|
SemanticNeighborhoodResponse,
|
||||||
)
|
)
|
||||||
from ..session import GraphSession
|
from ..session import GraphSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/graph", tags=["Graph"])
|
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]]:
|
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
|
||||||
if not raw_bbox:
|
if not raw_bbox:
|
||||||
return None
|
return None
|
||||||
@@ -175,6 +214,93 @@ async def find_path(
|
|||||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||||
|
|
||||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
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(
|
return PathResponse(
|
||||||
source=node_id,
|
source=node_id,
|
||||||
target=target,
|
target=target,
|
||||||
@@ -184,7 +310,13 @@ async def find_path(
|
|||||||
total_weight=total_weight,
|
total_weight=total_weight,
|
||||||
directed=directed,
|
directed=directed,
|
||||||
hop_count=hop_count,
|
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),
|
session: GraphSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
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))
|
# FR-7 — compute hop distances from anchor when requested
|
||||||
for result in results
|
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)
|
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)
|
@router.get("/stats", response_model=GraphStatsResponse)
|
||||||
async def graph_stats(
|
async def graph_stats(
|
||||||
session: GraphSession = Depends(get_session),
|
session: GraphSession = Depends(get_session),
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ Temporal routes for snapshots, diffs, and pattern detection.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone, UTC
|
from datetime import datetime, timedelta, timezone, UTC
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
|
from ..schemas import (
|
||||||
|
DistanceEvent,
|
||||||
|
DistanceHistoryResponse,
|
||||||
|
DistanceSnapshot,
|
||||||
|
TemporalDiffResponse,
|
||||||
|
TemporalPatternResponse,
|
||||||
|
)
|
||||||
from ..session import GraphSession
|
from ..session import GraphSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -120,3 +126,122 @@ async def temporal_bounds(
|
|||||||
):
|
):
|
||||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||||
return TemporalBoundsResponse(**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.
|
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
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -70,6 +71,13 @@ class PathResponse(BaseModel):
|
|||||||
directed: bool = True
|
directed: bool = True
|
||||||
hop_count: int = 0
|
hop_count: int = 0
|
||||||
distance_band: str = "direct"
|
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):
|
class GraphStatsResponse(BaseModel):
|
||||||
@@ -84,11 +92,19 @@ class SearchRequest(BaseModel):
|
|||||||
query: str
|
query: str
|
||||||
filters: Dict[str, Any] = Field(default_factory=dict)
|
filters: Dict[str, Any] = Field(default_factory=dict)
|
||||||
limit: int = Field(default=20, ge=1, le=200)
|
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):
|
class SearchResultItem(BaseModel):
|
||||||
node: NodeResponse
|
node: NodeResponse
|
||||||
score: float = 0.0
|
score: float = 0.0
|
||||||
|
# FR-7 distance metadata
|
||||||
|
hop_distance: Optional[int] = None
|
||||||
|
semantic_similarity: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class SearchResultResponse(BaseModel):
|
class SearchResultResponse(BaseModel):
|
||||||
@@ -308,3 +324,91 @@ class ProvenanceEdge(BaseModel):
|
|||||||
class ProvenanceResponse(BaseModel):
|
class ProvenanceResponse(BaseModel):
|
||||||
nodes: List[ProvenanceNode]
|
nodes: List[ProvenanceNode]
|
||||||
edges: List[ProvenanceEdge]
|
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 .arango_aql_exporter import ArangoAQLExporter
|
||||||
|
from .distance_exporter import DistanceExporter
|
||||||
from .config import ExportConfig, export_config
|
from .config import ExportConfig, export_config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -220,6 +221,7 @@ __all__ = [
|
|||||||
# Core Exporters
|
# Core Exporters
|
||||||
"ArrowExporter",
|
"ArrowExporter",
|
||||||
"ArangoAQLExporter",
|
"ArangoAQLExporter",
|
||||||
|
"DistanceExporter",
|
||||||
"RDFExporter",
|
"RDFExporter",
|
||||||
"RDFSerializer",
|
"RDFSerializer",
|
||||||
"RDFValidator",
|
"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/"):
|
if full_path.startswith("api/"):
|
||||||
raise HTTPException(status_code=404, detail="API route not found")
|
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)
|
normalized_path = os.path.normpath(full_path)
|
||||||
if (
|
if (
|
||||||
normalized_path in ("", ".")
|
os.path.isabs(normalized_path)
|
||||||
or os.path.isabs(normalized_path)
|
|
||||||
or normalized_path == ".."
|
or normalized_path == ".."
|
||||||
or normalized_path.startswith(".." + os.sep)
|
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
|
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
||||||
safe_rel_path = normalized_path.lstrip("/\\")
|
safe_rel_path = normalized_path.lstrip("/\\")
|
||||||
rel_parts = Path(safe_rel_path).parts
|
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")
|
raise HTTPException(status_code=400, detail="Invalid path")
|
||||||
|
|
||||||
static_dir_resolved = STATIC_DIR.resolve()
|
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