File size: 25,246 Bytes
c496840
f927995
 
 
 
 
 
 
 
 
 
 
 
c496840
f927995
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f927995
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
"""
Engine 2 β€” Supply Chain Knowledge Graph with PageRank + Louvain Community Detection.

Maintains a directed graph of drugs, APIs, rare earths, and production provinces.
Provides:
  - Personalized PageRank for shock propagation (replaces naive BFS)
  - Louvain community detection for supply cluster identification
  - HHI concentration scoring
  - Combined risk formula: R_i = PR_i Γ— (1 - S_i) Γ— e^(-B_i/Ο„) Γ— C_community

Implements:
  - GNN-based shock propagation concepts from AAAI 2025
  - Causal ML supply chain intervention from arXiv 2024
"""

import math
import logging
from typing import List, Dict, Optional, Any, Literal, Tuple

import networkx as nx

logger = logging.getLogger("shockmap.graph")

# ─── Sector Lead-Time Constants (Ο„ in days) ──────────────────────────────────
SECTOR_TAU = {
    "pharma": 30,
    "rare_earth": 60,
    "semiconductor": 45,
    "ev_battery": 50,
    "solar": 40,
    "defence": 90,
}


class GraphService:
    """
    Engine 2: Maintains a directed graph representation of the multi-sector
    supply chain with PageRank propagation and Louvain community detection.
    """

    def __init__(self, data_loader: Any):
        """
        Initializes the GraphService and builds the DiGraph from seed data.

        Args:
            data_loader: The data provider for drugs, APIs, rare earths, and dependencies.
        """
        self.data_loader = data_loader
        self.graph = nx.DiGraph()
        self._undirected = None  # For Louvain (needs undirected graph)
        self._communities = {}   # node_id β†’ community_id
        self._community_labels = {}  # community_id β†’ descriptive label
        self._community_detection_enabled = True
        self._community_detection_method = "louvain"
        self._pagerank_cache = {}
        self._build_graph()
        self._detect_communities()

    # ─── Graph Construction ──────────────────────────────────────────────

    def _build_graph(self):
        """Constructs the NetworkX graph from data_loader seed data."""
        # Add Drug nodes
        for drug in self.data_loader.get_drugs():
            self.graph.add_node(
                drug.id,
                type="drug",
                name=drug.name,
                sector="pharma",
                nlem_tier=drug.nlem_tier,
                patient_population_estimate=drug.patient_population_estimate,
                has_substitute=drug.has_substitute,
                therapeutic_class=drug.therapeutic_class,
                current_risk=drug.current_risk or 0.0,
                # Engine 2 enrichments
                substitutability=self._drug_substitutability(drug),
                buffer_days=self._estimate_buffer_days(drug),
                strategic_priority=1.0 if drug.nlem_tier.value == "TIER_1" else 0.5,
            )

        # Add API nodes and collect unique provinces
        provinces = set()
        for api in self.data_loader.get_apis():
            self.graph.add_node(
                api.id,
                type="api",
                name=api.name,
                sector="pharma",
                china_share=api.china_share,
                primary_provinces=api.primary_provinces,
                monthly_import_value_usd_millions=api.monthly_import_value_usd_millions,
                substitutability=self._api_substitutability(api),
                buffer_days=max(5, int(30 * (1 - api.china_share))),
            )
            for p in api.primary_provinces:
                provinces.add(p)

        # Add Province nodes
        for p_name in provinces:
            self.graph.add_node(
                p_name,
                type="province",
                name=p_name,
                country="China",
                sector="multi",
            )

        # Add Edges with trade_volume_share
        for edge in self.data_loader.get_dependencies():
            self.graph.add_edge(
                edge.source,
                edge.target,
                weight=edge.weight,
                edge_type=edge.edge_type,
                trade_volume_share=edge.weight,  # normalized 0-1
            )

        # Add Rare Earth nodes (from rare_earths.json if available)
        self._add_rare_earth_nodes()

        logger.info(
            f"Graph built: {self.graph.number_of_nodes()} nodes, "
            f"{self.graph.number_of_edges()} edges"
        )

    def _add_rare_earth_nodes(self):
        """Adds rare earth mineral nodes and their province edges."""
        try:
            import json
            import os
            seed_dir = self.data_loader.seed_dir
            re_path = os.path.join(seed_dir, "rare_earths.json")
            if not os.path.exists(re_path):
                return
            with open(re_path, "r", encoding="utf-8") as f:
                rare_earths = json.load(f)

            for re in rare_earths:
                node_id = re["id"]
                self.graph.add_node(
                    node_id,
                    type="input",
                    name=re["name"],
                    sector="rare_earth",
                    china_share=re.get("china_share", 0.9),
                    primary_provinces=re.get("primary_provinces", []),
                    monthly_import_value_usd_millions=re.get("monthly_import_value_usd_millions", 0),
                    end_use_sectors=re.get("end_use_sectors", []),
                    substitutability=max(0.0, 1.0 - re.get("china_share", 0.9)),
                    buffer_days=re.get("india_stockpile_days", 15),
                    strategic_priority=1.0 if re.get("criticality", 0) > 0.8 else 0.7,
                    criticality=re.get("criticality", 0.5),
                )

                # Create edges to provinces
                for prov in re.get("primary_provinces", []):
                    if not self.graph.has_node(prov):
                        self.graph.add_node(
                            prov, type="province", name=prov,
                            country="China", sector="multi"
                        )
                    share = re.get("china_share", 0.9) / max(1, len(re.get("primary_provinces", [prov])))
                    self.graph.add_edge(
                        node_id, prov,
                        weight=share,
                        edge_type="input_from_province",
                        trade_volume_share=share,
                    )
        except Exception as e:
            logger.warning(f"Failed to load rare earth nodes: {e}")

    # ─── Louvain Community Detection ─────────────────────────────────────

    def _detect_communities(self):
        """
        Runs Louvain community detection on the undirected version of the graph.
        Identifies supply chain clusters β€” groups of nodes that are tightly
        interconnected and likely to co-propagate shocks.
        """
        try:
            import community as community_louvain

            self._undirected = self.graph.to_undirected()
            self._communities = community_louvain.best_partition(
                self._undirected, random_state=42
            )

            # Build community summaries
            community_members: Dict[int, List[str]] = {}
            for node_id, comm_id in self._communities.items():
                community_members.setdefault(comm_id, []).append(node_id)

            # Label each community by its dominant province or sector
            for comm_id, members in community_members.items():
                provinces = [m for m in members if self.graph.nodes[m].get("type") == "province"]
                if provinces:
                    self._community_labels[comm_id] = f"{', '.join(provinces[:2])} Supply Cluster"
                else:
                    sectors = [self.graph.nodes[m].get("sector", "unknown") for m in members[:3]]
                    self._community_labels[comm_id] = f"{'|'.join(set(sectors))} Cluster {comm_id}"

            logger.info(
                f"Louvain detected {len(community_members)} communities "
                f"across {len(self._communities)} nodes"
            )

        except ImportError:
            logger.warning("python-louvain not installed. Community detection disabled.")
            self._community_detection_enabled = False
            self._community_detection_method = "disabled_missing_dependency"
            self._communities = {n: 0 for n in self.graph.nodes()}
        except Exception as e:
            logger.warning(f"Community detection failed: {e}")
            self._community_detection_enabled = False
            self._community_detection_method = "disabled_runtime_error"
            self._communities = {n: 0 for n in self.graph.nodes()}

    def get_communities(self) -> Dict[str, Any]:
        """Returns community detection results for the frontend."""
        community_data: Dict[int, Dict[str, Any]] = {}

        for node_id, comm_id in self._communities.items():
            if comm_id not in community_data:
                community_data[comm_id] = {
                    "id": comm_id,
                    "label": self._community_labels.get(comm_id, f"Cluster {comm_id}"),
                    "members": [],
                    "provinces": [],
                    "drugs": [],
                    "inputs": [],
                    "size": 0,
                }
            entry = community_data[comm_id]
            node = self.graph.nodes[node_id]
            node_type = node.get("type", "unknown")
            entry["members"].append(node_id)
            entry["size"] += 1
            if node_type == "province":
                entry["provinces"].append(node_id)
            elif node_type == "drug":
                entry["drugs"].append(node_id)
            elif node_type in ("api", "input"):
                entry["inputs"].append(node_id)

        return {
            "community_detection_enabled": self._community_detection_enabled,
            "community_detection_method": self._community_detection_method,
            "total_communities": len(community_data),
            "communities": list(community_data.values()),
        }

    def get_node_community(self, node_id: str) -> Optional[int]:
        """Returns the community ID for a given node."""
        return self._communities.get(node_id)

    def get_community_amplifier(self, community_id: int) -> float:
        """
        Returns the community amplifier C_community.
        Larger, more interconnected communities amplify shocks more.
        """
        members = [n for n, c in self._communities.items() if c == community_id]
        if not members:
            return 1.0
        # Amplifier based on community density (more edges = more amplification)
        subgraph = self._undirected.subgraph(members) if self._undirected else self.graph.subgraph(members)
        density = nx.density(subgraph) if len(members) > 1 else 0
        return 1.0 + (density * 0.5)  # Range: 1.0 to 1.5

    # ─── Personalized PageRank ───────────────────────────────────────────

    def compute_pagerank(
        self,
        shocked_node: Optional[str] = None,
        alpha: float = 0.85,
    ) -> Dict[str, float]:
        """
        Computes Personalized PageRank from a shock origin node.

        IMPORTANT: Since graph edges point drug→api→province, we run PageRank
        on the REVERSED graph when propagating from a province. This makes
        the random walk flow province→api→drug, correctly modeling how supply
        shocks propagate upstream to find affected end products.

        Args:
            shocked_node: The node where the shock originates (e.g., "Hebei")
            alpha: Damping factor (default 0.85)
        """
        cache_key = shocked_node or "__global__"
        if cache_key in self._pagerank_cache:
            return self._pagerank_cache[cache_key]

        personalization = None
        if shocked_node and self.graph.has_node(shocked_node):
            personalization = {shocked_node: 1.0}

        # Use reversed graph so PageRank flows from province β†’ api β†’ drug
        # This correctly models shock propagation through the supply chain
        target_graph = self.graph.reverse(copy=False)

        try:
            scores = nx.pagerank(
                target_graph,
                alpha=alpha,
                weight="trade_volume_share",
                personalization=personalization,
                max_iter=200,
            )
            self._pagerank_cache[cache_key] = scores
            return scores
        except nx.PowerIterationFailedConvergence:
            logger.warning("PageRank failed to converge, using uniform distribution")
            n = self.graph.number_of_nodes()
            return {node: 1.0 / n for node in self.graph.nodes()}

    def compute_province_risk(self, province_id: str, sector: str = "pharma") -> float:
        """
        Calculates the aggregated risk score for a Chinese province.
        Risk is based on:
          - Concentration (HHI) of the products produced in this province.
          - Total monthly import value dependent on this province.
          - Strategic priority of the dependent products.
        """
        if not self.graph.has_node(province_id):
            return 0.0

        dependent_products = []
        # Find APIs/Inputs that are LOCATED_IN or supplied by this province
        # In our graph, edges are drug -> api -> province, so predecessors(province) are the APIs
        for product_id in self.graph.predecessors(province_id):
            node_data = self.graph.nodes.get(product_id, {})
            if sector == "both" or node_data.get("sector") == sector:
                dependent_products.append(product_id)

        if not dependent_products:
            return 0.0

        total_risk = 0.0
        for product_id in dependent_products:
            node_data = self.graph.nodes.get(product_id, {})
            import_value = node_data.get("monthly_import_value_usd_millions", 1.0)
            china_share = node_data.get("china_share", 0.5)
            # High import value + High China share = High Dependency Risk
            product_risk = (import_value * china_share)
            total_risk += product_risk

        # Normalize to 0-100 scale (heuristic normalization)
        normalized_risk = min(100.0, math.log1p(total_risk) * 15)
        return round(normalized_risk, 2)

    # ─── Combined Risk Formula ───────────────────────────────────────────

    def compute_combined_risk(
        self,
        shocked_node: str,
        sector: str = "pharma",
    ) -> Dict[str, Dict[str, Any]]:
        """
        Computes the full risk score using the combined formula:

        R_i = PR_i(shock) Γ— (1 - S_i) Γ— e^(-B_i/Ο„) Γ— C_community

        Where:
          PR_i  = Personalized PageRank from shock origin (on reversed graph)
          S_i   = Substitutability (0-1)
          B_i   = Buffer stock in days
          Ο„     = Sector lead time (pharma=30d, rare_earth=60d)
          C     = Community amplifier

        Returns a dict of {node_id: {score, components}} for all affected nodes.
        """
        tau = SECTOR_TAU.get(sector, 30)
        pr_scores = self.compute_pagerank(shocked_node)

        # Find max PR score for normalization (excluding the shocked node itself)
        pr_values = [v for k, v in pr_scores.items() if k != shocked_node and v > 0]
        pr_max = max(pr_values) if pr_values else 1.0

        results = {}
        for node_id, pr_score in pr_scores.items():
            if node_id == shocked_node:
                continue  # Skip the shock origin itself

            node_data = self.graph.nodes.get(node_id, {})
            node_type = node_data.get("type", "")

            # Only score drugs, APIs, and inputs β€” not provinces
            if node_type not in ("drug", "api", "input"):
                continue

            substitutability = node_data.get("substitutability", 0.3)
            buffer_days = max(1, node_data.get("buffer_days", 15))
            community_id = self._communities.get(node_id, 0)
            community_amp = self.get_community_amplifier(community_id)

            # Normalize PR score relative to max (so top node β†’ ~1.0)
            pr_normalized = pr_score / pr_max if pr_max > 0 else 0

            # The combined formula
            non_substitutability = 1.0 - substitutability
            buffer_decay = math.exp(-buffer_days / tau)
            risk_raw = pr_normalized * non_substitutability * buffer_decay * community_amp

            # Scale to 0-100
            risk_score_normalized = min(100.0, risk_raw * 100)

            if risk_score_normalized > 0.5:  # Include anything meaningful
                results[node_id] = {
                    "risk_score": round(risk_score_normalized, 2),
                    "components": {
                        "pagerank": round(pr_score, 6),
                        "pagerank_normalized": round(pr_normalized, 4),
                        "non_substitutability": round(non_substitutability, 3),
                        "buffer_decay": round(buffer_decay, 3),
                        "community_amplifier": round(community_amp, 3),
                        "community_id": community_id,
                        "community_label": self._community_labels.get(community_id, ""),
                        "buffer_days": buffer_days,
                        "substitutability": round(substitutability, 3),
                    },
                    "type": node_type,
                    "name": node_data.get("name", node_id),
                    "sector": node_data.get("sector", sector),
                }

        # Sort by risk score descending
        results = dict(
            sorted(results.items(), key=lambda x: x[1]["risk_score"], reverse=True)
        )

        return results

    # ─── Existing Methods (Preserved) ────────────────────────────────────

    def get_node(self, node_id: str) -> Optional[Dict[str, Any]]:
        """Returns node data in a flat dictionary format."""
        if not self.graph.has_node(node_id):
            return None

        node_data = self.graph.nodes[node_id]
        return {
            "id": node_id,
            "type": node_data.get("type"),
            "name": node_data.get("name"),
            "attributes": {k: v for k, v in node_data.items() if k not in ["type", "name"]},
        }

    def get_neighbors(
        self,
        node_id: str,
        direction: Literal["in", "out", "both"] = "out",
    ) -> List[str]:
        """Returns the IDs of neighboring nodes based on direction."""
        if not self.graph.has_node(node_id):
            return []

        if direction == "out":
            return list(self.graph.successors(node_id))
        elif direction == "in":
            return list(self.graph.predecessors(node_id))
        else:
            return list(nx.all_neighbors(self.graph, node_id))

    def get_dependency_chain(self, drug_id: str) -> List[List[str]]:
        """
        Finds all simple paths of length 1-3 starting from a drug.
        Typically captures drug -> api -> province paths.
        """
        if not self.graph.has_node(drug_id):
            return []

        all_paths = []
        # Find paths up to length 3 (Drug -> API -> Province)
        for target in self.graph.nodes():
            if target == drug_id:
                continue
            # Use short path limits to capture the direct supply chain
            paths = list(nx.all_simple_paths(self.graph, source=drug_id, target=target, cutoff=3))
            all_paths.extend(paths)

        return all_paths

    def compute_concentration_hhi(self, drug_id: str) -> float:
        """
        Calculates the Herfindahl-Hirschman Index (HHI) for a drug's geographic dependency.
        Ranges from 0 to 10000. High values indicate extreme concentration.
        """
        if not self.graph.has_node(drug_id):
            return 0.0

        # 1. Map drug to provinces with effective weights
        province_weights: Dict[str, float] = {}

        # Get APIs
        for api_id in self.graph.successors(drug_id):
            edge_da = self.graph[drug_id][api_id]
            w_da = edge_da.get("weight", 1.0)

            # Get Provinces for this API
            for prov_id in self.graph.successors(api_id):
                edge_ap = self.graph[api_id][prov_id]
                w_ap = edge_ap.get("weight", 1.0)

                # Cumulative dependency share on this province
                share = w_da * w_ap
                province_weights[prov_id] = province_weights.get(prov_id, 0.0) + share

        if not province_weights:
            return 0.0

        # 2. Calculate HHI
        total_weight = sum(province_weights.values())
        if total_weight == 0:
            return 0.0

        hhi = 0.0
        for weight in province_weights.values():
            percentage = (weight / total_weight) * 100
            hhi += percentage ** 2

        return hhi

    def get_drugs_dependent_on_province(self, province_id: str) -> List[str]:
        """
        Returns a list of drug IDs that are dependent on the given province
        by traversing the graph in reverse.
        """
        if not self.graph.has_node(province_id):
            return []

        dependent_drugs = set()
        # Traverse backwards: Province <- API <- Drug
        for api_id in self.graph.predecessors(province_id):
            for drug_id in self.graph.predecessors(api_id):
                node_data = self.graph.nodes[drug_id]
                if node_data.get("type") == "drug":
                    dependent_drugs.add(drug_id)

        # Also check rare earth inputs
        for input_id in self.graph.predecessors(province_id):
            node_data = self.graph.nodes.get(input_id, {})
            if node_data.get("type") == "input":
                dependent_drugs.add(input_id)

        return list(dependent_drugs)

    def nodes_by_type(self, node_type: str) -> List[Dict[str, Any]]:
        """Returns all nodes of a specific type (e.g., 'api', 'province')."""
        return [
            self.get_node(n)
            for n, d in self.graph.nodes(data=True)
            if d.get("type") == node_type
        ]

    def to_serializable_dict(self) -> Dict[str, Any]:
        """Converts the graph structure to a JSON-serializable dictionary."""
        nodes = []
        for n, d in self.graph.nodes(data=True):
            node_entry = {
                "id": n,
                "type": d.get("type"),
                "name": d.get("name"),
                "attributes": {k: v for k, v in d.items() if k not in ["type", "name"]},
            }
            # Add community info
            if n in self._communities:
                node_entry["community_id"] = self._communities[n]
            nodes.append(node_entry)

        edges = []
        for u, v, d in self.graph.edges(data=True):
            edges.append({
                "source": u,
                "target": v,
                "weight": d.get("weight"),
                "edge_type": d.get("edge_type"),
            })

        return {"nodes": nodes, "edges": edges}

    def apply_risk_scores(self, risk_map: Dict[str, float]) -> None:
        """
        Updates the 'current_risk' attribute on drug nodes based on a map of {drug_id: risk_score}.
        """
        for drug_id, risk in risk_map.items():
            if self.graph.has_node(drug_id):
                self.graph.nodes[drug_id]["current_risk"] = risk

    # ─── Private Helpers ─────────────────────────────────────────────────

    @staticmethod
    def _drug_substitutability(drug: Any) -> float:
        """Estimates substitutability score for a drug (0=no alternatives, 1=many)."""
        if drug.has_substitute:
            return 0.6
        if drug.nlem_tier.value == "TIER_1":
            return 0.1  # Critical, hard to replace
        return 0.3

    @staticmethod
    def _api_substitutability(api: Any) -> float:
        """Estimates substitutability for an API based on China share."""
        # Higher China share = fewer alternatives = lower substitutability
        return max(0.0, 1.0 - api.china_share)

    @staticmethod
    def _estimate_buffer_days(drug: Any) -> int:
        """Estimates buffer stock in days based on drug tier and population."""
        if drug.nlem_tier.value == "TIER_1":
            return 10  # High-demand, low buffer
        elif drug.nlem_tier.value == "TIER_2":
            return 20
        return 30

    def invalidate_cache(self):
        """Clears the PageRank cache (call after graph modifications)."""
        self._pagerank_cache.clear()