abinazebinoy commited on
Commit
10ac2cc
·
1 Parent(s): 93561b5

feat(ai): complete — Adversarial Counterevidence and Competing Hypotheses

Browse files

ai/adversarial_engine.py: forced counterevidence engine

For every HIGH-severity finding, the engine generates a primary
hypothesis (finding as stated) and a contra hypothesis (alternative
innocent explanation), then evaluates evidence for each.

Contra templates: 8 finding types with 2 contra explanations each
(contract_concentration, ghost_company, affidavit_wealth,
granger_causality, bid_document_similarity, cooling_off_violation,
benami, default fallback)

Confidence scoring: contra confidence starts at 0.15 for HIGH
findings, 0.30 for MODERATE, 0.45 for LOW. Database
counterevidence raises it by 0.15 per piece found.

Verdicts: PRIMARY_HYPOTHESIS_SUPPORTED when primary/contra ratio
exceeds 3.0. INCONCLUSIVE when ratio is between 0.8 and 1.5.
CONTRA_HYPOTHESIS_PROBABLE below 0.8.

Overall assessment: FINDINGS_SUPPORTED, FINDINGS_WEAKENED,
or INCONCLUSIVE across all hypotheses in the report.

api/routes/adversarial.py: GET /adversarial/{entity_id}
Pulls findings from risk scorer, runs adversarial analysis,
returns hypothesis pairs with confidence and verdict per finding.
api/main.py: adversarial router registered, version 0.26.0

ai/adversarial_engine.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, math, re
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ CONFIDENCE_THRESHOLD = 0.6 # minimum evidence score to keep a hypothesis
8
+ MIN_FINDINGS_FOR_DEBATE = 2
9
+
10
+
11
+ class AdversarialEngine:
12
+ """
13
+ Forced counterevidence engine with competing hypotheses mode.
14
+
15
+ For every HIGH-severity finding, the engine:
16
+ 1. Generates an alternative (contra) hypothesis that explains
17
+ the same data without implying wrongdoing.
18
+ 2. Searches for evidence that supports the contra hypothesis.
19
+ 3. Produces a scorecard with evidence-for and evidence-against
20
+ each hypothesis.
21
+ 4. Adjusts finding confidence based on counterevidence strength.
22
+
23
+ This prevents confirmation bias in the investigation pipeline
24
+ and produces legally defensible, balanced outputs.
25
+ """
26
+
27
+ CONTRA_TEMPLATES = {
28
+ "contract_concentration": [
29
+ "Entity operates in a specialised sector where few vendors qualify, "
30
+ "making repeated awards to the same company structurally expected.",
31
+ "Contracts were awarded through open competitive tender. Repeated wins "
32
+ "reflect competitive pricing, not preferential treatment.",
33
+ ],
34
+ "ghost_company": [
35
+ "Company was incorporated shortly before the contract because the "
36
+ "contract opportunity itself prompted the business formation.",
37
+ "Registration date proximity to contract may reflect data lag in "
38
+ "official records rather than actual operational timeline.",
39
+ ],
40
+ "affidavit_wealth": [
41
+ "Asset growth reflects legitimate investment returns, property "
42
+ "appreciation, and inheritance not captured in salary declarations.",
43
+ "Declared assets in earlier years may have been understated due "
44
+ "to lack of independent valuation, not deliberate underreporting.",
45
+ ],
46
+ "granger_causality": [
47
+ "Statistical correlation between policy events and contract awards "
48
+ "reflects normal government procurement cycles, not causal influence.",
49
+ "Both policy changes and contract awards are driven by the same "
50
+ "underlying budget allocations, producing apparent but spurious correlation.",
51
+ ],
52
+ "bid_document_similarity": [
53
+ "Bid documents use standard government procurement templates, making "
54
+ "structural similarity across vendors expected and unrelated to coordination.",
55
+ "Similarity reflects industry-standard specifications published by the "
56
+ "procuring ministry, not copying between vendors.",
57
+ ],
58
+ "cooling_off_violation": [
59
+ "The appointment was to a non-regulated sector not covered by "
60
+ "the cooling-off policy applicable to this official's role.",
61
+ "The private sector role is advisory in nature and does not involve "
62
+ "the same subject matter as the official's prior government function.",
63
+ ],
64
+ "benami": [
65
+ "Director relationship reflects a legitimate family business structure "
66
+ "where multiple family members hold formal positions.",
67
+ "Address sharing reflects co-location in a business park or registered "
68
+ "office service, common among small and medium enterprises.",
69
+ ],
70
+ "default": [
71
+ "The observed pattern may reflect coincidence, data quality issues, "
72
+ "or structural features of the sector rather than intentional conduct.",
73
+ "Additional context from primary source documents is required before "
74
+ "drawing conclusions from this structural indicator.",
75
+ ],
76
+ }
77
+
78
+ def analyze(self, entity_id: str, entity_name: str,
79
+ findings: list[dict], driver=None) -> dict:
80
+ logger.info(
81
+ f"[AdversarialEngine] Analyzing {len(findings)} findings "
82
+ f"for {entity_name}"
83
+ )
84
+
85
+ if not findings:
86
+ return {
87
+ "entity_id": entity_id,
88
+ "entity_name": entity_name,
89
+ "status": "no_findings",
90
+ "hypotheses": [],
91
+ "analyzed_at": datetime.now().isoformat(),
92
+ }
93
+
94
+ high_findings = [f for f in findings
95
+ if f.get("severity") in ("HIGH", "VERY_HIGH")]
96
+ if not high_findings:
97
+ high_findings = findings[:3]
98
+
99
+ hypotheses = []
100
+ for finding in high_findings:
101
+ h = self._build_hypothesis_pair(finding, entity_name, driver)
102
+ hypotheses.append(h)
103
+
104
+ overall = self._overall_assessment(hypotheses)
105
+
106
+ logger.success(
107
+ f"[AdversarialEngine] {entity_name}: "
108
+ f"{len(hypotheses)} hypothesis pairs, "
109
+ f"overall={overall['verdict']}"
110
+ )
111
+
112
+ return {
113
+ "entity_id": entity_id,
114
+ "entity_name": entity_name,
115
+ "hypotheses": hypotheses,
116
+ "overall": overall,
117
+ "methodology": (
118
+ "For each HIGH finding, a primary hypothesis (finding as stated) "
119
+ "and a contra hypothesis (alternative innocent explanation) are "
120
+ "evaluated against available evidence. Confidence is adjusted "
121
+ "based on the strength of counterevidence."
122
+ ),
123
+ "analyzed_at": datetime.now().isoformat(),
124
+ }
125
+
126
+ def _build_hypothesis_pair(self, finding: dict,
127
+ entity_name: str,
128
+ driver) -> dict:
129
+ finding_type = finding.get("type", "default")
130
+
131
+ primary = {
132
+ "label": "Primary Hypothesis",
133
+ "description": finding.get("description",""),
134
+ "type": finding_type,
135
+ "evidence_for": finding.get("evidence", []),
136
+ "evidence_against": [],
137
+ "confidence": 1.0,
138
+ }
139
+
140
+ contra_texts = self.CONTRA_TEMPLATES.get(
141
+ finding_type,
142
+ self.CONTRA_TEMPLATES["default"]
143
+ )
144
+
145
+ db_evidence = self._search_counterevidence(
146
+ finding_type, entity_name, driver
147
+ )
148
+
149
+ contra = {
150
+ "label": "Contra Hypothesis",
151
+ "description": contra_texts[0],
152
+ "type": f"contra_{finding_type}",
153
+ "evidence_for": [contra_texts[1]] + db_evidence,
154
+ "evidence_against": finding.get("evidence", []),
155
+ "confidence": self._score_contra(db_evidence, finding),
156
+ }
157
+
158
+ primary["confidence"] = max(
159
+ 0.1, 1.0 - contra["confidence"] * 0.4
160
+ )
161
+
162
+ verdict = self._verdict(primary["confidence"], contra["confidence"])
163
+
164
+ return {
165
+ "finding_type": finding_type,
166
+ "severity": finding.get("severity",""),
167
+ "primary": primary,
168
+ "contra": contra,
169
+ "verdict": verdict,
170
+ "note": (
171
+ "This scorecard presents both the primary structural indicator "
172
+ "and an alternative explanation. Neither constitutes a legal "
173
+ "finding. Further primary source investigation is required."
174
+ ),
175
+ }
176
+
177
+ def _search_counterevidence(self, finding_type: str,
178
+ entity_name: str,
179
+ driver) -> list[str]:
180
+ if not driver:
181
+ return []
182
+ evidence = []
183
+ try:
184
+ with driver.session() as s:
185
+ if finding_type == "contract_concentration":
186
+ row = s.run(
187
+ """
188
+ MATCH (c:Company)-[:WON_CONTRACT]->(ct:Contract)
189
+ WHERE toLower(c.name) CONTAINS toLower($name)
190
+ WITH ct.buyer_org AS buyer, count(*) AS n
191
+ WHERE n >= 2
192
+ RETURN count(*) AS repeat_buyers
193
+ """, name=entity_name
194
+ ).single()
195
+ if row and row.get("repeat_buyers",0) == 0:
196
+ evidence.append(
197
+ "No repeat buyers found — contracts came from "
198
+ "different ministries, suggesting open competition."
199
+ )
200
+
201
+ elif finding_type in ("granger_causality", "transfer_entropy"):
202
+ row = s.run(
203
+ """
204
+ MATCH (p {id:$name})-[:MEMBER_OF]->(party:Party)
205
+ RETURN party.name AS party
206
+ """, name=entity_name
207
+ ).single()
208
+ if row:
209
+ evidence.append(
210
+ f"Entity is a member of {row['party']} — budget "
211
+ "allocations are party-level, not individual decisions."
212
+ )
213
+ except Exception as e:
214
+ logger.warning(f"[Adversarial] Counterevidence search failed: {e}")
215
+ return evidence
216
+
217
+ def _score_contra(self, db_evidence: list[str],
218
+ finding: dict) -> float:
219
+ base = 0.25
220
+ boost = min(0.4, len(db_evidence) * 0.15)
221
+ sev = finding.get("severity","")
222
+ if sev == "LOW":
223
+ base = 0.45
224
+ elif sev == "MODERATE":
225
+ base = 0.30
226
+ elif sev in ("HIGH", "VERY_HIGH"):
227
+ base = 0.15
228
+ return round(min(0.90, base + boost), 3)
229
+
230
+ def _verdict(self, primary_conf: float, contra_conf: float) -> str:
231
+ ratio = primary_conf / max(contra_conf, 0.01)
232
+ if ratio > 3.0:
233
+ return "PRIMARY_HYPOTHESIS_SUPPORTED"
234
+ elif ratio > 1.5:
235
+ return "PRIMARY_HYPOTHESIS_PROBABLE"
236
+ elif ratio > 0.8:
237
+ return "INCONCLUSIVE"
238
+ else:
239
+ return "CONTRA_HYPOTHESIS_PROBABLE"
240
+
241
+ def _overall_assessment(self, hypotheses: list[dict]) -> dict:
242
+ if not hypotheses:
243
+ return {"verdict": "NO_FINDINGS", "summary": "No findings to assess."}
244
+
245
+ verdicts = [h["verdict"] for h in hypotheses]
246
+ supported = sum(1 for v in verdicts
247
+ if v in ("PRIMARY_HYPOTHESIS_SUPPORTED",
248
+ "PRIMARY_HYPOTHESIS_PROBABLE"))
249
+ inconclusive = sum(1 for v in verdicts if v == "INCONCLUSIVE")
250
+ contra_wins = sum(1 for v in verdicts if v == "CONTRA_HYPOTHESIS_PROBABLE")
251
+
252
+ if supported > inconclusive + contra_wins:
253
+ verdict = "FINDINGS_SUPPORTED"
254
+ summary = (
255
+ f"{supported} of {len(hypotheses)} findings remain supported "
256
+ f"after counterevidence analysis."
257
+ )
258
+ elif contra_wins > supported:
259
+ verdict = "FINDINGS_WEAKENED"
260
+ summary = (
261
+ f"Counterevidence analysis weakens {contra_wins} of "
262
+ f"{len(hypotheses)} findings. Additional investigation recommended."
263
+ )
264
+ else:
265
+ verdict = "INCONCLUSIVE"
266
+ summary = (
267
+ f"Mixed results across {len(hypotheses)} findings. "
268
+ f"Neither primary nor contra hypotheses are clearly dominant."
269
+ )
270
+
271
+ return {
272
+ "verdict": verdict,
273
+ "summary": summary,
274
+ "supported": supported,
275
+ "inconclusive": inconclusive,
276
+ "contra_wins": contra_wins,
277
+ "total_assessed": len(hypotheses),
278
+ }
279
+
280
+
281
+ if __name__ == "__main__":
282
+ print("=" * 55)
283
+ print("BharatGraph — Adversarial Engine Test")
284
+ print("=" * 55)
285
+ engine = AdversarialEngine()
286
+ sample_findings = [
287
+ {
288
+ "type": "contract_concentration",
289
+ "severity": "HIGH",
290
+ "description": "Three contracts from the same ministry in 18 months.",
291
+ "evidence": ["Contract CT001: Rs 12 Cr", "Contract CT002: Rs 18 Cr",
292
+ "Contract CT003: Rs 9 Cr"],
293
+ },
294
+ {
295
+ "type": "granger_causality",
296
+ "severity": "HIGH",
297
+ "description": "Policy events predict contract awards (F=3.2).",
298
+ "evidence": ["F-statistic: 3.2", "Lag: 2"],
299
+ },
300
+ ]
301
+ r = engine.analyze("pol_001", "Test Entity", sample_findings, driver=None)
302
+ print(f"\n Hypotheses: {len(r['hypotheses'])}")
303
+ print(f" Overall: {r['overall']['verdict']}")
304
+ print(f" Summary: {r['overall']['summary']}")
305
+ for h in r["hypotheses"]:
306
+ print(f"\n [{h['finding_type']}]")
307
+ print(f" Primary confidence: {h['primary']['confidence']:.2f}")
308
+ print(f" Contra confidence: {h['contra']['confidence']:.2f}")
309
+ print(f" Verdict: {h['verdict']}")
310
+ print("\nDone!")
api/main.py CHANGED
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
- from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement, conflict, linguistic, policy
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
@@ -34,7 +34,7 @@ app = FastAPI(
34
  "All data sourced from official government records. "
35
  "Outputs are structural indicators, not legal findings."
36
  ),
37
- version="0.25.0",
38
  lifespan=lifespan,
39
  )
40
 
@@ -72,6 +72,7 @@ app.include_router(procurement.router, tags=["Procurement"])
72
  app.include_router(conflict.router, tags=["Conflict"])
73
  app.include_router(linguistic.router, tags=["Linguistic"])
74
  app.include_router(policy.router, tags=["Policy"])
 
75
 
76
 
77
  @app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
@@ -86,7 +87,7 @@ def health_check():
86
  return HealthResponse(
87
  status="ok" if connected else "degraded",
88
  neo4j_connected=connected,
89
- version="0.25.0",
90
  generated_at=datetime.now().isoformat(),
91
  )
92
 
 
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement, conflict, linguistic, policy, adversarial
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
 
34
  "All data sourced from official government records. "
35
  "Outputs are structural indicators, not legal findings."
36
  ),
37
+ version="0.26.0",
38
  lifespan=lifespan,
39
  )
40
 
 
72
  app.include_router(conflict.router, tags=["Conflict"])
73
  app.include_router(linguistic.router, tags=["Linguistic"])
74
  app.include_router(policy.router, tags=["Policy"])
75
+ app.include_router(adversarial.router, tags=["Adversarial"])
76
 
77
 
78
  @app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
 
87
  return HealthResponse(
88
  status="ok" if connected else "degraded",
89
  neo4j_connected=connected,
90
+ version="0.26.0",
91
  generated_at=datetime.now().isoformat(),
92
  )
93
 
api/routes/adversarial.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from api.dependencies import get_db
7
+ from ai.adversarial_engine import AdversarialEngine
8
+ from ai.risk_scorer import RiskScorer
9
+
10
+ router = APIRouter()
11
+ engine = AdversarialEngine()
12
+
13
+
14
+ @router.get("/adversarial/{entity_id}")
15
+ def adversarial_analysis(entity_id: str, driver=Depends(get_db)):
16
+ logger.info(f"[Adversarial] Analysis requested: {entity_id}")
17
+
18
+ with driver.session() as s:
19
+ row = s.run(
20
+ "MATCH (n {id:$id}) RETURN n.name AS name, "
21
+ "n.risk_score AS score",
22
+ id=entity_id
23
+ ).single()
24
+
25
+ if not row:
26
+ raise HTTPException(
27
+ status_code=404, detail=f"Entity {entity_id} not found"
28
+ )
29
+
30
+ name = row.get("name") or entity_id
31
+
32
+ # Pull findings from risk scorer to pass into adversarial engine
33
+ findings = []
34
+ try:
35
+ scorer = RiskScorer(driver=driver)
36
+ result = scorer.score(entity_id, name)
37
+ findings = result.get("findings", [])
38
+ except Exception as e:
39
+ logger.warning(f"[Adversarial] Could not load findings from risk scorer: {e}")
40
+
41
+ return engine.analyze(entity_id, name, findings, driver=driver)