From 1fd4187c44b64988bcd3ad88991e05ebe6eaf92d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:58:26 +0000 Subject: [PATCH 1/3] Initial plan From 502a30c7ac3e3c9983f5aac76cdc9e5cc9bd237a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:08:00 +0000 Subject: [PATCH 2/3] Implement graph analytics system for KYB/AML shell company detection Agent-Logs-Url: https://github.com/Bitu-Singh-Rathoud/kyb-graph-analytics/sessions/11474446-c06f-4d54-a3d0-acc52c4adc89 Co-authored-by: Bitu-Singh-Rathoud <247644259+Bitu-Singh-Rathoud@users.noreply.github.com> --- kyb_graph_analytics.egg-info/PKG-INFO | 5 + kyb_graph_analytics.egg-info/SOURCES.txt | 18 ++ .../dependency_links.txt | 1 + kyb_graph_analytics.egg-info/requires.txt | 3 + kyb_graph_analytics.egg-info/top_level.txt | 1 + kyb_graph_analytics/__init__.py | 28 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1076 bytes .../__pycache__/centrality.cpython-312.pyc | Bin 0 -> 7778 bytes .../community_detection.cpython-312.pyc | Bin 0 -> 8997 bytes .../entity_resolution.cpython-312.pyc | Bin 0 -> 11056 bytes .../__pycache__/graph_builder.cpython-312.pyc | Bin 0 -> 10110 bytes .../shell_company_detector.cpython-312.pyc | Bin 0 -> 11932 bytes kyb_graph_analytics/centrality.py | 210 +++++++++++++ kyb_graph_analytics/community_detection.py | 231 ++++++++++++++ kyb_graph_analytics/entity_resolution.py | 252 +++++++++++++++ kyb_graph_analytics/graph_builder.py | 263 ++++++++++++++++ kyb_graph_analytics/shell_company_detector.py | 295 ++++++++++++++++++ requirements.txt | 4 + setup.py | 15 + tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 172 bytes ...st_centrality.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 25704 bytes ...ity_detection.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 22968 bytes ...ty_resolution.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 28959 bytes ...graph_builder.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 23962 bytes ...pany_detector.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 23023 bytes tests/test_centrality.py | 152 +++++++++ tests/test_community_detection.py | 161 ++++++++++ tests/test_entity_resolution.py | 186 +++++++++++ tests/test_graph_builder.py | 163 ++++++++++ tests/test_shell_company_detector.py | 183 +++++++++++ 31 files changed, 2171 insertions(+) create mode 100644 kyb_graph_analytics.egg-info/PKG-INFO create mode 100644 kyb_graph_analytics.egg-info/SOURCES.txt create mode 100644 kyb_graph_analytics.egg-info/dependency_links.txt create mode 100644 kyb_graph_analytics.egg-info/requires.txt create mode 100644 kyb_graph_analytics.egg-info/top_level.txt create mode 100644 kyb_graph_analytics/__init__.py create mode 100644 kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc create mode 100644 kyb_graph_analytics/__pycache__/centrality.cpython-312.pyc create mode 100644 kyb_graph_analytics/__pycache__/community_detection.cpython-312.pyc create mode 100644 kyb_graph_analytics/__pycache__/entity_resolution.cpython-312.pyc create mode 100644 kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc create mode 100644 kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc create mode 100644 kyb_graph_analytics/centrality.py create mode 100644 kyb_graph_analytics/community_detection.py create mode 100644 kyb_graph_analytics/entity_resolution.py create mode 100644 kyb_graph_analytics/graph_builder.py create mode 100644 kyb_graph_analytics/shell_company_detector.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_community_detection.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_centrality.py create mode 100644 tests/test_community_detection.py create mode 100644 tests/test_entity_resolution.py create mode 100644 tests/test_graph_builder.py create mode 100644 tests/test_shell_company_detector.py diff --git a/kyb_graph_analytics.egg-info/PKG-INFO b/kyb_graph_analytics.egg-info/PKG-INFO new file mode 100644 index 0000000..eb3b479 --- /dev/null +++ b/kyb_graph_analytics.egg-info/PKG-INFO @@ -0,0 +1,5 @@ +Metadata-Version: 2.1 +Name: kyb-graph-analytics +Version: 0.1.0 +Summary: Graph-based fraud detection for KYB/AML: shell company and hidden ownership detection +Requires-Python: >=3.8 diff --git a/kyb_graph_analytics.egg-info/SOURCES.txt b/kyb_graph_analytics.egg-info/SOURCES.txt new file mode 100644 index 0000000..4691c25 --- /dev/null +++ b/kyb_graph_analytics.egg-info/SOURCES.txt @@ -0,0 +1,18 @@ +README.md +setup.py +kyb_graph_analytics/__init__.py +kyb_graph_analytics/centrality.py +kyb_graph_analytics/community_detection.py +kyb_graph_analytics/entity_resolution.py +kyb_graph_analytics/graph_builder.py +kyb_graph_analytics/shell_company_detector.py +kyb_graph_analytics.egg-info/PKG-INFO +kyb_graph_analytics.egg-info/SOURCES.txt +kyb_graph_analytics.egg-info/dependency_links.txt +kyb_graph_analytics.egg-info/requires.txt +kyb_graph_analytics.egg-info/top_level.txt +tests/test_centrality.py +tests/test_community_detection.py +tests/test_entity_resolution.py +tests/test_graph_builder.py +tests/test_shell_company_detector.py \ No newline at end of file diff --git a/kyb_graph_analytics.egg-info/dependency_links.txt b/kyb_graph_analytics.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/kyb_graph_analytics.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/kyb_graph_analytics.egg-info/requires.txt b/kyb_graph_analytics.egg-info/requires.txt new file mode 100644 index 0000000..cde698a --- /dev/null +++ b/kyb_graph_analytics.egg-info/requires.txt @@ -0,0 +1,3 @@ +networkx>=3.0 +numpy>=1.24 +python-louvain>=0.16 diff --git a/kyb_graph_analytics.egg-info/top_level.txt b/kyb_graph_analytics.egg-info/top_level.txt new file mode 100644 index 0000000..c81819c --- /dev/null +++ b/kyb_graph_analytics.egg-info/top_level.txt @@ -0,0 +1 @@ +kyb_graph_analytics diff --git a/kyb_graph_analytics/__init__.py b/kyb_graph_analytics/__init__.py new file mode 100644 index 0000000..8fce43a --- /dev/null +++ b/kyb_graph_analytics/__init__.py @@ -0,0 +1,28 @@ +""" +kyb_graph_analytics +=================== +Graph-based analytics system for detecting shell companies and hidden +ownership structures in KYB/AML investigations. + +Modules +------- +graph_builder - Build directed ownership/relationship graphs from raw data. +centrality - PageRank and Betweenness centrality measures. +community_detection - Louvain community detection. +entity_resolution - Fuzzy entity matching and deduplication. +shell_company_detector - Composite risk scoring combining all analyses. +""" + +from .graph_builder import GraphBuilder +from .centrality import CentralityAnalyzer +from .community_detection import CommunityDetector +from .entity_resolution import EntityResolver +from .shell_company_detector import ShellCompanyDetector + +__all__ = [ + "GraphBuilder", + "CentralityAnalyzer", + "CommunityDetector", + "EntityResolver", + "ShellCompanyDetector", +] diff --git a/kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc b/kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e7320986e30cdeea052ab1b77c63825c13448a36 GIT binary patch literal 1076 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 literal 0 HcmV?d00001 diff --git a/kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc b/kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c3ea8c53be6a34e213bfe905190a3a5a0bd61f1 GIT binary patch literal 10110 zcmcIqZ)_Z8dY{>yUGJ{fUOToE#|dN-z<7=002e4EkgF3X;1CQdxuY0z>+#Omo@94+ z@15D0bvFo|P-Q5+kmDo=6$vA?1sbWxx1Oq2;$x+%Qe0mZYs!a|t90t8ZsPQ+<5Pdn z`_9bH+H0<%y|MhxJMW+8{qy{ufA72h*wK-a@N_-!PiEVGN%|#y_>Uqo^2UqEEK9mH zCF!yrDagghR79qHv=A*SQ%W&56)VQ4;>EV9wqjx`QA|!HdA(9dO|?g)XCyuLmZZn8 zMFhpEbXCc=Y4^&Kl$v8&c|JE&F$=oEM$6UI!A(!X=p*WaVb0AXrN`!rmfDhQc<-XR>?aRX6Pxya!jYH>YAgevn8fJ_sa1@W6z&a zP3xjzJLa4qFq%r8q=u$pgKC)>Hu|agQn9RAC>ud@-Mnb(6|H~|EuSw{EC)FdEIIQA zQ>~J2*rTbF`dkx*V74P5W;v`>;C&pXS+yfu&Q%X|OcO<3opWdTKQ4`a9b>%H(Do%BJU${jhukQq}V^eK@iM~sZqfeq$ zAH831L))ajTTh@(N*~aZc(>!7!aJ?+(cAIvFgo?UdipKpnzxpz3@_;jm2~O%=$&ZW zUCm_gb<@ufsyJ}4J5K{=CjbqO1XZ(iwWwLzoPlfuLx;8^2uM)zyr?m)XkeRd{@H*h z|MYQrgnv={m*)*j9X`t{#;`i18?#!a;AB-mL*=+sOFIHkPjY(tsKCvFX&b8P zpgN$0`BeZm0ep1vI*h973jy@Q(*|J%8WvCp%oeHvAwh#fY(%w7Amvkin&vq@O_Rd@ zl&rZz6(9uKi-x5e`Y0!n_vDsuKjncVd85WLRFi8`&f|+D)nqo{cqo&k^PUC?s*&+| zh+aW$RSNZqhg*w$>pod|G8=P~Ie#f`#CqLr^S0HE@y&8$e9yBHSFw%4tefx~+q4W- z{rU%o=1WE65UW_wC5IMD?82c7)tQ65*Fm79P<70_y;Gh@0Aq}nDxVW^(+B;}?$r3RcmAtB&z5)>-hBrH@U2<1$@Au&|^Jn`G ze%6E@B^6ClK+n2qJH(}UxwD?wue?VnT@b+ zFxE|Ix}GCmWfiuA3X)zcQ)m73oob9?lN{3auwGGvuA=qgt|rV9O;Pb68rlbuyeZWO zrR3fv<#Oun)Q!ZR)x@6lbl{8KNtckUb6s*3nPq5? zap~MiNxBqq06+26BG4s=%(k`SpW zkc5bkgm<4&BzP(@Z46U5UMXBSh%FE(BJY6v7tqnBXM)wzY=e1lSn>``cz}|VT7I4v zs>O;;4u$1trbRZ0kfj4Q>II{Elp7__h6jDoBE)5{Dd}ud)pHgs2PBouid{DHCj5wi zyP*l(g?M6h%|P|N3|b;z*obP-dKOG)9)qbxD7j*#h^71yt>uV%;3-o-cHq+VbVCf> zh!4KDigm$)KQx(*x(bnVqvcYW?FBEgF@e_vUoYn2#>L9n0BSjQZPIpxR}Zl10GDXK zxVtrEQT6-iW}ig@(C(CW?Yk{0$Umbk^wWU+*{r7&9`XIG(c&&eEX<~gq z{b=}u;gxfrPpl0bTY7Fiy=Q6s@(XXjaCKreJ@NU`>*r5NnT^zVAf&nMQSttc)y=x%Y zr3%X%TlDnVhDLVg!TDkE(H#2PX5rTF%C ztftgrwfL-JN`WQ2087@EO=yS1mMpxn|4-%&Vk2NcMv6i$6bLX-0Sp{WqToM*7|+b{ zV(xE+C&W?b%c;N~zV2F$g-QN4qoyKo&%;rJ$IBIIXs&KtEX$3H)o@(93h5S(?q2~u zcVkq^a2k+=zb@+EU}HsR9SC*hWLZx$4L@!oHg z6#F7b@C2eVAocFQEya`1$xA1`+_CdL`~B?FiS@qz<(IF#eDx34`UYYBn!k1pEH7MH zxW4bv&rhxGI=b|1P`zTW^*w~<>HSONH`4o8)B9HjKOOvh=;xY#yy ztcCroK-T^aGxKEaWl4GsChd}PDF)QGj9M zIfzdm@EojfBQT`Wzd=x@r@i&SlKV?SHVf%4YbFbcOc?GhEVXC+n%x&3e`)1%!NhVA zW@4e6C9#$9^=XMZ^_vNhgi?^i{Y@dEo&}5XwTDbrAf8+7Rwp_CIclFHJ}>TRlEvoA z%NWf50TKw}khJgq+fpp~y!`g$(y8}Ot!MhKp8aU*gQ?ZbgX>+r%b6>gmH02Z9^AG( z-PI7f>yMuJ;`{%SK69&`2yQiQBr@E%k=F}>YbhWt1uF=tT8YIqP^0H198E4h@dpM@ zR}shX;PoIvpWHs~1TIM^~ z6W`goX`~1ErmY~e-KN>?jT5Uz{P`o<3AD(0@^m&mU_fG15k70OZ}-Qg%aMF!4l(k% zB>5n7Qu%vmBA*A47R}1zFuTOyM$f9lo>DON zVH^u`Jw?v7I5eNfA&bWH^VN_|oghXAk%i#k0v#s`DOBf5Ld9_^>Q{gK6XC4m9NZr^ z&`6}XNHJE*IHxhRqUXm#-Vr#`iM60aP57bGMN?CUaonyOSb&WM42R}*7qM&g8$%47 zTrn+ggq&&|Gld2cJJ~pb^$xItRryLCYYNK9PLFx(4=Qg5| zp3b@y>FB(Zl6Lklzjo!dmHR&#{&@IPbFJ@*U&oZL)XlE$^-R}$6YsonJ0^8>-j1TI zjxap+8wBHBDIVGXZRc3K()yI>M;P|oOQb{o2Y*^hp>&?#g!yg6yCIv`AZGMH=|vXr zganR!Mx2%7{BVoN4F$JGbg++&v=Ocau=zh@x(pG+)s&F&5aDl&;qhQ4Aryyj`ZyG? zZR)1RaGG)#AdPd7vSZjiS0Mos7;O*joIt5i{T!vuT0cDSCaoW?`Ui|)Lthd$2g2BV zHiXSMDt>x=dGgBS%HdCre0=27>RRuUYndb06GwzG@E8p;;$WQ{0}CJ9L5~eJjKQ}t z06w(*_e;_ox2@WdX4P(+ML3RXzM6;o=<9Q?qeIL;VX{2DQd~v`ir#}^RUP9PRF8|- z3Go`^+D7xPe*$#US$AH8A>pT*sy%POxPW!yGBq^d{}yfAX-yyLZ69B9&pO?Cf@PWCVo;H_UucU?aA&ap3hc6}JR(Lc1>KlEwu&jvmlSnEH$ z*7Ml)^keIp?xo~bh#~qB{>J0TYzMJcPFj;2cF$Sd4;Q@K4`P|;Y0RuEcyihEuBN_*4cf1~0*(Pv{960DkpNqAS;)l7CR^U(FgL%*vf-nV&) z;Q+&n5?U-&`1D&A%3b$X3vE=~!5Z1~l<sF1^9V|8y6y*O|S{-b%ql9Rf@1Ic7`!F3WobEr|C^Rs?1Yxh@ zlOL%vg>tV@GKB=R4&e25zkg-(>NqY~UPZqVIH}ch=R!>*>=1Q(n7XA|mhzk|sWg!r z3~~=5=f-9WCC%Ap*hi@CYm_u5li*xl+#O7Y|0qSw22;Xpnnz&gn)@s4C6J=JyO|?@ELHcD>d?s`bchFfoHK&ApczX}z@Ga?9BoRy8=~ZGWcshF@sJHEk zrEd1@tjF*MEO+kaCG~`~YoMN_vXqqS+-OH`caU5$m9`P9m0}+;O=l_ME(a&tvr{Wo{-rHe!^iKjG0&?v6b# z-^>ivW0b)Npxef?2_np^y~$DO#^aIL;f+K;=6Sd~c64K~GqwYVCh^YOkw|Q0BZ^#| za+u(jvO9^+m^k9+yOr%=d2ARlZni?d00W08@WByowTz%j2o448j8rQloz5seXQVaV zgoRr){2t_W?mGx37*tEffHUUn#8Uzh$C!yS(vLPVz8;lj84Qw@|LT?G`+g-oeM5Ts Smr~z<$171ebz7p8Q~4jQbH`Kw literal 0 HcmV?d00001 diff --git a/kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc b/kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f7e5803a26ee60f993f9f1726ae488857b7ca77 GIT binary patch literal 11932 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 LTzX+n_VB*|6nDN3 literal 0 HcmV?d00001 diff --git a/kyb_graph_analytics/centrality.py b/kyb_graph_analytics/centrality.py new file mode 100644 index 0000000..7031f05 --- /dev/null +++ b/kyb_graph_analytics/centrality.py @@ -0,0 +1,210 @@ +""" +centrality.py +------------- +Compute centrality measures on a KYB/AML ownership graph. + +PageRank + Identifies the most *influential* entities in an ownership network. + High-PageRank nodes are likely ultimate beneficial owners (UBOs) or + pivotal holding companies. + +Betweenness Centrality + Identifies *bridge* entities that sit on many shortest paths. + High-betweenness nodes are often intermediary shell companies used to + obfuscate ownership chains. + +In-Degree / Out-Degree + Simple counts of incoming/outgoing ownership edges. An entity with + many owners but few or no subsidiaries may be an opaque vehicle. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +import networkx as nx + + +class CentralityAnalyzer: + """Compute and expose centrality metrics for an ownership graph. + + Parameters + ---------- + graph: + A ``networkx.DiGraph`` (or ``Graph``) representing the ownership + network produced by :class:`~kyb_graph_analytics.GraphBuilder`. + """ + + def __init__(self, graph: nx.Graph) -> None: + self.graph = graph + + # ------------------------------------------------------------------ + # PageRank + # ------------------------------------------------------------------ + + def pagerank( + self, + alpha: float = 0.85, + weight: Optional[str] = "weight", + max_iter: int = 100, + tol: float = 1.0e-6, + ) -> Dict[str, float]: + """Compute PageRank for all nodes. + + Parameters + ---------- + alpha: + Damping factor (default 0.85). + weight: + Edge attribute to use as weight. Pass ``None`` to treat all + edges equally. + max_iter: + Maximum number of iterations. + tol: + Convergence tolerance. + + Returns + ------- + dict mapping node ID → PageRank score (float in [0, 1]). + """ + if self.graph.number_of_nodes() == 0: + return {} + return nx.pagerank( + self.graph, + alpha=alpha, + weight=weight, + max_iter=max_iter, + tol=tol, + ) + + # ------------------------------------------------------------------ + # Betweenness Centrality + # ------------------------------------------------------------------ + + def betweenness_centrality( + self, + normalized: bool = True, + weight: Optional[str] = None, + ) -> Dict[str, float]: + """Compute Betweenness Centrality for all nodes. + + Parameters + ---------- + normalized: + When *True* (default) values are normalised to [0, 1]. + weight: + Edge attribute interpreted as *distance* (lower weight = shorter + path). Pass ``None`` to count hops only. + + Returns + ------- + dict mapping node ID → betweenness score (float in [0, 1]). + """ + if self.graph.number_of_nodes() == 0: + return {} + return nx.betweenness_centrality( + self.graph, + normalized=normalized, + weight=weight, + ) + + # ------------------------------------------------------------------ + # Degree Centrality + # ------------------------------------------------------------------ + + def in_degree_centrality(self) -> Dict[str, float]: + """Normalised in-degree centrality (for directed graphs). + + Returns + ------- + dict mapping node ID → normalised in-degree score. + """ + if self.graph.number_of_nodes() == 0: + return {} + if isinstance(self.graph, nx.DiGraph): + return nx.in_degree_centrality(self.graph) + return nx.degree_centrality(self.graph) + + def out_degree_centrality(self) -> Dict[str, float]: + """Normalised out-degree centrality (for directed graphs). + + Returns + ------- + dict mapping node ID → normalised out-degree score. + """ + if self.graph.number_of_nodes() == 0: + return {} + if isinstance(self.graph, nx.DiGraph): + return nx.out_degree_centrality(self.graph) + return nx.degree_centrality(self.graph) + + # ------------------------------------------------------------------ + # Combined report + # ------------------------------------------------------------------ + + def all_centrality_scores( + self, + pagerank_alpha: float = 0.85, + ) -> Dict[str, Dict[str, float]]: + """Return a combined dict of all centrality measures per node. + + Parameters + ---------- + pagerank_alpha: + Damping factor forwarded to :meth:`pagerank`. + + Returns + ------- + dict mapping node ID → ``{"pagerank": …, "betweenness": …, + "in_degree": …, "out_degree": …}``. + """ + pr = self.pagerank(alpha=pagerank_alpha) + bc = self.betweenness_centrality() + in_deg = self.in_degree_centrality() + out_deg = self.out_degree_centrality() + + return { + node: { + "pagerank": pr.get(node, 0.0), + "betweenness": bc.get(node, 0.0), + "in_degree": in_deg.get(node, 0.0), + "out_degree": out_deg.get(node, 0.0), + } + for node in self.graph.nodes() + } + + def top_nodes( + self, + measure: str = "pagerank", + n: int = 10, + pagerank_alpha: float = 0.85, + ) -> list: + """Return the top-*n* nodes ranked by *measure*. + + Parameters + ---------- + measure: + One of ``"pagerank"``, ``"betweenness"``, ``"in_degree"``, + ``"out_degree"``. + n: + Number of top nodes to return. + pagerank_alpha: + Damping factor forwarded to :meth:`pagerank`. + + Returns + ------- + list of ``(node_id, score)`` tuples, sorted descending. + """ + scores_map = { + "pagerank": self.pagerank(alpha=pagerank_alpha), + "betweenness": self.betweenness_centrality(), + "in_degree": self.in_degree_centrality(), + "out_degree": self.out_degree_centrality(), + } + if measure not in scores_map: + raise ValueError( + f"Unknown measure '{measure}'. Choose from: " + + ", ".join(scores_map) + ) + scores = scores_map[measure] + return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:n] diff --git a/kyb_graph_analytics/community_detection.py b/kyb_graph_analytics/community_detection.py new file mode 100644 index 0000000..536d36e --- /dev/null +++ b/kyb_graph_analytics/community_detection.py @@ -0,0 +1,231 @@ +""" +community_detection.py +---------------------- +Louvain community detection for KYB/AML ownership graphs. + +Communities in an ownership graph reveal clusters of closely related +entities that may constitute a single beneficial ownership group. A +community containing many shell-like companies warrants deeper scrutiny. + +Louvain is applied to the *undirected* version of the graph so that +ownership links in either direction contribute to the same community. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +import networkx as nx + +try: + import community as community_louvain # python-louvain package +except ImportError as exc: # pragma: no cover + raise ImportError( + "The 'python-louvain' package is required for community detection. " + "Install it with: pip install python-louvain" + ) from exc + + +class CommunityDetector: + """Detect ownership communities using the Louvain algorithm. + + Parameters + ---------- + graph: + A NetworkX graph (directed or undirected). Directed graphs are + automatically converted to undirected for community detection. + random_state: + Seed for the Louvain random-number generator. Set to an integer + for reproducible results. + """ + + def __init__( + self, + graph: nx.Graph, + random_state: Optional[int] = 42, + ) -> None: + self.graph = graph + self.random_state = random_state + self._partition: Optional[Dict[str, int]] = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _undirected(self) -> nx.Graph: + """Return an undirected copy of the graph, collapsing parallel edges.""" + if isinstance(self.graph, nx.DiGraph): + return self.graph.to_undirected() + return self.graph + + # ------------------------------------------------------------------ + # Partition + # ------------------------------------------------------------------ + + def detect(self, resolution: float = 1.0) -> Dict[str, int]: + """Run Louvain community detection and return the partition. + + Parameters + ---------- + resolution: + Controls community granularity. Higher values produce more, + smaller communities; lower values produce fewer, larger ones. + Default is ``1.0`` (standard Louvain). + + Returns + ------- + dict mapping node ID → integer community label. + """ + undirected = self._undirected() + if undirected.number_of_nodes() == 0: + self._partition = {} + return self._partition + + self._partition = community_louvain.best_partition( + undirected, + weight="weight", + resolution=resolution, + random_state=self.random_state, + ) + return self._partition + + @property + def partition(self) -> Optional[Dict[str, int]]: + """The last computed partition, or *None* if :meth:`detect` has not + been called yet.""" + return self._partition + + # ------------------------------------------------------------------ + # Community grouping + # ------------------------------------------------------------------ + + def communities(self, resolution: float = 1.0) -> Dict[int, List[str]]: + """Return detected communities as a dict of label → member list. + + Calls :meth:`detect` internally if not already done. + + Parameters + ---------- + resolution: + Forwarded to :meth:`detect`. + + Returns + ------- + dict mapping community label → list of node IDs in that community. + """ + partition = self.detect(resolution=resolution) + groups: Dict[int, List[str]] = {} + for node, label in partition.items(): + groups.setdefault(label, []).append(node) + return groups + + def community_of(self, node_id: str) -> Optional[int]: + """Return the community label for *node_id*. + + Returns ``None`` if :meth:`detect` has not been called or the node + does not appear in the partition. + """ + if self._partition is None: + return None + return self._partition.get(node_id) + + # ------------------------------------------------------------------ + # Modularity + # ------------------------------------------------------------------ + + def modularity(self, resolution: float = 1.0) -> float: + """Compute the modularity score of the current (or new) partition. + + Higher modularity (closer to 1.0) indicates more clearly separated + communities; lower scores suggest poorly structured clusters. + + Parameters + ---------- + resolution: + Forwarded to :meth:`detect`. + + Returns + ------- + float modularity score. + """ + partition = self.detect(resolution=resolution) + if not partition: + return 0.0 + undirected = self._undirected() + return community_louvain.modularity(partition, undirected, weight="weight") + + # ------------------------------------------------------------------ + # Suspicious community indicators + # ------------------------------------------------------------------ + + def suspicious_communities( + self, + min_size: int = 2, + max_size: int = 50, + resolution: float = 1.0, + ) -> List[Dict]: + """Identify communities that exhibit shell-company warning signs. + + A community is flagged as suspicious when: + - Its size is in the range [*min_size*, *max_size*], which filters + out trivial singletons and very large legitimate conglomerates. + - It contains at least one entity of type ``"company"`` and at least + one ``"individual"`` (typical ownership structure). + - OR it consists entirely of ``"company"`` nodes with no individuals + (layers of holding companies with no traceable UBO). + + Parameters + ---------- + min_size: + Minimum community size to consider. + max_size: + Maximum community size to consider. + resolution: + Forwarded to :meth:`detect`. + + Returns + ------- + list of dicts, each with keys: + ``"community_id"``, ``"members"``, ``"size"``, + ``"has_individuals"``, ``"has_companies"``, ``"reason"``. + """ + communities = self.communities(resolution=resolution) + suspicious = [] + + for label, members in communities.items(): + size = len(members) + if size < min_size or size > max_size: + continue + + types = [ + self.graph.nodes[m].get("entity_type", "unknown") + for m in members + ] + has_individuals = any(t == "individual" for t in types) + has_companies = any(t == "company" for t in types) + all_companies = all(t == "company" for t in types) + + reasons = [] + if all_companies and size > 1: + reasons.append( + "Community contains only company nodes with no traceable UBO" + ) + elif has_companies and not has_individuals and size > 1: + # Mixed companies/unknown-type entities but no individual UBO + reasons.append( + "No individual beneficial owners in the ownership community" + ) + + if reasons: + suspicious.append( + { + "community_id": label, + "members": members, + "size": size, + "has_individuals": has_individuals, + "has_companies": has_companies, + "reason": "; ".join(reasons), + } + ) + + return suspicious diff --git a/kyb_graph_analytics/entity_resolution.py b/kyb_graph_analytics/entity_resolution.py new file mode 100644 index 0000000..1421f15 --- /dev/null +++ b/kyb_graph_analytics/entity_resolution.py @@ -0,0 +1,252 @@ +""" +entity_resolution.py +-------------------- +Identify and merge duplicate or alias entity records in a KYB/AML graph. + +Shell-company schemes frequently use slight name variations (typos, +abbreviations, transliterations) to mask that multiple records refer to +the same real-world entity. This module provides: + +* ``EntityResolver`` – fuzzy string-similarity matching that groups + candidate duplicate entities and can collapse them in the graph. + +The similarity metric is token-sort-ratio computed over the *name* +attribute of each node, falling back to the node ID when the attribute is +absent. The implementation intentionally avoids heavy ML dependencies so +the library can run without GPU resources. +""" + +from __future__ import annotations + +import unicodedata +import re +from typing import Dict, List, Optional, Set, Tuple + +import networkx as nx + + +# --------------------------------------------------------------------------- +# String normalisation helpers +# --------------------------------------------------------------------------- + +def _normalise(text: str) -> str: + """Lower-case, strip accents, collapse whitespace.""" + # Strip unicode accents + nfkd = unicodedata.normalize("NFKD", text) + ascii_text = nfkd.encode("ascii", "ignore").decode("ascii") + # Lower-case and collapse non-alphanumeric runs to single space + cleaned = re.sub(r"[^a-z0-9]+", " ", ascii_text.lower()).strip() + return cleaned + + +def _token_sort_ratio(a: str, b: str) -> float: + """Compute a token-sort similarity ratio between two strings. + + Tokens in both strings are sorted alphabetically and joined before + comparison, making the metric order-invariant. Returns a float in + [0.0, 1.0]. + """ + tokens_a = sorted(_normalise(a).split()) + tokens_b = sorted(_normalise(b).split()) + joined_a = " ".join(tokens_a) + joined_b = " ".join(tokens_b) + + if not joined_a and not joined_b: + return 1.0 + if not joined_a or not joined_b: + return 0.0 + + # Longest common subsequence length as similarity proxy + lcs_len = _lcs_length(joined_a, joined_b) + return 2 * lcs_len / (len(joined_a) + len(joined_b)) + + +def _lcs_length(s: str, t: str) -> int: + """Iterative LCS length computation (space-optimised).""" + m, n = len(s), len(t) + if m > n: + s, t, m, n = t, s, n, m + # Use two rows + prev = [0] * (m + 1) + curr = [0] * (m + 1) + for j in range(1, n + 1): + for i in range(1, m + 1): + if t[j - 1] == s[i - 1]: + curr[i] = prev[i - 1] + 1 + else: + curr[i] = max(curr[i - 1], prev[i]) + prev, curr = curr, [0] * (m + 1) + return prev[m] + + +# --------------------------------------------------------------------------- +# EntityResolver +# --------------------------------------------------------------------------- + +class EntityResolver: + """Detect and optionally merge duplicate entity nodes in a graph. + + Parameters + ---------- + graph: + A NetworkX graph whose nodes may carry a ``"name"`` attribute used + for similarity comparison. + threshold: + Minimum similarity score (0.0–1.0) to consider two entities as + potential duplicates. Default is ``0.85``. + name_attr: + Node attribute to use as the canonical name for comparison. + Defaults to ``"name"``; falls back to the node ID when absent. + """ + + def __init__( + self, + graph: nx.Graph, + threshold: float = 0.85, + name_attr: str = "name", + ) -> None: + if not 0.0 <= threshold <= 1.0: + raise ValueError("threshold must be between 0.0 and 1.0") + self.graph = graph + self.threshold = threshold + self.name_attr = name_attr + + # ------------------------------------------------------------------ + # Label extraction + # ------------------------------------------------------------------ + + def _label(self, node_id: str) -> str: + """Return the comparison label for a node.""" + return str(self.graph.nodes[node_id].get(self.name_attr, node_id)) + + # ------------------------------------------------------------------ + # Duplicate candidate detection + # ------------------------------------------------------------------ + + def find_duplicates(self) -> List[Tuple[str, str, float]]: + """Return all pairs of nodes with similarity >= threshold. + + Returns + ------- + list of ``(node_a, node_b, similarity_score)`` tuples, sorted by + descending score. + """ + nodes = list(self.graph.nodes()) + candidates: List[Tuple[str, str, float]] = [] + + for i, a in enumerate(nodes): + for b in nodes[i + 1 :]: + score = _token_sort_ratio(self._label(a), self._label(b)) + if score >= self.threshold: + candidates.append((a, b, score)) + + return sorted(candidates, key=lambda x: x[2], reverse=True) + + def duplicate_groups(self) -> List[List[str]]: + """Return groups of mutually similar entities using union-find. + + Returns + ------- + list of groups, where each group is a list of node IDs that are + considered the same real-world entity. + """ + pairs = self.find_duplicates() + parent: Dict[str, str] = {n: n for n in self.graph.nodes()} + + def find(x: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(x: str, y: str) -> None: + parent[find(x)] = find(y) + + for a, b, _ in pairs: + union(a, b) + + groups: Dict[str, List[str]] = {} + for node in self.graph.nodes(): + root = find(node) + groups.setdefault(root, []).append(node) + + return [g for g in groups.values() if len(g) > 1] + + # ------------------------------------------------------------------ + # Graph merging + # ------------------------------------------------------------------ + + def merge_duplicates( + self, + groups: Optional[List[List[str]]] = None, + ) -> nx.Graph: + """Return a new graph where each group of duplicates is merged into + a single canonical node. + + The canonical node for each group is the one with the longest name + attribute (or the first alphabetically if lengths are equal). All + edges from/to merged nodes are redirected to the canonical node. + Self-loops introduced by merging are removed. + + Parameters + ---------- + groups: + Explicit list of duplicate groups. When *None* (default), + :meth:`duplicate_groups` is called automatically. + + Returns + ------- + A new NetworkX graph (same type as ``self.graph``) with duplicates + merged. + """ + if groups is None: + groups = self.duplicate_groups() + + # Build a mapping: old_node → canonical_node + merge_map: Dict[str, str] = {} + for group in groups: + canonical = max(group, key=lambda n: len(self._label(n))) + for node in group: + merge_map[node] = canonical + + # Relabel nodes in a copy of the graph + merged = nx.relabel_nodes(self.graph, merge_map, copy=True) + # Remove self-loops introduced by merging + merged.remove_edges_from(list(nx.selfloop_edges(merged))) + return merged + + # ------------------------------------------------------------------ + # Convenience report + # ------------------------------------------------------------------ + + def resolution_report(self) -> List[Dict]: + """Return a human-readable list of detected duplicate groups. + + Returns + ------- + list of dicts with keys: + ``"canonical"``, ``"aliases"``, ``"similarity_pairs"``. + """ + groups = self.duplicate_groups() + pairs = { + frozenset((a, b)): score + for a, b, score in self.find_duplicates() + } + report = [] + for group in groups: + canonical = max(group, key=lambda n: len(self._label(n))) + aliases = [n for n in group if n != canonical] + sim_pairs = [ + {"a": a, "b": b, "score": pairs[frozenset((a, b))]} + for a in group + for b in group + if a < b and frozenset((a, b)) in pairs + ] + report.append( + { + "canonical": canonical, + "aliases": aliases, + "similarity_pairs": sim_pairs, + } + ) + return report diff --git a/kyb_graph_analytics/graph_builder.py b/kyb_graph_analytics/graph_builder.py new file mode 100644 index 0000000..a145255 --- /dev/null +++ b/kyb_graph_analytics/graph_builder.py @@ -0,0 +1,263 @@ +""" +graph_builder.py +---------------- +Build directed, weighted ownership / relationship graphs from structured +entity data for KYB/AML investigations. + +Entities represent companies, individuals, accounts, or other nodes. +Edges represent ownership, control, or transactional relationships. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import networkx as nx + + +class GraphBuilder: + """Construct and manage an ownership/relationship graph. + + Parameters + ---------- + directed: + When *True* (default) the graph is a ``DiGraph``; otherwise it is + an undirected ``Graph``. Ownership relationships are inherently + directional, so ``directed=True`` is strongly recommended. + """ + + def __init__(self, directed: bool = True) -> None: + self._directed = directed + self.graph: nx.DiGraph | nx.Graph = ( + nx.DiGraph() if directed else nx.Graph() + ) + + # ------------------------------------------------------------------ + # Node management + # ------------------------------------------------------------------ + + def add_entity( + self, + entity_id: str, + entity_type: str = "unknown", + **attributes: Any, + ) -> None: + """Add a single entity node to the graph. + + Parameters + ---------- + entity_id: + Unique identifier for the entity (e.g. company registration + number, person ID). + entity_type: + Semantic type label: ``"company"``, ``"individual"``, + ``"account"``, etc. + **attributes: + Arbitrary extra node attributes (name, jurisdiction, …). + """ + self.graph.add_node( + entity_id, + entity_type=entity_type, + **attributes, + ) + + def add_entities(self, entities: Iterable[Dict[str, Any]]) -> None: + """Bulk-add entities from an iterable of attribute dicts. + + Each dict must contain an ``"id"`` key; an optional + ``"entity_type"`` key is recognised as a special attribute. + + Parameters + ---------- + entities: + Iterable of dicts, each with at minimum ``{"id": ""}``. + """ + for entity in entities: + entity = dict(entity) + entity_id = entity.pop("id") + entity_type = entity.pop("entity_type", "unknown") + self.add_entity(entity_id, entity_type=entity_type, **entity) + + # ------------------------------------------------------------------ + # Edge management + # ------------------------------------------------------------------ + + def add_relationship( + self, + source_id: str, + target_id: str, + relationship_type: str = "owns", + weight: float = 1.0, + **attributes: Any, + ) -> None: + """Add a directed relationship edge between two entities. + + Parameters + ---------- + source_id: + The entity that *owns* or *controls* the target. + target_id: + The entity that is owned or controlled. + relationship_type: + Semantic label: ``"owns"``, ``"controls"``, ``"transacts"``, + ``"directs"``, etc. + weight: + Ownership stake (0.0–1.0) or transaction volume. Defaults to + ``1.0`` (full ownership / single connection). + **attributes: + Extra edge attributes stored verbatim. + """ + self.graph.add_edge( + source_id, + target_id, + relationship_type=relationship_type, + weight=weight, + **attributes, + ) + + def add_relationships( + self, relationships: Iterable[Dict[str, Any]] + ) -> None: + """Bulk-add relationships from an iterable of attribute dicts. + + Each dict must contain ``"source"`` and ``"target"`` keys. + Optional keys: ``"relationship_type"``, ``"weight"``. + + Parameters + ---------- + relationships: + Iterable of dicts describing edges. + """ + for rel in relationships: + rel = dict(rel) + source = rel.pop("source") + target = rel.pop("target") + rel_type = rel.pop("relationship_type", "owns") + weight = rel.pop("weight", 1.0) + self.add_relationship(source, target, rel_type, weight, **rel) + + # ------------------------------------------------------------------ + # Graph-level helpers + # ------------------------------------------------------------------ + + def from_edge_list( + self, + edges: Iterable[Tuple[str, str]], + relationship_type: str = "owns", + weight: float = 1.0, + ) -> None: + """Populate the graph from a bare list of (source, target) tuples. + + Nodes that do not yet exist are created automatically with + ``entity_type="unknown"``. + + Parameters + ---------- + edges: + Iterable of ``(source_id, target_id)`` pairs. + relationship_type: + Default relationship type applied to all edges. + weight: + Default weight applied to all edges. + """ + for source, target in edges: + if source not in self.graph: + self.add_entity(source) + if target not in self.graph: + self.add_entity(target) + self.add_relationship(source, target, relationship_type, weight) + + def get_subgraph(self, node_ids: List[str]) -> nx.DiGraph | nx.Graph: + """Return a node-induced subgraph for the given entity IDs.""" + return self.graph.subgraph(node_ids).copy() + + def ownership_chain(self, entity_id: str) -> List[str]: + """Return all ancestors of *entity_id* in the ownership hierarchy. + + In a directed graph where edges go from owner → owned, ancestors + are the upstream owners reachable from the node via *predecessors*. + + Parameters + ---------- + entity_id: + The entity to trace ownership for. + + Returns + ------- + list of str + Ancestor entity IDs, excluding *entity_id* itself. + """ + if not self._directed: + raise ValueError( + "ownership_chain() is only meaningful on a directed graph." + ) + return [ + n + for n in nx.ancestors(self.graph, entity_id) + if n != entity_id + ] + + def subsidiaries(self, entity_id: str) -> List[str]: + """Return all descendants of *entity_id* (companies it owns). + + Parameters + ---------- + entity_id: + The parent entity. + + Returns + ------- + list of str + Descendant entity IDs. + """ + if not self._directed: + raise ValueError( + "subsidiaries() is only meaningful on a directed graph." + ) + return list(nx.descendants(self.graph, entity_id)) + + def detect_cycles(self) -> List[List[str]]: + """Return all simple cycles in the graph. + + Circular ownership (company A owns B owns C owns A) is a strong + indicator of a shell structure. + + Returns + ------- + list of list of str + Each inner list is one cycle, represented as a sequence of + node IDs. + """ + if self._directed: + return list(nx.simple_cycles(self.graph)) + return [] + + # ------------------------------------------------------------------ + # Statistics + # ------------------------------------------------------------------ + + @property + def node_count(self) -> int: + """Total number of entity nodes.""" + return self.graph.number_of_nodes() + + @property + def edge_count(self) -> int: + """Total number of relationship edges.""" + return self.graph.number_of_edges() + + def summary(self) -> Dict[str, Any]: + """Return a dict of high-level graph statistics.""" + cycles = self.detect_cycles() + return { + "nodes": self.node_count, + "edges": self.edge_count, + "directed": self._directed, + "is_weakly_connected": ( + nx.is_weakly_connected(self.graph) + if self._directed and self.node_count > 0 + else None + ), + "cycle_count": len(cycles), + "cycles": cycles, + } diff --git a/kyb_graph_analytics/shell_company_detector.py b/kyb_graph_analytics/shell_company_detector.py new file mode 100644 index 0000000..ab64ce7 --- /dev/null +++ b/kyb_graph_analytics/shell_company_detector.py @@ -0,0 +1,295 @@ +""" +shell_company_detector.py +-------------------------- +Composite risk scoring for shell company and hidden ownership detection. + +This module combines: + - Graph topology analysis (cycle detection, layer depth) + - PageRank and Betweenness centrality + - Louvain community detection + - Entity resolution (duplicate/alias detection) + +Each entity receives a ``risk_score`` between 0.0 and 1.0 together with +a list of ``flags`` explaining what triggered the score. Scores above +``HIGH_RISK_THRESHOLD`` (0.7) warrant immediate KYB/AML review. + +Risk factors +~~~~~~~~~~~~ ++-------------------------------------+----------+ +| Factor | Weight | ++=====================================+==========+ +| Member of circular ownership cycle | 0.40 | +| Betweenness centrality spike | 0.20 | +| PageRank significantly above mean | 0.15 | +| Many ownership layers (depth ≥ 3) | 0.15 | +| Part of suspicious community | 0.20 | +| Possible duplicate/alias entity | 0.15 | ++-------------------------------------+----------+ + +Scores are capped at 1.0. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import networkx as nx + +from .graph_builder import GraphBuilder +from .centrality import CentralityAnalyzer +from .community_detection import CommunityDetector +from .entity_resolution import EntityResolver + +# Risk thresholds +HIGH_RISK_THRESHOLD = 0.7 +MEDIUM_RISK_THRESHOLD = 0.4 + +# Factor weights (must sum ≤ 1 each, but they stack up to 1.0) +_W_CYCLE = 0.40 +_W_BETWEENNESS = 0.20 +_W_PAGERANK = 0.15 +_W_DEPTH = 0.15 +_W_COMMUNITY = 0.20 +_W_DUPLICATE = 0.15 + + +class ShellCompanyDetector: + """Detect shell companies and hidden ownership in an entity graph. + + Parameters + ---------- + graph_builder: + A :class:`~kyb_graph_analytics.GraphBuilder` instance containing + the populated ownership graph. + pagerank_threshold_multiplier: + Nodes with PageRank > *mean × multiplier* are flagged. + Default ``2.0`` (twice the mean). + betweenness_threshold: + Absolute betweenness centrality score above which a node is + flagged as a structural bridge. Default ``0.1``. + max_community_size: + Upper bound for ``suspicious_communities()`` in community detection. + entity_resolution_threshold: + Similarity threshold forwarded to :class:`~kyb_graph_analytics.EntityResolver`. + random_state: + Seed for Louvain; set for reproducibility. + """ + + def __init__( + self, + graph_builder: GraphBuilder, + pagerank_threshold_multiplier: float = 2.0, + betweenness_threshold: float = 0.1, + max_community_size: int = 50, + entity_resolution_threshold: float = 0.85, + random_state: Optional[int] = 42, + ) -> None: + self.gb = graph_builder + self.graph = graph_builder.graph + self._pr_mult = pagerank_threshold_multiplier + self._bw_thresh = betweenness_threshold + self._max_comm_size = max_community_size + self._er_thresh = entity_resolution_threshold + self._random_state = random_state + + # Sub-analysers (lazy initialised) + self._centrality: Optional[CentralityAnalyzer] = None + self._community: Optional[CommunityDetector] = None + self._resolver: Optional[EntityResolver] = None + + # ------------------------------------------------------------------ + # Lazy accessor properties + # ------------------------------------------------------------------ + + @property + def centrality(self) -> CentralityAnalyzer: + if self._centrality is None: + self._centrality = CentralityAnalyzer(self.graph) + return self._centrality + + @property + def community_detector(self) -> CommunityDetector: + if self._community is None: + self._community = CommunityDetector( + self.graph, random_state=self._random_state + ) + return self._community + + @property + def entity_resolver(self) -> EntityResolver: + if self._resolver is None: + self._resolver = EntityResolver( + self.graph, threshold=self._er_thresh + ) + return self._resolver + + # ------------------------------------------------------------------ + # Pre-computed sets (populated in analyse()) + # ------------------------------------------------------------------ + + def _build_cycle_set(self) -> set: + """Return the set of node IDs participating in at least one cycle.""" + members: set = set() + for cycle in self.gb.detect_cycles(): + members.update(cycle) + return members + + def _build_suspicious_community_set(self) -> set: + """Return the set of node IDs in suspicious communities.""" + members: set = set() + for comm in self.community_detector.suspicious_communities( + max_size=self._max_comm_size + ): + members.update(comm["members"]) + return members + + def _build_duplicate_set(self) -> set: + """Return the set of node IDs flagged as potential duplicates.""" + members: set = set() + for group in self.entity_resolver.duplicate_groups(): + members.update(group) + return members + + def _ownership_depth(self, node_id: str) -> int: + """Return the number of ownership layers above *node_id*.""" + try: + return len(self.gb.ownership_chain(node_id)) + except Exception: + return 0 + + # ------------------------------------------------------------------ + # Core analysis + # ------------------------------------------------------------------ + + def analyse(self) -> List[Dict[str, Any]]: + """Run full shell-company detection and return scored entity records. + + Returns + ------- + list of dicts, one per graph node, with keys: + ``"entity_id"``, ``"entity_type"``, ``"risk_score"``, + ``"risk_level"``, ``"flags"``. + + Sorted by descending ``risk_score``. + """ + if self.graph.number_of_nodes() == 0: + return [] + + # Pre-compute sets and scores + cycle_nodes = self._build_cycle_set() + susp_community_nodes = self._build_suspicious_community_set() + duplicate_nodes = self._build_duplicate_set() + + pr_scores = self.centrality.pagerank() + bw_scores = self.centrality.betweenness_centrality() + + pr_mean = ( + sum(pr_scores.values()) / len(pr_scores) if pr_scores else 0.0 + ) + pr_threshold = pr_mean * self._pr_mult + + results = [] + for node in self.graph.nodes(): + node_data = self.graph.nodes[node] + flags: List[str] = [] + score = 0.0 + + # 1. Circular ownership + if node in cycle_nodes: + flags.append("Participates in circular ownership cycle") + score += _W_CYCLE + + # 2. High betweenness (structural bridge) + bw = bw_scores.get(node, 0.0) + if bw > self._bw_thresh: + flags.append( + f"High betweenness centrality ({bw:.3f} > {self._bw_thresh})" + ) + score += _W_BETWEENNESS + + # 3. Elevated PageRank + pr = pr_scores.get(node, 0.0) + if pr > pr_threshold and pr_threshold > 0: + flags.append( + f"PageRank ({pr:.4f}) exceeds 2× mean ({pr_mean:.4f})" + ) + score += _W_PAGERANK + + # 4. Deep ownership chain + depth = self._ownership_depth(node) + if depth >= 3: + flags.append( + f"Deep ownership chain ({depth} layers above this entity)" + ) + score += _W_DEPTH + + # 5. Suspicious community membership + if node in susp_community_nodes: + flags.append( + "Member of a community with no traceable individual UBO" + ) + score += _W_COMMUNITY + + # 6. Potential duplicate / alias + if node in duplicate_nodes: + flags.append( + "Possible duplicate or alias of another entity" + ) + score += _W_DUPLICATE + + # Cap at 1.0 + score = min(score, 1.0) + + risk_level = ( + "high" + if score >= HIGH_RISK_THRESHOLD + else ("medium" if score >= MEDIUM_RISK_THRESHOLD else "low") + ) + + results.append( + { + "entity_id": node, + "entity_type": node_data.get("entity_type", "unknown"), + "risk_score": round(score, 4), + "risk_level": risk_level, + "flags": flags, + } + ) + + return sorted(results, key=lambda r: r["risk_score"], reverse=True) + + # ------------------------------------------------------------------ + # Convenience summaries + # ------------------------------------------------------------------ + + def high_risk_entities(self) -> List[Dict[str, Any]]: + """Return only entities classified as high risk (score ≥ 0.7).""" + return [r for r in self.analyse() if r["risk_level"] == "high"] + + def summary_report(self) -> Dict[str, Any]: + """Return an aggregate summary of the analysis. + + Returns + ------- + dict with keys: + ``"total_entities"``, ``"high_risk"``, ``"medium_risk"``, + ``"low_risk"``, ``"cycle_count"``, ``"modularity"``, + ``"duplicate_groups"``, ``"top_risks"``. + """ + results = self.analyse() + graph_summary = self.gb.summary() + + high = [r for r in results if r["risk_level"] == "high"] + medium = [r for r in results if r["risk_level"] == "medium"] + low = [r for r in results if r["risk_level"] == "low"] + + return { + "total_entities": len(results), + "high_risk": len(high), + "medium_risk": len(medium), + "low_risk": len(low), + "cycle_count": graph_summary["cycle_count"], + "modularity": round(self.community_detector.modularity(), 4), + "duplicate_groups": len(self.entity_resolver.duplicate_groups()), + "top_risks": results[:5], + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00a5d98 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +networkx>=3.0 +python-louvain>=0.16 +numpy>=1.24 +scipy>=1.10 # required by networkx>=3.0 for pagerank computation diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1a997a0 --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +setup( + name="kyb-graph-analytics", + version="0.1.0", + description="Graph-based fraud detection for KYB/AML: shell company and hidden ownership detection", + packages=find_packages(exclude=["tests*"]), + python_requires=">=3.8", + install_requires=[ + "networkx>=3.0", + "python-louvain>=0.16", + "numpy>=1.24", + "scipy>=1.10", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e027091b0d9363fefa4375e27b1fa563b5cf4d79 GIT binary patch literal 172 zcmX@j%ge<81T$67W`gL)AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd<*lEQpPQ;*RGOEU zTBKi|UzDw%U74htUX)mnp_`bOm{VDjnOuxjtR%I#q*y;bJ~J<~BtBlRpz;@oO>TZl aX-=wL5i8JaMj$Q*F+MUgGBOr116csp11z=x literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0678ff8c13fe4ae61e6e9bfee381cd600e90b57d GIT binary patch literal 25704 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 EZ-oo0(EtDd literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3678054bf75fae10c405a987bdd093f4b641104 GIT binary patch literal 28959 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..894db1b33ad8e6fad5f58fa5af61c12b1736dc9a GIT binary patch literal 23962 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91987bcbd2dd9ef23eeb17436626f61db1866d8e GIT binary patch literal 23023 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

)pM-jIY)#XS~zq722}GR!CE@ z=bu(K<1uP^um^Qae;K}IA;y!Pnn~k>4)z%$66@@+i-|8c<%|4a)kag zHem3DFtih+_@iIYi}^>t;I4<`jeWC=w)_so@s%%&|E-6f#32AVxA}`?!Cn%4(>4^{PjDT~2CdLI!BRq4-&cXo_X0k=ct%xNEe>|K-q*jxEO)yFPu literal 0 HcmV?d00001 diff --git a/tests/test_centrality.py b/tests/test_centrality.py new file mode 100644 index 0000000..51e8509 --- /dev/null +++ b/tests/test_centrality.py @@ -0,0 +1,152 @@ +"""Tests for CentralityAnalyzer.""" + +import pytest +import networkx as nx + +from kyb_graph_analytics.graph_builder import GraphBuilder +from kyb_graph_analytics.centrality import CentralityAnalyzer + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def star_graph(): + """Hub-and-spoke: hub -> s1, s2, s3, s4. Hub should have high PR.""" + gb = GraphBuilder() + gb.add_entity("hub", entity_type="company") + for i in range(1, 5): + gb.add_entity(f"s{i}", entity_type="company") + gb.add_relationship("hub", f"s{i}") + return gb + + +@pytest.fixture +def chain_graph(): + """Linear chain: a -> b -> c -> d -> e. Middle nodes have high BC.""" + gb = GraphBuilder() + nodes = list("abcde") + for n in nodes: + gb.add_entity(n, entity_type="company") + for src, tgt in zip(nodes, nodes[1:]): + gb.add_relationship(src, tgt) + return gb + + +@pytest.fixture +def empty_graph(): + return GraphBuilder() + + +# --------------------------------------------------------------------------- +# PageRank +# --------------------------------------------------------------------------- + +class TestPageRank: + def test_returns_all_nodes(self, star_graph): + ca = CentralityAnalyzer(star_graph.graph) + pr = ca.pagerank() + assert set(pr.keys()) == set(star_graph.graph.nodes()) + + def test_scores_sum_to_one(self, star_graph): + ca = CentralityAnalyzer(star_graph.graph) + pr = ca.pagerank() + assert abs(sum(pr.values()) - 1.0) < 1e-4 + + def test_spokes_have_higher_pr_than_hub_in_directed_star(self, star_graph): + # In a directed star (hub -> s1..s4), spokes *receive* inbound links so + # they accumulate more PageRank than the hub (which has no inbound edges). + ca = CentralityAnalyzer(star_graph.graph) + pr = ca.pagerank() + spoke_avg = sum(pr[f"s{i}"] for i in range(1, 5)) / 4 + assert spoke_avg > pr["hub"] + + def test_empty_graph_returns_empty(self, empty_graph): + ca = CentralityAnalyzer(empty_graph.graph) + assert ca.pagerank() == {} + + +# --------------------------------------------------------------------------- +# Betweenness Centrality +# --------------------------------------------------------------------------- + +class TestBetweennessCentrality: + def test_returns_all_nodes(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + bc = ca.betweenness_centrality() + assert set(bc.keys()) == set(chain_graph.graph.nodes()) + + def test_middle_nodes_have_higher_bc(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + bc = ca.betweenness_centrality() + # In a -> b -> c -> d -> e, 'c' is the true midpoint + assert bc["c"] >= bc["a"] + assert bc["c"] >= bc["e"] + + def test_all_scores_in_range(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + bc = ca.betweenness_centrality() + for score in bc.values(): + assert 0.0 <= score <= 1.0 + + def test_empty_graph_returns_empty(self, empty_graph): + ca = CentralityAnalyzer(empty_graph.graph) + assert ca.betweenness_centrality() == {} + + +# --------------------------------------------------------------------------- +# Degree Centrality +# --------------------------------------------------------------------------- + +class TestDegreeCentrality: + def test_in_degree_nonempty(self, star_graph): + ca = CentralityAnalyzer(star_graph.graph) + in_deg = ca.in_degree_centrality() + assert set(in_deg.keys()) == set(star_graph.graph.nodes()) + # Spokes receive edges, hub does not + spoke_in = in_deg["s1"] + hub_in = in_deg["hub"] + assert spoke_in > hub_in + + def test_out_degree_hub_highest(self, star_graph): + ca = CentralityAnalyzer(star_graph.graph) + out_deg = ca.out_degree_centrality() + assert out_deg["hub"] == max(out_deg.values()) + + def test_empty_graph_returns_empty(self, empty_graph): + ca = CentralityAnalyzer(empty_graph.graph) + assert ca.in_degree_centrality() == {} + assert ca.out_degree_centrality() == {} + + +# --------------------------------------------------------------------------- +# Combined scores +# --------------------------------------------------------------------------- + +class TestAllCentralityScores: + def test_combined_keys(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + all_scores = ca.all_centrality_scores() + for node in chain_graph.graph.nodes(): + assert node in all_scores + assert set(all_scores[node].keys()) == { + "pagerank", "betweenness", "in_degree", "out_degree" + } + + def test_top_nodes(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + top = ca.top_nodes(measure="betweenness", n=3) + assert len(top) == 3 + # Results should be sorted descending + assert top[0][1] >= top[1][1] >= top[2][1] + + def test_top_nodes_invalid_measure(self, chain_graph): + ca = CentralityAnalyzer(chain_graph.graph) + with pytest.raises(ValueError, match="Unknown measure"): + ca.top_nodes(measure="invalid") + + def test_top_nodes_capped_at_n(self, star_graph): + ca = CentralityAnalyzer(star_graph.graph) + top = ca.top_nodes(measure="pagerank", n=2) + assert len(top) == 2 diff --git a/tests/test_community_detection.py b/tests/test_community_detection.py new file mode 100644 index 0000000..ef4b7b3 --- /dev/null +++ b/tests/test_community_detection.py @@ -0,0 +1,161 @@ +"""Tests for CommunityDetector.""" + +import pytest +import networkx as nx + +from kyb_graph_analytics.graph_builder import GraphBuilder +from kyb_graph_analytics.community_detection import CommunityDetector + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def two_cluster_graph(): + """Two clearly separated cliques with a single bridging edge. + + Cluster 1: c1a, c1b, c1c (all companies) + Cluster 2: c2a, c2b, c2c (all companies) + Bridge: c1c -> c2a + """ + gb = GraphBuilder() + for node in ["c1a", "c1b", "c1c"]: + gb.add_entity(node, entity_type="company") + for node in ["c2a", "c2b", "c2c"]: + gb.add_entity(node, entity_type="company") + # Dense intra-cluster edges + gb.add_relationship("c1a", "c1b") + gb.add_relationship("c1b", "c1c") + gb.add_relationship("c1a", "c1c") + gb.add_relationship("c2a", "c2b") + gb.add_relationship("c2b", "c2c") + gb.add_relationship("c2a", "c2c") + # Bridge + gb.add_relationship("c1c", "c2a") + return gb.graph + + +@pytest.fixture +def mixed_cluster_graph(): + """A community with individuals and companies (not suspicious) plus + one company-only community (suspicious).""" + gb = GraphBuilder() + # Mixed community + gb.add_entity("alice", entity_type="individual", name="Alice") + gb.add_entity("alpha_llc", entity_type="company", name="Alpha LLC") + gb.add_relationship("alice", "alpha_llc") + # Company-only community + gb.add_entity("shell1", entity_type="company", name="Shell One") + gb.add_entity("shell2", entity_type="company", name="Shell Two") + gb.add_relationship("shell1", "shell2") + return gb.graph + + +@pytest.fixture +def empty_graph(): + return GraphBuilder().graph + + +# --------------------------------------------------------------------------- +# Partition / detect +# --------------------------------------------------------------------------- + +class TestDetect: + def test_returns_partition_dict(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + partition = cd.detect() + assert isinstance(partition, dict) + assert set(partition.keys()) == set(two_cluster_graph.nodes()) + + def test_all_values_are_ints(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + partition = cd.detect() + assert all(isinstance(v, int) for v in partition.values()) + + def test_empty_graph_returns_empty(self, empty_graph): + cd = CommunityDetector(empty_graph) + assert cd.detect() == {} + + def test_partition_cached_after_detect(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + p1 = cd.detect() + assert cd.partition is p1 + + +# --------------------------------------------------------------------------- +# Communities grouping +# --------------------------------------------------------------------------- + +class TestCommunities: + def test_returns_dict_of_lists(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + comms = cd.communities() + assert isinstance(comms, dict) + # All members are graph nodes + all_members = {n for members in comms.values() for n in members} + assert all_members == set(two_cluster_graph.nodes()) + + def test_community_of_known_node(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + cd.detect() + label = cd.community_of("c1a") + assert isinstance(label, int) + + def test_community_of_before_detect_returns_none(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + assert cd.community_of("c1a") is None + + def test_community_of_unknown_node_returns_none(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + cd.detect() + assert cd.community_of("nonexistent") is None + + +# --------------------------------------------------------------------------- +# Modularity +# --------------------------------------------------------------------------- + +class TestModularity: + def test_modularity_is_float(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + mod = cd.modularity() + assert isinstance(mod, float) + + def test_modularity_in_valid_range(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + mod = cd.modularity() + # Modularity for a non-degenerate partition is typically in (-1, 1) + assert -1.0 <= mod <= 1.0 + + def test_empty_graph_modularity_zero(self, empty_graph): + cd = CommunityDetector(empty_graph) + assert cd.modularity() == 0.0 + + +# --------------------------------------------------------------------------- +# Suspicious communities +# --------------------------------------------------------------------------- + +class TestSuspiciousCommunities: + def test_flags_company_only_communities(self, mixed_cluster_graph): + cd = CommunityDetector(mixed_cluster_graph) + suspicious = cd.suspicious_communities() + # The shell1/shell2 community should be flagged + flagged_members = {m for c in suspicious for m in c["members"]} + assert "shell1" in flagged_members or "shell2" in flagged_members + + def test_result_has_expected_keys(self, mixed_cluster_graph): + cd = CommunityDetector(mixed_cluster_graph) + suspicious = cd.suspicious_communities() + for item in suspicious: + assert "community_id" in item + assert "members" in item + assert "size" in item + assert "reason" in item + + def test_min_size_filter(self, two_cluster_graph): + cd = CommunityDetector(two_cluster_graph) + # With min_size larger than total nodes, nothing is flagged + suspicious = cd.suspicious_communities(min_size=100) + assert suspicious == [] diff --git a/tests/test_entity_resolution.py b/tests/test_entity_resolution.py new file mode 100644 index 0000000..cabeb43 --- /dev/null +++ b/tests/test_entity_resolution.py @@ -0,0 +1,186 @@ +"""Tests for EntityResolver.""" + +import pytest +import networkx as nx + +from kyb_graph_analytics.entity_resolution import ( + EntityResolver, + _normalise, + _token_sort_ratio, + _lcs_length, +) + + +# --------------------------------------------------------------------------- +# String utility tests +# --------------------------------------------------------------------------- + +class TestNormalise: + def test_lowercase(self): + assert _normalise("HELLO") == "hello" + + def test_strips_accents(self): + assert _normalise("café") == "cafe" + + def test_collapses_whitespace(self): + assert _normalise(" a b ") == "a b" + + def test_removes_punctuation(self): + assert _normalise("Ltd.") == "ltd" + + +class TestTokenSortRatio: + def test_identical_strings(self): + assert _token_sort_ratio("Alpha Corp", "Alpha Corp") == 1.0 + + def test_order_invariant(self): + s1 = _token_sort_ratio("Corp Alpha", "Alpha Corp") + s2 = _token_sort_ratio("Alpha Corp", "Corp Alpha") + assert s1 == s2 + + def test_similar_strings(self): + score = _token_sort_ratio("Alpha Holdings Ltd", "Alpha Holdings Limited") + assert score > 0.8 + + def test_completely_different_strings(self): + score = _token_sort_ratio("Alpha Corp", "XYZ Ventures") + assert score < 0.5 + + def test_both_empty(self): + assert _token_sort_ratio("", "") == 1.0 + + def test_one_empty(self): + assert _token_sort_ratio("Alpha", "") == 0.0 + + +class TestLcsLength: + def test_identical(self): + assert _lcs_length("abc", "abc") == 3 + + def test_no_common(self): + assert _lcs_length("abc", "xyz") == 0 + + def test_partial(self): + assert _lcs_length("abcde", "ace") == 3 + + +# --------------------------------------------------------------------------- +# EntityResolver fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def graph_with_duplicates(): + """Graph containing obvious name duplicates.""" + g = nx.DiGraph() + g.add_node("e1", entity_type="company", name="Alpha Holdings Ltd") + g.add_node("e2", entity_type="company", name="Alpha Holdings Limited") + g.add_node("e3", entity_type="individual", name="John Smith") + g.add_node("e4", entity_type="individual", name="Jon Smith") + g.add_node("e5", entity_type="company", name="Completely Different Corp") + g.add_edge("e3", "e1") + g.add_edge("e4", "e2") + return g + + +@pytest.fixture +def graph_no_duplicates(): + g = nx.DiGraph() + g.add_node("a", entity_type="company", name="Alpha Corp") + g.add_node("b", entity_type="individual", name="Bob Jones") + g.add_node("c", entity_type="company", name="Zeta Industries") + return g + + +# --------------------------------------------------------------------------- +# find_duplicates +# --------------------------------------------------------------------------- + +class TestFindDuplicates: + def test_detects_near_identical_names(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.80) + dupes = er.find_duplicates() + pairs = {(a, b) for a, b, _ in dupes} + assert ("e1", "e2") in pairs or ("e2", "e1") in pairs + + def test_no_false_positives_on_distinct_entities(self, graph_no_duplicates): + er = EntityResolver(graph_no_duplicates, threshold=0.85) + dupes = er.find_duplicates() + assert dupes == [] + + def test_scores_sorted_descending(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.70) + dupes = er.find_duplicates() + if len(dupes) > 1: + for i in range(len(dupes) - 1): + assert dupes[i][2] >= dupes[i + 1][2] + + def test_invalid_threshold_raises(self): + g = nx.DiGraph() + with pytest.raises(ValueError, match="threshold"): + EntityResolver(g, threshold=1.5) + + +# --------------------------------------------------------------------------- +# duplicate_groups +# --------------------------------------------------------------------------- + +class TestDuplicateGroups: + def test_groups_are_lists(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.80) + groups = er.duplicate_groups() + assert isinstance(groups, list) + for g in groups: + assert isinstance(g, list) + assert len(g) >= 2 + + def test_no_groups_on_distinct_graph(self, graph_no_duplicates): + er = EntityResolver(graph_no_duplicates, threshold=0.85) + groups = er.duplicate_groups() + assert groups == [] + + +# --------------------------------------------------------------------------- +# merge_duplicates +# --------------------------------------------------------------------------- + +class TestMergeDuplicates: + def test_merged_graph_has_fewer_nodes(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.80) + merged = er.merge_duplicates() + assert merged.number_of_nodes() < graph_with_duplicates.number_of_nodes() + + def test_no_self_loops_after_merge(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.80) + merged = er.merge_duplicates() + assert list(nx.selfloop_edges(merged)) == [] + + def test_merge_with_explicit_groups(self): + g = nx.DiGraph() + g.add_node("x", name="X Corp") + g.add_node("y", name="Y Corp") + g.add_node("z", name="Z Corp") + g.add_edge("x", "z") + g.add_edge("y", "z") + er = EntityResolver(g, threshold=0.99) + # Force merge x and y + merged = er.merge_duplicates(groups=[["x", "y"]]) + # z should still exist; x and y merged to canonical + assert "z" in merged.nodes() + + +# --------------------------------------------------------------------------- +# resolution_report +# --------------------------------------------------------------------------- + +class TestResolutionReport: + def test_report_has_expected_keys(self, graph_with_duplicates): + er = EntityResolver(graph_with_duplicates, threshold=0.80) + report = er.resolution_report() + for item in report: + assert "canonical" in item + assert "aliases" in item + assert "similarity_pairs" in item + + def test_report_empty_on_no_duplicates(self, graph_no_duplicates): + er = EntityResolver(graph_no_duplicates, threshold=0.85) + assert er.resolution_report() == [] diff --git a/tests/test_graph_builder.py b/tests/test_graph_builder.py new file mode 100644 index 0000000..7a91dfa --- /dev/null +++ b/tests/test_graph_builder.py @@ -0,0 +1,163 @@ +"""Tests for GraphBuilder.""" + +import pytest +import networkx as nx + +from kyb_graph_analytics.graph_builder import GraphBuilder + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def simple_graph(): + """A small ownership graph: Alice -> HoldCo -> TargetCo.""" + gb = GraphBuilder() + gb.add_entity("alice", entity_type="individual", name="Alice Smith") + gb.add_entity("holdco", entity_type="company", name="HoldCo Ltd") + gb.add_entity("targetco", entity_type="company", name="Target Co Ltd") + gb.add_relationship("alice", "holdco", relationship_type="owns", weight=1.0) + gb.add_relationship("holdco", "targetco", relationship_type="owns", weight=0.75) + return gb + + +@pytest.fixture +def cyclic_graph(): + """A graph with a circular ownership cycle: A -> B -> C -> A.""" + gb = GraphBuilder() + for node in ["A", "B", "C"]: + gb.add_entity(node, entity_type="company") + gb.add_relationship("A", "B") + gb.add_relationship("B", "C") + gb.add_relationship("C", "A") + return gb + + +# --------------------------------------------------------------------------- +# Node tests +# --------------------------------------------------------------------------- + +class TestAddEntity: + def test_single_node_added(self, simple_graph): + assert "alice" in simple_graph.graph + + def test_node_attributes(self, simple_graph): + data = simple_graph.graph.nodes["alice"] + assert data["entity_type"] == "individual" + assert data["name"] == "Alice Smith" + + def test_bulk_add_entities(self): + gb = GraphBuilder() + entities = [ + {"id": "c1", "entity_type": "company", "name": "Corp One"}, + {"id": "c2", "entity_type": "company", "name": "Corp Two"}, + ] + gb.add_entities(entities) + assert gb.node_count == 2 + assert "c1" in gb.graph + assert gb.graph.nodes["c2"]["name"] == "Corp Two" + + def test_default_entity_type(self): + gb = GraphBuilder() + gb.add_entity("x") + assert gb.graph.nodes["x"]["entity_type"] == "unknown" + + +# --------------------------------------------------------------------------- +# Edge tests +# --------------------------------------------------------------------------- + +class TestAddRelationship: + def test_edge_exists(self, simple_graph): + assert simple_graph.graph.has_edge("alice", "holdco") + assert simple_graph.graph.has_edge("holdco", "targetco") + + def test_edge_attributes(self, simple_graph): + edge_data = simple_graph.graph["alice"]["holdco"] + assert edge_data["relationship_type"] == "owns" + assert edge_data["weight"] == 1.0 + + def test_bulk_add_relationships(self): + gb = GraphBuilder() + gb.add_entity("a") + gb.add_entity("b") + gb.add_entity("c") + gb.add_relationships([ + {"source": "a", "target": "b", "weight": 0.5}, + {"source": "b", "target": "c", "weight": 0.3}, + ]) + assert gb.edge_count == 2 + + def test_from_edge_list(self): + gb = GraphBuilder() + gb.from_edge_list([("p1", "p2"), ("p2", "p3")]) + assert gb.node_count == 3 + assert gb.edge_count == 2 + + +# --------------------------------------------------------------------------- +# Topology helpers +# --------------------------------------------------------------------------- + +class TestTopologyHelpers: + def test_ownership_chain(self, simple_graph): + chain = simple_graph.ownership_chain("targetco") + assert "alice" in chain + assert "holdco" in chain + assert "targetco" not in chain + + def test_subsidiaries(self, simple_graph): + subs = simple_graph.subsidiaries("alice") + assert "holdco" in subs + assert "targetco" in subs + + def test_detect_cycles_none(self, simple_graph): + cycles = simple_graph.detect_cycles() + assert cycles == [] + + def test_detect_cycles_present(self, cyclic_graph): + cycles = cyclic_graph.detect_cycles() + assert len(cycles) >= 1 + # All three nodes should appear in cycles + cycle_nodes = {n for c in cycles for n in c} + assert {"A", "B", "C"}.issubset(cycle_nodes) + + def test_ownership_chain_undirected_raises(self): + gb = GraphBuilder(directed=False) + gb.add_entity("x") + with pytest.raises(ValueError, match="directed"): + gb.ownership_chain("x") + + def test_subsidiaries_undirected_raises(self): + gb = GraphBuilder(directed=False) + gb.add_entity("x") + with pytest.raises(ValueError, match="directed"): + gb.subsidiaries("x") + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +class TestSummary: + def test_summary_keys(self, simple_graph): + s = simple_graph.summary() + for key in ("nodes", "edges", "directed", "cycle_count", "cycles"): + assert key in s + + def test_summary_values(self, simple_graph): + s = simple_graph.summary() + assert s["nodes"] == 3 + assert s["edges"] == 2 + assert s["cycle_count"] == 0 + assert s["directed"] is True + + def test_summary_cyclic(self, cyclic_graph): + s = cyclic_graph.summary() + assert s["cycle_count"] >= 1 + + def test_subgraph(self, simple_graph): + sg = simple_graph.get_subgraph(["alice", "holdco"]) + assert sg.number_of_nodes() == 2 + assert sg.has_edge("alice", "holdco") diff --git a/tests/test_shell_company_detector.py b/tests/test_shell_company_detector.py new file mode 100644 index 0000000..14536f0 --- /dev/null +++ b/tests/test_shell_company_detector.py @@ -0,0 +1,183 @@ +"""Tests for ShellCompanyDetector.""" + +import pytest + +from kyb_graph_analytics.graph_builder import GraphBuilder +from kyb_graph_analytics.shell_company_detector import ( + ShellCompanyDetector, + HIGH_RISK_THRESHOLD, + MEDIUM_RISK_THRESHOLD, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def clean_graph(): + """A simple, transparent ownership structure with a real UBO.""" + gb = GraphBuilder() + gb.add_entity("alice", entity_type="individual", name="Alice Smith") + gb.add_entity("acme", entity_type="company", name="Acme Ltd") + gb.add_relationship("alice", "acme", weight=1.0) + return gb + + +@pytest.fixture +def shell_graph(): + """A graph with multiple shell-company indicators: + - Circular ownership: ShellA -> ShellB -> ShellC -> ShellA + - All companies, no individual UBO + - Deep chain for TargetCo + """ + gb = GraphBuilder() + for node in ["shell_a", "shell_b", "shell_c"]: + gb.add_entity(node, entity_type="company", name=node) + gb.add_entity("target_co", entity_type="company", name="Target Co") + + # Circular ownership cycle + gb.add_relationship("shell_a", "shell_b") + gb.add_relationship("shell_b", "shell_c") + gb.add_relationship("shell_c", "shell_a") + + # Deep chain to target + gb.add_relationship("shell_a", "target_co") + return gb + + +@pytest.fixture +def empty_graph(): + return GraphBuilder() + + +# --------------------------------------------------------------------------- +# analyse() +# --------------------------------------------------------------------------- + +class TestAnalyse: + def test_returns_list(self, clean_graph): + det = ShellCompanyDetector(clean_graph) + results = det.analyse() + assert isinstance(results, list) + + def test_all_entities_present(self, clean_graph): + det = ShellCompanyDetector(clean_graph) + results = det.analyse() + ids = {r["entity_id"] for r in results} + assert ids == set(clean_graph.graph.nodes()) + + def test_result_keys(self, clean_graph): + det = ShellCompanyDetector(clean_graph) + for result in det.analyse(): + assert "entity_id" in result + assert "entity_type" in result + assert "risk_score" in result + assert "risk_level" in result + assert "flags" in result + + def test_sorted_by_risk_score_descending(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + results = det.analyse() + scores = [r["risk_score"] for r in results] + assert scores == sorted(scores, reverse=True) + + def test_risk_score_in_range(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + for r in det.analyse(): + assert 0.0 <= r["risk_score"] <= 1.0 + + def test_risk_level_matches_score(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + for r in det.analyse(): + if r["risk_score"] >= HIGH_RISK_THRESHOLD: + assert r["risk_level"] == "high" + elif r["risk_score"] >= MEDIUM_RISK_THRESHOLD: + assert r["risk_level"] == "medium" + else: + assert r["risk_level"] == "low" + + def test_empty_graph_returns_empty(self, empty_graph): + det = ShellCompanyDetector(empty_graph) + assert det.analyse() == [] + + def test_cycle_nodes_are_flagged(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + results = {r["entity_id"]: r for r in det.analyse()} + # All nodes in the cycle should carry the cycle flag + for node in ["shell_a", "shell_b", "shell_c"]: + flags = results[node]["flags"] + cycle_flags = [f for f in flags if "cycle" in f.lower()] + assert len(cycle_flags) > 0 + + def test_clean_graph_lower_risk_than_shell_graph( + self, clean_graph, shell_graph + ): + clean_det = ShellCompanyDetector(clean_graph) + shell_det = ShellCompanyDetector(shell_graph) + clean_max = max(r["risk_score"] for r in clean_det.analyse()) + shell_max = max(r["risk_score"] for r in shell_det.analyse()) + assert shell_max > clean_max + + +# --------------------------------------------------------------------------- +# high_risk_entities() +# --------------------------------------------------------------------------- + +class TestHighRiskEntities: + def test_all_high_risk(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + high = det.high_risk_entities() + for r in high: + assert r["risk_score"] >= HIGH_RISK_THRESHOLD + + def test_clean_graph_no_high_risk(self, clean_graph): + det = ShellCompanyDetector(clean_graph) + high = det.high_risk_entities() + # A simple two-node clean graph should produce no high-risk entities + assert all(r["risk_score"] < HIGH_RISK_THRESHOLD for r in high) + + +# --------------------------------------------------------------------------- +# summary_report() +# --------------------------------------------------------------------------- + +class TestSummaryReport: + def test_summary_keys(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + summary = det.summary_report() + for key in ( + "total_entities", + "high_risk", + "medium_risk", + "low_risk", + "cycle_count", + "modularity", + "duplicate_groups", + "top_risks", + ): + assert key in summary + + def test_counts_sum_to_total(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + summary = det.summary_report() + assert ( + summary["high_risk"] + summary["medium_risk"] + summary["low_risk"] + == summary["total_entities"] + ) + + def test_cycle_count_in_shell_graph(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + summary = det.summary_report() + assert summary["cycle_count"] >= 1 + + def test_top_risks_length(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + summary = det.summary_report() + # top_risks contains at most 5 entries + assert len(summary["top_risks"]) <= 5 + + def test_modularity_is_numeric(self, shell_graph): + det = ShellCompanyDetector(shell_graph) + summary = det.summary_report() + assert isinstance(summary["modularity"], float) From 616ddbaeb01600e71c8016ba3b2978176f3e3822 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:08:57 +0000 Subject: [PATCH 3/3] Add .gitignore and clean build artifacts from repo Agent-Logs-Url: https://github.com/Bitu-Singh-Rathoud/kyb-graph-analytics/sessions/11474446-c06f-4d54-a3d0-acc52c4adc89 Co-authored-by: Bitu-Singh-Rathoud <247644259+Bitu-Singh-Rathoud@users.noreply.github.com> --- .gitignore | 25 ++++++++++++++++++ kyb_graph_analytics.egg-info/PKG-INFO | 5 ---- kyb_graph_analytics.egg-info/SOURCES.txt | 18 ------------- .../dependency_links.txt | 1 - kyb_graph_analytics.egg-info/requires.txt | 3 --- kyb_graph_analytics.egg-info/top_level.txt | 1 - .../__pycache__/__init__.cpython-312.pyc | Bin 1076 -> 0 bytes .../__pycache__/centrality.cpython-312.pyc | Bin 7778 -> 0 bytes .../community_detection.cpython-312.pyc | Bin 8997 -> 0 bytes .../entity_resolution.cpython-312.pyc | Bin 11056 -> 0 bytes .../__pycache__/graph_builder.cpython-312.pyc | Bin 10110 -> 0 bytes .../shell_company_detector.cpython-312.pyc | Bin 11932 -> 0 bytes tests/__pycache__/__init__.cpython-312.pyc | Bin 172 -> 0 bytes ...st_centrality.cpython-312-pytest-9.0.2.pyc | Bin 25704 -> 0 bytes ...ity_detection.cpython-312-pytest-9.0.2.pyc | Bin 22968 -> 0 bytes ...ty_resolution.cpython-312-pytest-9.0.2.pyc | Bin 28959 -> 0 bytes ...graph_builder.cpython-312-pytest-9.0.2.pyc | Bin 23962 -> 0 bytes ...pany_detector.cpython-312-pytest-9.0.2.pyc | Bin 23023 -> 0 bytes 18 files changed, 25 insertions(+), 28 deletions(-) create mode 100644 .gitignore delete mode 100644 kyb_graph_analytics.egg-info/PKG-INFO delete mode 100644 kyb_graph_analytics.egg-info/SOURCES.txt delete mode 100644 kyb_graph_analytics.egg-info/dependency_links.txt delete mode 100644 kyb_graph_analytics.egg-info/requires.txt delete mode 100644 kyb_graph_analytics.egg-info/top_level.txt delete mode 100644 kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc delete mode 100644 kyb_graph_analytics/__pycache__/centrality.cpython-312.pyc delete mode 100644 kyb_graph_analytics/__pycache__/community_detection.cpython-312.pyc delete mode 100644 kyb_graph_analytics/__pycache__/entity_resolution.cpython-312.pyc delete mode 100644 kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc delete mode 100644 kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc delete mode 100644 tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_community_detection.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc delete mode 100644 tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c65beb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Distribution / packaging +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# Pytest +.pytest_cache/ +.coverage +htmlcov/ + +# Editor artifacts +.idea/ +.vscode/ +*.swp diff --git a/kyb_graph_analytics.egg-info/PKG-INFO b/kyb_graph_analytics.egg-info/PKG-INFO deleted file mode 100644 index eb3b479..0000000 --- a/kyb_graph_analytics.egg-info/PKG-INFO +++ /dev/null @@ -1,5 +0,0 @@ -Metadata-Version: 2.1 -Name: kyb-graph-analytics -Version: 0.1.0 -Summary: Graph-based fraud detection for KYB/AML: shell company and hidden ownership detection -Requires-Python: >=3.8 diff --git a/kyb_graph_analytics.egg-info/SOURCES.txt b/kyb_graph_analytics.egg-info/SOURCES.txt deleted file mode 100644 index 4691c25..0000000 --- a/kyb_graph_analytics.egg-info/SOURCES.txt +++ /dev/null @@ -1,18 +0,0 @@ -README.md -setup.py -kyb_graph_analytics/__init__.py -kyb_graph_analytics/centrality.py -kyb_graph_analytics/community_detection.py -kyb_graph_analytics/entity_resolution.py -kyb_graph_analytics/graph_builder.py -kyb_graph_analytics/shell_company_detector.py -kyb_graph_analytics.egg-info/PKG-INFO -kyb_graph_analytics.egg-info/SOURCES.txt -kyb_graph_analytics.egg-info/dependency_links.txt -kyb_graph_analytics.egg-info/requires.txt -kyb_graph_analytics.egg-info/top_level.txt -tests/test_centrality.py -tests/test_community_detection.py -tests/test_entity_resolution.py -tests/test_graph_builder.py -tests/test_shell_company_detector.py \ No newline at end of file diff --git a/kyb_graph_analytics.egg-info/dependency_links.txt b/kyb_graph_analytics.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/kyb_graph_analytics.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/kyb_graph_analytics.egg-info/requires.txt b/kyb_graph_analytics.egg-info/requires.txt deleted file mode 100644 index cde698a..0000000 --- a/kyb_graph_analytics.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -networkx>=3.0 -numpy>=1.24 -python-louvain>=0.16 diff --git a/kyb_graph_analytics.egg-info/top_level.txt b/kyb_graph_analytics.egg-info/top_level.txt deleted file mode 100644 index c81819c..0000000 --- a/kyb_graph_analytics.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -kyb_graph_analytics diff --git a/kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc b/kyb_graph_analytics/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e7320986e30cdeea052ab1b77c63825c13448a36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1076 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 diff --git a/kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc b/kyb_graph_analytics/__pycache__/graph_builder.cpython-312.pyc deleted file mode 100644 index 8c3ea8c53be6a34e213bfe905190a3a5a0bd61f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10110 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 Smr~z<$171ebz7p8Q~4jQbH`Kw diff --git a/kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc b/kyb_graph_analytics/__pycache__/shell_company_detector.cpython-312.pyc deleted file mode 100644 index 8f7e5803a26ee60f993f9f1726ae488857b7ca77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11932 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 LTzX+n_VB*|6nDN3 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e027091b0d9363fefa4375e27b1fa563b5cf4d79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 172 zcmX@j%ge<81T$67W`gL)AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd<*lEQpPQ;*RGOEU zTBKi|UzDw%U74htUX)mnp_`bOm{VDjnOuxjtR%I#q*y;bJ~J<~BtBlRpz;@oO>TZl aX-=wL5i8JaMj$Q*F+MUgGBOr116csp11z=x diff --git a/tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_centrality.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 0678ff8c13fe4ae61e6e9bfee381cd600e90b57d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25704 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 EZ-oo0(EtDd diff --git a/tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_entity_resolution.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index d3678054bf75fae10c405a987bdd093f4b641104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28959 zcmeHwdvF^^dglzD0}v!gk$P)oNR}u;7Aa8@B};2b)>>NcDqcB~?LC`W2Z1mk2?+$a zGoU2GV68Um)Y7&RaUYeXFTM+M<;sy~Ct=lH)oop+F5V=SXC(y^q^V4l%H~noxbjCV zF^LjYC6(XTJ<~G)hYvsYQMDR^-+bNE-P7pl{@y+K{pJcR+lJqx}unwPz zENqn}>06R2^+>9$da`nlhn>9xzLZCn2K+q%nO_O^1lbj(Cxo*%8y;xrX&8v~Lb;ENXuR_E-q8h%-N%S`sP zE>WhzQKpTx9YNcTqV2UTuL*fkk++WJH6yP@?2>(pu+x)VXtUpV3r%!3ZaEd2Jw>&FE>PB~i#c!eKt?+QiK&m((0q;tPv5H z1+utCPDX$1Z{bqM9)T6gd8}TnP|j;WSOZ7n zI%OW646zzU`0>Hv0_I;LsbQk0lG$uROAl(P{J>yROB)efPFx(yWDA*`ZUnMLL|-<4F_}%|k^^ZYl+csCX==e}I>qjq$>+{!T3$0+6Ig%)$wDH1Z7`e6 z(K^aa1`VH{&h{D&bRXW$E@$GPGjZ6N=;jID=A*o$JkhmZr&%21$Nd-f_2&oD`?R54 zF0Jjmn%6GxyF7fcqfbi?_IIEk+2KMarMu?Q!07wf@T3{xz!wJP7^1Fs4h|bl>{2#= zHLazRdb+sfS?5abti9TbKIvZpxGvq=w5_6a+$fIksz$d1EB66ZqT9=T)6tH3g+6m; z22Tk%P1Sg+YNNAC$M`OuJHzS;yxb?M3glg-V*%Ze9#9%0!}Na##2=romKYI0-$;`te(Kjy&QGIca^xrxV8RQHmQ|aO^mu28q z5E8w;7u^R6*@DyxD^K;n%?iwhpxPK>i9=!QfCXRTyG&U9sI| z8Mu_&;#>u>in0f*puHM}bhM}Z96%+yt^C|{bkDp(pE)ywrv#j)YCKi7(OG5BcstLX zVf6%Feoj;s$h*oOwhGS0gNBk|vTPy&Eif^VSBJ7`;E_b)i$lpQNx6xH1~p64_7dnM z&;>9lu@)GM)JS<^U=0wRIN!_CU61VVyc>}H9R&6-06fC0Q0Wx@#bN1q3;U6^vKp!S zp-cu<=;=Mma4@bIYiRhNC9U-tD6JRhnba^lx*=&+3qEN2updA#!N13;Tb4YQ7Ci#R z6*?s}My6f*OMd+?9NLemJBq#ofp+O<+9he3541~jXi7r6bc~*xR_n7U5wuW0*E^XO z8Zfoc;HtIIpw*IfuG2!j&_w+sKBkG@51+~o_9tUcZxyVxT_2LDOJ?g z6WBn2IHtCVz-9tOI0hik?$$VB@ZJN-jY}UJ;!8sCly{sDp2P`k&3mB_FW87wuwk{mULTo z!kk9xpc+`UDa_ffTMvZsX~iChlITG|4Zh)Fi{GazYN+Hh7r%eii{Ec8dh1M!pP8M) z=Zaxk<}tS7wWFAyY@x^H;u_P+WSN#=Xfix{7yb{#J@z6lI&Df=^K4OTkE5Y@fUm?S zsnO36pf%t$)|fUOzV5npSlP^F#}{22TXcC%O>2ov?n+Y2By$BWSJ!ehzU1l((z4gn z0N174ja$b{6JMw{9vk&I@mtZBu?w$UyBWP1{rTvqHg(Il@!rX} zd|y6+N#9~k`V)+Y9mJgX>#4k!p5#-WxYfZLGioem${lZM{Ur+)Lc%J##RM}M-~e16n_YyGB*vT1Z^On>F2 zd1Vtmb3Dlt0!~vdPgQMnR@pSBi!uV4Q8s<_*$?US!R*HqlN{@a1f{y8mOkRNkS^2e-6uF-_mE)aN@z;gs1C-8ZIMYD4oBh2I( z$fgVF>~KQO^pdSVR}fS6&{BfYaX1xF)+0EO^d#9SJ%_o15v-W{DB>gZg;EuWP z#zTHM+K?e>$7m%`kF-w`VCu+Ygq3Mb$&=-52Ka{U} zC0R(wO)JcIAv3K|?t@zsg2I|W3dQ%+nh=Ixo9h-ogBfkIA}o~;znZwKHJ?kHi;20L z+=L=ggssn+S}p?QTqb7lrl2{_Ga@?_P4 z&nkz?>v{GJ-9eyeRp3*t9Ib=6H(N{R;vww-I;$NdK;72P5cmlKJp_K5K$3t;pql_m zd)(G|gfIdz{Oj8QxUKQDd^h0rZ@3%w_&48G7%tZM*5O7G9@rPH(J{}qX<~(y{{5o}KS=wc;Z0sly+>AO2N@vcU#n%Ru>7&y zRa*X*V!xbV2!kAt%@DJ-&Rj z)rM#wLj+cEghDi+W>F9ag=m;4g=lp1T!d(p;fSLGELsCh*TfgK#joJb516h?NZBe3 z5IybLk zT)-zFxH%2gmxy%K9aiA)h*Xj;QO&x8{6ebtZQoneA`W?}a(fxNl=!xghVW6T;0Z{8;QexRnXp0rFJl87baWj6X@?q_I>!S4d`ZFhIxh7q4XU zLwbw?24d>aU=|)kOx8}rlkPHtEWS23tbGo5pY#|Fvv$^&si!E%QE$uCPf*U| zb{Vw93VlvwjXgW-+f4DCpsK>bra5Q;V77xbrT&E3i`90 z)UKez+&Om@m?2-(N;nropUPj1;i0E>LrxW=J?TO+_Su{|MBeGN9`|ZrqI%Z}e3`&k z0G!XXl|NIkZ^sjT6@|$DtA7^Yy7ae7xc+(S<XVdn(#{E0_Mf7x zPu+5V8Z@`)_#hi3STxJ0u>?+wWt4IfDIsK#9_4((B}BEnZ9A+zCC??3K=Dp+D2N;U zaHpl4zMKA=ft0UbN_k#{wdf0|foOgINT5V^D9_hDHzGc1BuH@+BY{3?M1e0aR0@@X zSEP|}DLm3p3hRpM8;O)6EU&TDh`fd|S%1DDX27L@vj-f9cP|VdUx3=O?OcvJ^gZj2 zQpj@ds=-2ob@7sm9o71l*t@aT(I&zQt%(be;EIfk;Y-H4OvQI{?jVBD#h2F)1F3qU>~7EKb%kf@WLWoUaIy0u1P(rx1q8X_~bnEcnt3LQ{nPU z74$?d4bQZ&s<1#wy~x~twFH_QSeC1m!d>+so(x55Y_4qS`u4N4jh`Cz+-hDk-@JXg zd3&{a2fTp0_RcqVRGK?pe{%fP_!rCD#iMXk8&{Rg{^C)Llgrj>{HWS7>o^ zPC5Q>|KycgI&~toZ$>|TP^n>VB!SWdU3e8k^--7`SXFHh=j#rF;6`$n3gQf*H|)6N z;!IhB&A2cmI~=lx&A2fn;p$*+N}DCQm36ucdHfUvF>K+p-5r7%IljLeX%)<^jv!hA zlixeYj(<3*{Q`!ADY27_1E@gYv^VJb1p?0!AhH$@GqR(RO1j!0$G92fNV1wnL4~|w zA|0HFJWm+W0~d?VUZyES=vbak=HlM>?L)JTk1tx>8+c&KxThvCW!xj8OgOS+V9KJBC@%t2Tt&pUKjJE~ z=ZrmnRqy4~82Cv$V$i)*5$na8OQ)q5F>CmW+=y{feFR6ClT-B)vvlMJO8!#d@)vMD zSPJUd!m=w6Vq}nsk*MPkmFzlhYf%K4INEkAC$_bUfG&kaCn~165pqN)+IMsdX%T{} zH6o_C;fIJ390}YQ^GU<_gDTY5%Z|S@*V!*(Y~G`1OPgg+9Jd4sL=MMJ@a?b^gY2Q0 ztu7XpSQD#=&}Hp7D#PbSRF$ni?1D(=48NAl^`#wak+FRdVT=VEqYA{&?Zs!v8SR%T zXFCD563+s2hIt*)iA>sjm2=fku;s{t%yhQ1JPqL^tz$18g|8L&FSY)bs*%Gz^#2L4 zEH}N~&@?*q+Ocu@)f2bZZhGV3_|PvMn~+~WQC+*|t|T`e9+Pja-!gvwYuC#1*IueL zcbC%>r>5k|Gry9Z>iU&DGR9si=cm_q&o|R&j;DE|N(qk6pkkh*PB`1#eaDNNyziAF zF0o_8f*7OwRUl4Ax2!=*wwIclPE`B=M*cwYkhv(v-U!%`PC z=&O)UG-@0#$=MfTqFB6*MU*oFH# zX_`|_r-I(^1pTL)gV;4wZ?>d|sQoVj|A)Z;BhXDCPGA>-8y2_-MC_6A`A^)9c;T@Q z`~8O(n!JAQ`(mX@WE2MoIXf0M;NJUo2+8#K+p-T@OBX3TT701MW zR%Q+KD6m5BZ8>6vLuX_`oB14D$&up z17^Gsncb+4ElaAARX^dC)~YZ>t(tx~#zAc~sY+OF zQlq7?DPK3Nn#MF(Em>z%z7CQpZ$ugM+&D}UcD$LJ?wAf+>~`$MgA~2g+h-ta^WA>@ zH8gNf>3BU%A(>01jX)pcu!axsPAbHGe9O>pU|!jq|Co%Uy-j()1^@}3z3v8@GQT0& z1Z!V+gB#MWLQ}pc8Gec?I$M8+nj^`+M%;gt@6&iR-pH+d&BEo8;QiG8Kh=AO04ZF2 zgCVUQw_S$pu533VtcEcvWNQPzMak_1&JrLoj_U(E2>b5@{x^VAGdQY~ie+mCmc+M~ z=?1)icrQ;nK|^WoI;<09xS6fK=yf!!|2}wGy)w|!I^VKmx@AYTrF}GTJGyba{mmWK z=&p*gYbJ{KXV{-PWf$$Kri`kMiV`!*F4|meUbax@1x0Y{TP^Ds zBv0s}(NniuKQW$sv#r|NUWv5Nw6@Pj?9W`J{hP;lM%6||iJ3_Icv4)pQ0L`0+r(uH zosG0tYVU#~xb^MUbz@)r#^Fk2>-c$qvBOY_J~5JmBh0an_JQoGV&x_&$!OiaYF*4k#Y4UzNTgQvR9#{n>zIowC#VV*x;PKcNL2Z(f zj5xR(0DqgVGlE0>*Fl6_b~!IL0J4Ue*6@Ad;*x)7IM1NuOhv;vYbQ7&aNm3(MC@Yk<70L;7 zC{&4$>l=ws)D^#xD9O~&<-fg9d{+2I!kGcXZGicSL(Fg+G6l=9L$I{(=%&1eErHTH zn}Veef+ZPm$yXd02{O|y^9plf_ys&`NP4f4XVyiuge1v@KOna7*O?m{xQKn=`Cb;N zq=!d#lN`u*Fp>|L*J0jZE-^yj<9R^Lb|5agw_^kbXqBo=ii5F^W|u=sC1Fzg4uNj~ z)Vhz+OYNJ;jW?KbC2PsyJ~pCtU0*egZ>X0n|A@N4$oqD}o*+;rK$2yRE4NGgAIM_X zIfqo~bmQ_b3>JWBwM2hXPxPko@iG!&(qU;o~Fz_PgnPx2Lc(fvIk|(&nxtK*UI5Z0jHJo#3`sq)kbHj zG=l3Xdxq8IxPWv6+|{ZlaH_cHX`W9N_n5aA6*+?Hi-j>Vm@#@@1pFxU+&0d+*|IrZ zx6NiF4x^oIQerVl!BPO!e7B(HeallZLCsOiq2@bYHF3#>@>;xuy$#l;k}j@P+X`>d z44CBX8ZK03aSfvGpvF;*H9J0Hk9<};tM1XF8jLzG`z^qQp@h+a2&C2ae63?-T*lIV z{@^3a-`&X>ozY<~<89BM7234<&GPrQ`Hk{M(BwB16=w4rl6l$TxTA{+s z^admC9egkD`XSm4=Lq90%n0%fU82XzY!#fnoJ6vgy`T#$I_aQkx6uBWYLLikG}ZQl z-)pt{gFA0w`U5c&##=Z?F~f`Yb53)b?(7UNI^@n|wpuy{buvMtg%^bO7QkT`2Pqy{ zlEXm5aIZ6tTkHP;uq=DI-L`JNZRd0w*vlcna&NVbHhA7BRN6YK(N3UKwEMFX?VQ>! z_Ikc+X4LJ=#9q%E1)hhko)#(!VV~zWKP4_(C^mvt+Q4|sZKNE*9qIj}l!ulqlohpi zhoVos2pXO;H<0oS0nrZUIwm#2XgEv&ck8r><^84528622+F^hK_}}(gt|~-ix|f2Z zXceU>^0~GxAyL-cF;H_qw=zD}QN}hp!!f74E#5wlH|cmT32Ibx2VbA3Pkvia5WLAQ zKyDT{-DJcMeaQcoS)E1u1`yFp0TUc^d6kSF6nl6Q4&0?afPx%7h0j5;0$V$X}U&is@hz#Lqo`-!45}D zTdX|$Y{xWP+|!|9kog+1*w8Lo5F>xB7J~Ko3Lve*MY6 zDz^RlKbl^7nVVzAP#z58Gz%34@i(JT5g5uD0uYlSATX6@0kL^RCvBZDH4=@{d7oyJ z_~+0%i&(W^sT_~m;Q%}`|7WQXNsf%Z8-CWJLs6i?skaC%3P^dG3bAM~rYQU}C7HST zVZs<~-$__IfNLnqLyTxM0?jvi&G?AJ*IMFllRE2EZM@IwSjjpLE zJH~pymHk>4$k(#p$l)}qkL3_8ITFbhKzRhdj(?rjFWIQP{tNQmu-D(c5N`1A}B>B(t~(UsC!gIr|+XFHO#9mzjznQ2!8tl<|u#32f`XC1@O`{ z!HV1z9+;N`Agrv0t(G|IDB^ny#1>>fkm<(mhnq1<5Ni-tyQJIQMM+W)M2sBU%~lGU zrWU2J+6YwP4Xh)CAb@K4!AOpLJR{lgU?e}DksOn6{4?_@M;wZn5lSU<`5e7JRWn&G zd&ea7B#Y%TZ3i#r8$b-4%|V~Vnr5NWMi7y2_<0T8Xg1$?M_VQNMiCxGZM4|G%R#?M z0$R3eE*h#cw;@1}H}IEqf5uH254Bw@WWE>XHD@#@ugJ`5V4>J9ftb$A>0zC#J@wR< zTh^AUGliSz$3V{A)SjKwEjz0%yG8@I+BPjn-caZ0>07N^;kX?8WO;}1Xu@MkY2o!8 zgA3F4r*coHrTlAUZX`ZN3f};YWLfI!STd24U1hsCp z?yN*QDy=&w+A6Ib^AY;YnHfA)Whsseh&mJS)Iq7LKSsPz5xkZ_KH|fU3h=U$pBdVSpMA%{9Yjo--3rWX#Lo~1NE-V~%m|4rBWULFdGLR6H$%y#3-ohT z*SKMCCw`U6bj*lfrRp@_3t-Pbt}gnh+a!tdS0#}o%H9CRUIk$Udo$PQ9iTiqiOnPy zYyF#4j8q2pmFkA#zVaCVt%DQVKcWaJ5<2N0?|Wrg{xj(zcz9lxzS8*T(%O4IzwB9% z0PbyQkRQ1t0o>cNR_?wd0o>cK$oua|0Pk;=Jjz$MetGL1PgrhT@JgQM_X&G{lgRgr od|>xBtdkL74sh>SMBZ{o0=U<{PTom*JMSH9koVrjz4%l4Pbdn>761SM diff --git a/tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_graph_builder.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 894db1b33ad8e6fad5f58fa5af61c12b1736dc9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23962 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 diff --git a/tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_shell_company_detector.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 91987bcbd2dd9ef23eeb17436626f61db1866d8e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23023 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

)pM-jIY)#XS~zq722}GR!CE@ z=bu(K<1uP^um^Qae;K}IA;y!Pnn~k>4)z%$66@@+i-|8c<%|4a)kag zHem3DFtih+_@iIYi}^>t;I4<`jeWC=w)_so@s%%&|E-6f#32AVxA}`?!Cn%4(>4^{PjDT~2CdLI!BRq4-&cXo_X0k=ct%xNEe>|K-q*jxEO)yFPu