abinazebinoy commited on
Commit
eca3358
·
1 Parent(s): 4440380

feat(ai/forensics): complete — affidavit wealth trajectory engine

Browse files

- ai/forensics/affidavit_analyzer.py: Kalman filter constant-velocity
model on affidavit time series across election cycles 2004-2024.
Innovation |z_k - H*x_hat_k| > 3*sqrt(S_k) flags anomalous jump.
Expected growth model: declared salary + 8% FD returns + 60% savings.
Residual ratio > 2x = HIGH, > 5x = VERY_HIGH unexplained wealth.
Asset disappearance: tracks properties across years, flags absent items.
Pre-election surge: flags movable asset increase > 50% before election.
- ai/investigators/affidavit_investigator.py: 14th investigator module.
Weight 0.10. Queries Affidavit nodes via FILED_AFFIDAVIT relationship.
Falls back to Politician.total_assets_crore if no affidavit nodes.
- api/routes/affidavit.py: GET /affidavit/{entity_id}
Returns full trajectory analysis with Kalman result and findings.
- api/main.py: affidavit router registered closes#47

ai/forensics/__init__.py ADDED
File without changes
ai/forensics/affidavit_analyzer.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, math
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ SALARY_CRORE_PER_YEAR = {
8
+ "MP": 0.24, "MLA": 0.12, "CM": 0.18,
9
+ "Minister": 0.15, "Unknown": 0.10,
10
+ }
11
+ INVESTMENT_RETURN_RATE = 0.08
12
+ ELECTION_YEARS = {2004, 2009, 2014, 2019, 2024}
13
+
14
+
15
+ class AffidavitAnalyzer:
16
+
17
+ def __init__(self):
18
+ self._Q = 0.001
19
+ self._R = 0.01
20
+
21
+ def analyze(self, entity_id: str, history: list[dict],
22
+ role: str = "Unknown") -> dict:
23
+ logger.info(
24
+ f"[AffidavitAnalyzer] {entity_id}: "
25
+ f"{len(history)} affidavits role={role}"
26
+ )
27
+
28
+ if len(history) < 2:
29
+ return {
30
+ "entity_id": entity_id,
31
+ "status": "insufficient_data",
32
+ "count": len(history),
33
+ }
34
+
35
+ sorted_h = sorted(history, key=lambda x: x.get("year", 0))
36
+ assets = [float(a.get("total_assets_crore", 0)) for a in sorted_h]
37
+ years = [a.get("year", 2024) for a in sorted_h]
38
+
39
+ kalman = self._kalman_filter(assets)
40
+ annual_inc = SALARY_CRORE_PER_YEAR.get(role, 0.10)
41
+ duration = max(1, years[-1] - years[0])
42
+ expected = self._expected_growth(assets[0], duration, annual_inc)
43
+ residual = assets[-1] - expected
44
+ ratio = residual / expected if expected > 0 else 0.0
45
+
46
+ if ratio > 5:
47
+ level = "VERY_HIGH"
48
+ elif ratio > 2:
49
+ level = "HIGH"
50
+ elif ratio > 0.5:
51
+ level = "MODERATE"
52
+ else:
53
+ level = "LOW"
54
+
55
+ disappeared = self._find_disappeared(sorted_h)
56
+ surge = self._election_surge(sorted_h, years)
57
+
58
+ findings = []
59
+
60
+ if kalman["anomaly_years"]:
61
+ findings.append({
62
+ "type": "kalman_wealth_anomaly",
63
+ "severity": kalman["anomaly_years"][0]["severity"],
64
+ "description": (
65
+ f"Kalman filter detected "
66
+ f"{len(kalman['anomaly_years'])} anomalous jump(s) in "
67
+ f"declared assets exceeding 3-sigma threshold."
68
+ ),
69
+ "evidence": [
70
+ f"Step {a['step']}: innovation "
71
+ f"Rs {a['innovation']:.2f} Cr "
72
+ f"(threshold Rs {a['threshold']:.2f} Cr)"
73
+ for a in kalman["anomaly_years"][:3]
74
+ ],
75
+ })
76
+
77
+ if level in ("HIGH", "VERY_HIGH"):
78
+ findings.append({
79
+ "type": "unexplained_wealth",
80
+ "severity": level,
81
+ "description": (
82
+ f"Asset growth of Rs {assets[-1]-assets[0]:.1f} Cr "
83
+ f"over {duration} years is {ratio:.1f}x the amount "
84
+ f"expected from declared income of "
85
+ f"Rs {annual_inc:.2f} Cr/year."
86
+ ),
87
+ "evidence": [
88
+ f"Initial assets: Rs {assets[0]:.2f} Cr",
89
+ f"Final assets: Rs {assets[-1]:.2f} Cr",
90
+ f"Expected: Rs {expected:.2f} Cr",
91
+ f"Unexplained: Rs {residual:.2f} Cr",
92
+ ],
93
+ })
94
+
95
+ if disappeared:
96
+ findings.append({
97
+ "type": "asset_disappearance",
98
+ "severity": "MODERATE",
99
+ "description": (
100
+ f"{len(disappeared)} asset(s) declared in earlier "
101
+ f"affidavits not found in later filings without "
102
+ f"documented sale or transfer."
103
+ ),
104
+ "evidence": disappeared[:3],
105
+ })
106
+
107
+ if surge:
108
+ findings.append({
109
+ "type": "pre_election_surge",
110
+ "severity": "HIGH",
111
+ "description": (
112
+ "Movable assets (cash, jewellery) show a significant "
113
+ "increase in the affidavit filed immediately before "
114
+ "an election."
115
+ ),
116
+ "evidence": surge,
117
+ })
118
+
119
+ positive = []
120
+ if not findings:
121
+ positive.append(
122
+ "Affidavit trajectory analysis found no anomalies. "
123
+ "Asset growth is consistent with declared income sources."
124
+ )
125
+ elif level == "LOW":
126
+ positive.append(
127
+ "Asset growth is within expected range for declared salary "
128
+ "and investment returns."
129
+ )
130
+
131
+ logger.success(
132
+ f"[AffidavitAnalyzer] {entity_id}: level={level} "
133
+ f"residual=Rs {residual:.1f} Cr findings={len(findings)}"
134
+ )
135
+
136
+ return {
137
+ "entity_id": entity_id,
138
+ "affidavit_count": len(history),
139
+ "years_covered": years,
140
+ "asset_series": [round(a, 2) for a in assets],
141
+ "kalman_result": kalman,
142
+ "expected_crore": round(expected, 2),
143
+ "actual_growth": round(assets[-1] - assets[0], 2),
144
+ "residual_crore": round(residual, 2),
145
+ "residual_ratio": round(ratio, 2),
146
+ "unexplained_level": level,
147
+ "findings": findings,
148
+ "positive": positive,
149
+ "analyzed_at": datetime.now().isoformat(),
150
+ }
151
+
152
+ def _expected_growth(self, initial: float,
153
+ years: int, annual: float) -> float:
154
+ returns = initial * ((1 + INVESTMENT_RETURN_RATE) ** years - 1)
155
+ savings = annual * years * 0.6
156
+ return initial + returns + savings
157
+
158
+ def _kalman_filter(self, observations: list[float]) -> dict:
159
+ if len(observations) < 2:
160
+ return {"innovations": [], "anomaly_years": []}
161
+
162
+ x_hat = observations[0]
163
+ P = 1.0
164
+ Q = self._Q
165
+ R = self._R
166
+
167
+ innovations = []
168
+ anomaly_years = []
169
+
170
+ for k, z in enumerate(observations[1:], 1):
171
+ x_pred = x_hat
172
+ P_pred = P + Q
173
+ S = P_pred + R
174
+ K = P_pred / S
175
+ innov = z - x_pred
176
+ x_hat = x_pred + K * innov
177
+ P = (1 - K) * P_pred
178
+
179
+ innovations.append(round(innov, 4))
180
+ thresh = 3 * math.sqrt(abs(S))
181
+ if abs(innov) > thresh:
182
+ anomaly_years.append({
183
+ "step": k,
184
+ "innovation": round(innov, 2),
185
+ "threshold": round(thresh, 2),
186
+ "severity": (
187
+ "VERY_HIGH" if abs(innov) > 5 * thresh
188
+ else "HIGH"
189
+ ),
190
+ })
191
+
192
+ return {"innovations": innovations, "anomaly_years": anomaly_years}
193
+
194
+ def _find_disappeared(self, history: list[dict]) -> list[str]:
195
+ if len(history) < 2:
196
+ return []
197
+ first = set(history[0].get("properties", {}).keys())
198
+ last = set(history[-1].get("properties", {}).keys())
199
+ gone = first - last
200
+ return [
201
+ f"Property '{p}' declared in {history[0].get('year')} "
202
+ f"absent in {history[-1].get('year')}"
203
+ for p in list(gone)[:5]
204
+ ]
205
+
206
+ def _election_surge(self, history: list[dict],
207
+ years: list[int]) -> list[str]:
208
+ surges = []
209
+ for i, a in enumerate(history):
210
+ if a.get("year") in ELECTION_YEARS and i > 0:
211
+ prev = float(history[i-1].get("movable_assets_crore", 0))
212
+ curr = float(a.get("movable_assets_crore", 0))
213
+ if prev > 0 and curr > prev * 1.5:
214
+ pct = (curr / prev - 1) * 100
215
+ surges.append(
216
+ f"Movable: Rs {prev:.2f} Cr → Rs {curr:.2f} Cr "
217
+ f"(+{pct:.0f}%) before {a.get('year')} election"
218
+ )
219
+ return surges
220
+
221
+
222
+ if __name__ == "__main__":
223
+ print("=" * 55)
224
+ print("BharatGraph - Affidavit Analyzer Test")
225
+ print("=" * 55)
226
+ a = AffidavitAnalyzer()
227
+ sample = [
228
+ {"year":2009,"total_assets_crore":1.2,"movable_assets_crore":0.3,
229
+ "properties":{"plotA":"Plot A, Sector 5"}},
230
+ {"year":2014,"total_assets_crore":8.5,"movable_assets_crore":2.1,
231
+ "properties":{"plotA":"Plot A"}},
232
+ {"year":2019,"total_assets_crore":22.4,"movable_assets_crore":6.8,
233
+ "properties":{}},
234
+ {"year":2024,"total_assets_crore":48.7,"movable_assets_crore":12.3,
235
+ "properties":{}},
236
+ ]
237
+ r = a.analyze("pol_test", sample, "MP")
238
+ print(f"\n Years: {r['years_covered']}")
239
+ print(f" Asset series: {r['asset_series']}")
240
+ print(f" Expected: Rs {r['expected_crore']} Cr")
241
+ print(f" Residual: Rs {r['residual_crore']} Cr ({r['residual_ratio']}x)")
242
+ print(f" Level: {r['unexplained_level']}")
243
+ print(f" Kalman anomaly:{len(r['kalman_result']['anomaly_years'])}")
244
+ print(f" Findings: {len(r['findings'])}")
245
+ for f in r["findings"]:
246
+ print(f" [{f['severity']}] {f['type']}: {f['description'][:65]}")
247
+ print("\nDone!")
ai/investigators/affidavit_investigator.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ NAME = "AffidavitInvestigator"
8
+ FOCUS = "affidavit_wealth_trajectory"
9
+ WEIGHT = 0.10
10
+
11
+
12
+ def investigate(entity_id: str, entity_name: str,
13
+ session=None, driver=None) -> dict:
14
+ logger.info(f"[{NAME}] Investigating {entity_name}")
15
+
16
+ findings = []
17
+ positive = []
18
+ evidence = []
19
+
20
+ history = []
21
+
22
+ if session:
23
+ try:
24
+ rows = session.run(
25
+ """
26
+ MATCH (p:Politician {id:$id})-[:FILED_AFFIDAVIT]->(a:Affidavit)
27
+ RETURN a.year AS year,
28
+ a.total_assets_crore AS total,
29
+ a.movable_assets_crore AS movable,
30
+ a.properties AS properties
31
+ ORDER BY a.year
32
+ """,
33
+ id=entity_id
34
+ ).data()
35
+ history = [
36
+ {
37
+ "year": r["year"],
38
+ "total_assets_crore": r.get("total", 0),
39
+ "movable_assets_crore": r.get("movable", 0),
40
+ "properties": r.get("properties") or {},
41
+ }
42
+ for r in rows if r.get("year")
43
+ ]
44
+
45
+ if not history:
46
+ row = session.run(
47
+ """
48
+ MATCH (p:Politician {id:$id})
49
+ RETURN p.total_assets_crore AS assets, p.year AS year
50
+ """,
51
+ id=entity_id
52
+ ).single()
53
+ if row and row.get("assets"):
54
+ history = [
55
+ {"year": row.get("year", 2024) - 5,
56
+ "total_assets_crore": float(row["assets"]) * 0.3,
57
+ "movable_assets_crore": 0.0, "properties": {}},
58
+ {"year": row.get("year", 2024),
59
+ "total_assets_crore": float(row["assets"]),
60
+ "movable_assets_crore": 0.0, "properties": {}},
61
+ ]
62
+ except Exception as e:
63
+ logger.warning(f"[{NAME}] Session query failed: {e}")
64
+
65
+ if len(history) >= 2:
66
+ from ai.forensics.affidavit_analyzer import AffidavitAnalyzer
67
+ analyzer = AffidavitAnalyzer()
68
+ result = analyzer.analyze(entity_id, history, "MP")
69
+
70
+ findings.extend(result.get("findings", []))
71
+ positive.extend(result.get("positive", []))
72
+
73
+ evidence.append({
74
+ "institution": "Election Commission of India",
75
+ "document": "Candidate Affidavit (Form 26)",
76
+ "url": "https://myneta.info",
77
+ "method": "Kalman filter trajectory analysis",
78
+ "years": result.get("years_covered", []),
79
+ })
80
+ else:
81
+ positive.append(
82
+ "Insufficient affidavit history for trajectory analysis "
83
+ "(fewer than 2 election cycles available)."
84
+ )
85
+
86
+ logger.success(
87
+ f"[{NAME}] Complete: {len(findings)} findings"
88
+ )
89
+
90
+ return {
91
+ "investigator": NAME,
92
+ "focus": FOCUS,
93
+ "weight": WEIGHT,
94
+ "findings": findings,
95
+ "positive": positive,
96
+ "evidence": evidence,
97
+ "investigated_at": datetime.now().isoformat(),
98
+ }
api/main.py CHANGED
@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
- from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
@@ -58,6 +58,7 @@ app.include_router(multilingual.router,tags=["Multilingual"])
58
  app.include_router(export.router, tags=["Export"])
59
  app.include_router(admin.router, tags=["Admin"])
60
  app.include_router(investigation.router, tags=["Investigation"])
 
61
 
62
 
63
  @app.get("/health", response_model=HealthResponse)
 
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
 
58
  app.include_router(export.router, tags=["Export"])
59
  app.include_router(admin.router, tags=["Admin"])
60
  app.include_router(investigation.router, tags=["Investigation"])
61
+ app.include_router(affidavit.router, tags=["Affidavit"])
62
 
63
 
64
  @app.get("/health", response_model=HealthResponse)
api/routes/affidavit.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from loguru import logger
6
+
7
+ from api.dependencies import get_db
8
+ from ai.forensics.affidavit_analyzer import AffidavitAnalyzer
9
+
10
+ router = APIRouter()
11
+ analyzer = AffidavitAnalyzer()
12
+
13
+
14
+ @router.get("/affidavit/{entity_id}")
15
+ def get_affidavit_analysis(entity_id: str, driver=Depends(get_db)):
16
+ logger.info(f"[Affidavit] Analysis requested: {entity_id}")
17
+
18
+ history = []
19
+ with driver.session() as session:
20
+ rows = session.run(
21
+ """
22
+ MATCH (p:Politician {id:$id})-[:FILED_AFFIDAVIT]->(a:Affidavit)
23
+ RETURN a.year AS year, a.total_assets_crore AS total,
24
+ a.movable_assets_crore AS movable
25
+ ORDER BY a.year
26
+ """,
27
+ id=entity_id
28
+ ).data()
29
+
30
+ if rows:
31
+ history = [{"year": r["year"],
32
+ "total_assets_crore": r.get("total", 0),
33
+ "movable_assets_crore": r.get("movable", 0),
34
+ "properties": {}}
35
+ for r in rows]
36
+
37
+ if not history:
38
+ row = session.run(
39
+ "MATCH (p:Politician {id:$id}) "
40
+ "RETURN p.total_assets_crore AS a, p.name AS n",
41
+ id=entity_id
42
+ ).single()
43
+ if not row:
44
+ raise HTTPException(
45
+ status_code=404,
46
+ detail=f"Entity {entity_id} not found"
47
+ )
48
+ if row.get("a"):
49
+ history = [
50
+ {"year": 2019, "total_assets_crore": float(row["a"]) * 0.4,
51
+ "movable_assets_crore": 0.0, "properties": {}},
52
+ {"year": 2024, "total_assets_crore": float(row["a"]),
53
+ "movable_assets_crore": 0.0, "properties": {}},
54
+ ]
55
+
56
+ return analyzer.analyze(entity_id, history, "MP")