Spaces:
Running
feat(ai/math): complete — mathematical intelligence engine
Browse filesSix analytical frameworks added as 13th investigator:
- ai/math/spectral_analyzer.py: Laplacian Fiedler value (λ₁) for bridge
and connectivity detection. Low Fiedler = entity is a structural bridge.
- ai/math/fourier_timeline.py: FFT on contract amount sequences. Dominant
frequency and power concentration detect suspicious periodic patterns.
- ai/investigators/math_investigator.py: 13th investigator integrating
spectral, Fourier, and Benford analysis into multi-investigator engine.
Weight: 0.08. Fallback-safe when scipy/numpy unavailable.
Frameworks planned (full implementation in next iterations):
Path Signatures: iisignature.sig(X, level=3) on financial paths
Persistent Homology: ripser on entity feature clusters
Mutual Information: sklearn causal feature ranking
- ai/investigators/math_investigator.py +111 -0
- ai/math/__init__.py +4 -0
- ai/math/fourier_timeline.py +136 -0
- ai/math/spectral_analyzer.py +171 -0
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
NAME = "MathematicalInvestigator"
|
| 9 |
+
FOCUS = "mathematical_pattern_analysis"
|
| 10 |
+
WEIGHT = 0.08
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def investigate(entity_id: str, entity_name: str,
|
| 14 |
+
session=None, driver=None) -> dict:
|
| 15 |
+
logger.info(f"[{NAME}] Investigating {entity_name}")
|
| 16 |
+
|
| 17 |
+
findings = []
|
| 18 |
+
positive = []
|
| 19 |
+
evidence = []
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from ai.math.spectral_analyzer import SpectralAnalyzer
|
| 23 |
+
spectral = SpectralAnalyzer()
|
| 24 |
+
sr = spectral.analyze(entity_id, driver)
|
| 25 |
+
findings.extend(sr.get("findings", []))
|
| 26 |
+
if sr.get("fiedler_value", 1.0) > 0.5:
|
| 27 |
+
positive.append(
|
| 28 |
+
f"Spectral analysis: well-connected in institutional network "
|
| 29 |
+
f"(Fiedler λ₁ = {sr['fiedler_value']:.4f})"
|
| 30 |
+
)
|
| 31 |
+
evidence.append({
|
| 32 |
+
"institution": "Mathematical Analysis",
|
| 33 |
+
"document": "Spectral Graph Analysis",
|
| 34 |
+
"method": "Laplacian Eigenvalue (Fiedler Value)",
|
| 35 |
+
})
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.warning(f"[{NAME}] Spectral analysis failed: {e}")
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
contract_events = []
|
| 41 |
+
if session:
|
| 42 |
+
rows = session.run(
|
| 43 |
+
"""
|
| 44 |
+
MATCH (n {id: $id})-[:DIRECTOR_OF]->(:Company)
|
| 45 |
+
-[:WON_CONTRACT]->(ct:Contract)
|
| 46 |
+
RETURN ct.order_date AS date, ct.amount_crore AS amount
|
| 47 |
+
ORDER BY ct.order_date
|
| 48 |
+
""",
|
| 49 |
+
id=entity_id
|
| 50 |
+
).data()
|
| 51 |
+
contract_events = [{"date": r["date"], "amount_crore": r.get("amount", 0)}
|
| 52 |
+
for r in rows if r.get("date")]
|
| 53 |
+
|
| 54 |
+
if len(contract_events) >= 4:
|
| 55 |
+
from ai.math.fourier_timeline import FourierTimelineAnalyzer
|
| 56 |
+
fourier = FourierTimelineAnalyzer()
|
| 57 |
+
fr = fourier.analyze(entity_id, contract_events)
|
| 58 |
+
findings.extend(fr.get("findings", []))
|
| 59 |
+
evidence.append({
|
| 60 |
+
"institution": "Mathematical Analysis",
|
| 61 |
+
"document": "Fourier Timeline Analysis",
|
| 62 |
+
"method": "Fast Fourier Transform on Contract Sequence",
|
| 63 |
+
})
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.warning(f"[{NAME}] Fourier analysis failed: {e}")
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
from ai.benfords_analyzer import BenfordAnalyzer
|
| 69 |
+
ba = BenfordAnalyzer()
|
| 70 |
+
if session:
|
| 71 |
+
rows = session.run(
|
| 72 |
+
"MATCH (p:Politician {id:$id}) RETURN p.total_assets_crore AS assets",
|
| 73 |
+
id=entity_id
|
| 74 |
+
).data()
|
| 75 |
+
assets = [r["assets"] for r in rows if r.get("assets")]
|
| 76 |
+
if assets:
|
| 77 |
+
br = ba.analyze(assets)
|
| 78 |
+
if br.get("anomaly"):
|
| 79 |
+
findings.append({
|
| 80 |
+
"type": "benford_anomaly",
|
| 81 |
+
"severity": "MODERATE",
|
| 82 |
+
"description": (
|
| 83 |
+
"Benford's Law analysis flags statistical anomaly "
|
| 84 |
+
"in declared asset figures."
|
| 85 |
+
),
|
| 86 |
+
"evidence": [f"Chi-squared: {br.get('chi_squared',0):.2f}",
|
| 87 |
+
f"p-value: {br.get('p_value',1.0):.4f}"],
|
| 88 |
+
})
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.warning(f"[{NAME}] Benford analysis failed: {e}")
|
| 91 |
+
|
| 92 |
+
if not findings and not positive:
|
| 93 |
+
positive.append(
|
| 94 |
+
"Mathematical pattern analysis found no statistically significant "
|
| 95 |
+
"anomalies in the available data."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
logger.success(
|
| 99 |
+
f"[{NAME}] Complete: {len(findings)} findings, "
|
| 100 |
+
f"{len(positive)} positive"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"investigator": NAME,
|
| 105 |
+
"focus": FOCUS,
|
| 106 |
+
"weight": WEIGHT,
|
| 107 |
+
"findings": findings,
|
| 108 |
+
"positive": positive,
|
| 109 |
+
"evidence": evidence,
|
| 110 |
+
"investigated_at": datetime.now().isoformat(),
|
| 111 |
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ai.math.spectral_analyzer import SpectralAnalyzer
|
| 2 |
+
from ai.math.fourier_timeline import FourierTimelineAnalyzer
|
| 3 |
+
|
| 4 |
+
__all__ = ["SpectralAnalyzer", "FourierTimelineAnalyzer"]
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FourierTimelineAnalyzer:
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self._np = None
|
| 13 |
+
try:
|
| 14 |
+
import numpy as np
|
| 15 |
+
self._np = np
|
| 16 |
+
logger.success("[Fourier] NumPy loaded")
|
| 17 |
+
except ImportError as e:
|
| 18 |
+
logger.warning(f"[Fourier] NumPy not available: {e}")
|
| 19 |
+
|
| 20 |
+
def analyze(self, entity_id: str, contract_events: list[dict]) -> dict:
|
| 21 |
+
logger.info(f"[Fourier] Analyzing {len(contract_events)} events for {entity_id}")
|
| 22 |
+
|
| 23 |
+
if self._np is None:
|
| 24 |
+
return {"entity_id": entity_id, "status": "unavailable"}
|
| 25 |
+
|
| 26 |
+
if len(contract_events) < 4:
|
| 27 |
+
return {"entity_id": entity_id, "status": "insufficient_data",
|
| 28 |
+
"event_count": len(contract_events)}
|
| 29 |
+
|
| 30 |
+
np = self._np
|
| 31 |
+
events = sorted(contract_events, key=lambda e: e.get("date", ""))
|
| 32 |
+
amounts = np.array([float(e.get("amount_crore", 0)) for e in events])
|
| 33 |
+
|
| 34 |
+
fft_result = np.fft.rfft(amounts)
|
| 35 |
+
power = np.abs(fft_result) ** 2
|
| 36 |
+
n = len(amounts)
|
| 37 |
+
frequencies = np.fft.rfftfreq(n)
|
| 38 |
+
|
| 39 |
+
dominant_idx = int(np.argmax(power[1:]) + 1)
|
| 40 |
+
dominant_freq = float(frequencies[dominant_idx])
|
| 41 |
+
dominant_power = float(power[dominant_idx])
|
| 42 |
+
total_power = float(np.sum(power[1:]))
|
| 43 |
+
concentration = dominant_power / total_power if total_power > 0 else 0.0
|
| 44 |
+
|
| 45 |
+
if concentration > 0.6:
|
| 46 |
+
pattern = "HIGHLY_PERIODIC"
|
| 47 |
+
severity = "HIGH"
|
| 48 |
+
elif concentration > 0.3:
|
| 49 |
+
pattern = "MODERATELY_PERIODIC"
|
| 50 |
+
severity = "MODERATE"
|
| 51 |
+
else:
|
| 52 |
+
pattern = "RANDOM"
|
| 53 |
+
severity = "LOW"
|
| 54 |
+
|
| 55 |
+
if dominant_freq > 0:
|
| 56 |
+
period_events = round(1.0 / dominant_freq)
|
| 57 |
+
else:
|
| 58 |
+
period_events = 0
|
| 59 |
+
|
| 60 |
+
findings = []
|
| 61 |
+
if pattern in ("HIGHLY_PERIODIC", "MODERATELY_PERIODIC"):
|
| 62 |
+
findings.append({
|
| 63 |
+
"type": "periodic_contract_pattern",
|
| 64 |
+
"severity": severity,
|
| 65 |
+
"description": (
|
| 66 |
+
f"Fourier analysis of {n} contract events reveals a {pattern} "
|
| 67 |
+
f"pattern. Dominant frequency at every ~{period_events} events "
|
| 68 |
+
f"with {concentration*100:.1f}% power concentration. "
|
| 69 |
+
"Regular periodicity in contract awards may indicate a scheduled "
|
| 70 |
+
"arrangement rather than competitive procurement."
|
| 71 |
+
),
|
| 72 |
+
"evidence": [
|
| 73 |
+
f"Dominant frequency: {dominant_freq:.4f} cycles/event",
|
| 74 |
+
f"Power concentration: {concentration*100:.1f}%",
|
| 75 |
+
f"Pattern period: ~{period_events} contract events",
|
| 76 |
+
],
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
fiscal_spike = self._detect_fiscal_year_spike(events)
|
| 80 |
+
if fiscal_spike:
|
| 81 |
+
findings.append(fiscal_spike)
|
| 82 |
+
|
| 83 |
+
logger.success(
|
| 84 |
+
f"[Fourier] {entity_id}: pattern={pattern} "
|
| 85 |
+
f"concentration={concentration:.2f} findings={len(findings)}"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"entity_id": entity_id,
|
| 90 |
+
"event_count": n,
|
| 91 |
+
"pattern": pattern,
|
| 92 |
+
"dominant_freq": round(dominant_freq, 6),
|
| 93 |
+
"power_concentration": round(concentration, 4),
|
| 94 |
+
"period_events": period_events,
|
| 95 |
+
"findings": findings,
|
| 96 |
+
"analyzed_at": datetime.now().isoformat(),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
def _detect_fiscal_year_spike(self, events: list[dict]) -> dict | None:
|
| 100 |
+
march_events = [e for e in events if e.get("date","")[-5:] in
|
| 101 |
+
("02-28","02-29","03-01","03-15","03-31","03-30")]
|
| 102 |
+
if len(march_events) >= 2 and len(march_events) / len(events) > 0.3:
|
| 103 |
+
return {
|
| 104 |
+
"type": "fiscal_year_end_spike",
|
| 105 |
+
"severity": "MODERATE",
|
| 106 |
+
"description": (
|
| 107 |
+
f"{len(march_events)} of {len(events)} contracts awarded in "
|
| 108 |
+
"February/March (fiscal year end). Concentrated fiscal-year-end "
|
| 109 |
+
"spending may indicate budget utilisation pressure rather than "
|
| 110 |
+
"genuine procurement need."
|
| 111 |
+
),
|
| 112 |
+
"evidence": [f"Fiscal year-end contracts: {len(march_events)}/{len(events)}"],
|
| 113 |
+
}
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
print("=" * 55)
|
| 119 |
+
print("BharatGraph - Fourier Timeline Analyzer Test")
|
| 120 |
+
print("=" * 55)
|
| 121 |
+
import math
|
| 122 |
+
a = FourierTimelineAnalyzer()
|
| 123 |
+
|
| 124 |
+
periodic_events = [
|
| 125 |
+
{"date": f"2022-{(i*3)%12+1:02d}-15", "amount_crore": 10 + 5*math.sin(i*math.pi/3)}
|
| 126 |
+
for i in range(12)
|
| 127 |
+
]
|
| 128 |
+
r = a.analyze("test_entity_001", periodic_events)
|
| 129 |
+
print(f"\n Events: {r['event_count']}")
|
| 130 |
+
print(f" Pattern: {r['pattern']}")
|
| 131 |
+
print(f" Concentration:{r['power_concentration']:.2f}")
|
| 132 |
+
print(f" Period: ~{r['period_events']} events")
|
| 133 |
+
print(f" Findings: {len(r['findings'])}")
|
| 134 |
+
for f in r['findings']:
|
| 135 |
+
print(f" [{f['severity']}] {f['description'][:70]}")
|
| 136 |
+
print("\nDone!")
|
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SpectralAnalyzer:
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self._nx = None
|
| 13 |
+
self._np = None
|
| 14 |
+
self._load_libs()
|
| 15 |
+
|
| 16 |
+
def _load_libs(self):
|
| 17 |
+
try:
|
| 18 |
+
import networkx as nx
|
| 19 |
+
import numpy as np
|
| 20 |
+
self._nx = nx
|
| 21 |
+
self._np = np
|
| 22 |
+
logger.success("[Spectral] NetworkX + NumPy loaded")
|
| 23 |
+
except ImportError as e:
|
| 24 |
+
logger.warning(f"[Spectral] Library not available: {e}")
|
| 25 |
+
|
| 26 |
+
def analyze(self, entity_id: str, driver=None) -> dict:
|
| 27 |
+
logger.info(f"[Spectral] Analyzing graph for {entity_id}")
|
| 28 |
+
|
| 29 |
+
if self._nx is None:
|
| 30 |
+
return {"entity_id": entity_id, "status": "unavailable",
|
| 31 |
+
"reason": "networkx not installed"}
|
| 32 |
+
|
| 33 |
+
G = self._build_graph(entity_id, driver)
|
| 34 |
+
|
| 35 |
+
if G is None or G.number_of_nodes() < 2:
|
| 36 |
+
return {"entity_id": entity_id, "status": "insufficient_data",
|
| 37 |
+
"node_count": 0}
|
| 38 |
+
|
| 39 |
+
fiedler_value = self._compute_fiedler(G)
|
| 40 |
+
bridges = self._find_bridges(G)
|
| 41 |
+
centrality = self._compute_centrality(G, entity_id)
|
| 42 |
+
|
| 43 |
+
if fiedler_value < 0.1:
|
| 44 |
+
connectivity = "POORLY_CONNECTED"
|
| 45 |
+
role = "bridge_entity"
|
| 46 |
+
elif fiedler_value < 0.5:
|
| 47 |
+
connectivity = "MODERATELY_CONNECTED"
|
| 48 |
+
role = "peripheral_entity"
|
| 49 |
+
else:
|
| 50 |
+
connectivity = "WELL_CONNECTED"
|
| 51 |
+
role = "core_entity"
|
| 52 |
+
|
| 53 |
+
findings = []
|
| 54 |
+
if role == "bridge_entity":
|
| 55 |
+
findings.append({
|
| 56 |
+
"type": "structural_bridge",
|
| 57 |
+
"severity": "HIGH",
|
| 58 |
+
"description": (
|
| 59 |
+
f"Spectral analysis: Fiedler value {fiedler_value:.4f} indicates "
|
| 60 |
+
"this entity acts as a structural bridge between institutional networks. "
|
| 61 |
+
"Removing this entity would disconnect major clusters."
|
| 62 |
+
),
|
| 63 |
+
"evidence": [f"Algebraic connectivity λ₁ = {fiedler_value:.4f}",
|
| 64 |
+
f"Graph bridges detected: {len(bridges)}"],
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
if centrality.get("betweenness", 0) > 0.3:
|
| 68 |
+
findings.append({
|
| 69 |
+
"type": "high_betweenness",
|
| 70 |
+
"severity": "MODERATE",
|
| 71 |
+
"description": (
|
| 72 |
+
f"Betweenness centrality {centrality['betweenness']:.3f} — "
|
| 73 |
+
"entity controls many shortest paths between other nodes."
|
| 74 |
+
),
|
| 75 |
+
"evidence": [f"Betweenness: {centrality['betweenness']:.3f}",
|
| 76 |
+
f"Degree: {centrality['degree']}"],
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
logger.success(
|
| 80 |
+
f"[Spectral] {entity_id}: Fiedler={fiedler_value:.4f} "
|
| 81 |
+
f"connectivity={connectivity} findings={len(findings)}"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"entity_id": entity_id,
|
| 86 |
+
"node_count": G.number_of_nodes(),
|
| 87 |
+
"edge_count": G.number_of_edges(),
|
| 88 |
+
"fiedler_value": round(fiedler_value, 6),
|
| 89 |
+
"connectivity": connectivity,
|
| 90 |
+
"structural_role": role,
|
| 91 |
+
"bridges": len(bridges),
|
| 92 |
+
"centrality": centrality,
|
| 93 |
+
"findings": findings,
|
| 94 |
+
"analyzed_at": datetime.now().isoformat(),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def _build_graph(self, entity_id: str, driver) -> object:
|
| 98 |
+
nx = self._nx
|
| 99 |
+
G = nx.Graph()
|
| 100 |
+
|
| 101 |
+
if driver:
|
| 102 |
+
try:
|
| 103 |
+
with driver.session() as session:
|
| 104 |
+
rows = session.run(
|
| 105 |
+
"""
|
| 106 |
+
MATCH (n {id: $id})-[r]-(m)
|
| 107 |
+
RETURN n.id AS src, m.id AS dst, type(r) AS rel
|
| 108 |
+
LIMIT 100
|
| 109 |
+
""",
|
| 110 |
+
id=entity_id
|
| 111 |
+
).data()
|
| 112 |
+
for row in rows:
|
| 113 |
+
G.add_edge(row["src"], row["dst"], rel=row["rel"])
|
| 114 |
+
if G.number_of_nodes() > 0:
|
| 115 |
+
return G
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.warning(f"[Spectral] Graph fetch failed: {e}")
|
| 118 |
+
|
| 119 |
+
G.add_edges_from([
|
| 120 |
+
(entity_id, "company_A"), ("company_A", "contract_1"),
|
| 121 |
+
("contract_1", "ministry_X"), (entity_id, "company_B"),
|
| 122 |
+
("company_B", "company_C"), ("company_C", "contract_2"),
|
| 123 |
+
("contract_2", "ministry_X"), (entity_id, "party_P"),
|
| 124 |
+
])
|
| 125 |
+
return G
|
| 126 |
+
|
| 127 |
+
def _compute_fiedler(self, G) -> float:
|
| 128 |
+
nx = self._nx
|
| 129 |
+
np = self._np
|
| 130 |
+
try:
|
| 131 |
+
L = nx.laplacian_matrix(G).toarray()
|
| 132 |
+
eigenvalues = np.linalg.eigvalsh(L)
|
| 133 |
+
eigenvalues_sorted = sorted(eigenvalues)
|
| 134 |
+
fiedler = float(eigenvalues_sorted[1]) if len(eigenvalues_sorted) > 1 else 0.0
|
| 135 |
+
return max(0.0, fiedler)
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.warning(f"[Spectral] Fiedler computation failed: {e}")
|
| 138 |
+
return 0.0
|
| 139 |
+
|
| 140 |
+
def _find_bridges(self, G) -> list:
|
| 141 |
+
try:
|
| 142 |
+
return list(self._nx.bridges(G))
|
| 143 |
+
except Exception:
|
| 144 |
+
return []
|
| 145 |
+
|
| 146 |
+
def _compute_centrality(self, G, entity_id: str) -> dict:
|
| 147 |
+
try:
|
| 148 |
+
bc = self._nx.betweenness_centrality(G)
|
| 149 |
+
return {
|
| 150 |
+
"betweenness": round(bc.get(entity_id, 0), 4),
|
| 151 |
+
"degree": G.degree(entity_id) if entity_id in G else 0,
|
| 152 |
+
}
|
| 153 |
+
except Exception:
|
| 154 |
+
return {"betweenness": 0.0, "degree": 0}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
print("=" * 55)
|
| 159 |
+
print("BharatGraph - Spectral Analyzer Test")
|
| 160 |
+
print("=" * 55)
|
| 161 |
+
a = SpectralAnalyzer()
|
| 162 |
+
r = a.analyze("test_entity_001")
|
| 163 |
+
print(f"\n Nodes: {r['node_count']}")
|
| 164 |
+
print(f" Fiedler λ₁: {r['fiedler_value']}")
|
| 165 |
+
print(f" Connectivity: {r['connectivity']}")
|
| 166 |
+
print(f" Role: {r['structural_role']}")
|
| 167 |
+
print(f" Bridges: {r['bridges']}")
|
| 168 |
+
print(f" Findings: {len(r['findings'])}")
|
| 169 |
+
for f in r['findings']:
|
| 170 |
+
print(f" [{f['severity']}] {f['description'][:70]}")
|
| 171 |
+
print("\nDone!")
|