@@ -81,7 +81,7 @@ def _handler(signum: int, frame: object) -> None:
8181from codeanalyzer .schema .py_schema import PyCallEdge , PyModule
8282from codeanalyzer .semantic_analysis .call_graph import iter_callables_in_symbol_table
8383from codeanalyzer .semantic_analysis .pycg .pycg_exceptions import PyCGExceptions
84- from codeanalyzer .semantic_analysis .pycg .shard_planner import ShardPlan , plan_shards
84+ from codeanalyzer .semantic_analysis .pycg .shard_planner import plan_shards
8585from codeanalyzer .utils import ProgressBar , logger
8686
8787
@@ -379,6 +379,13 @@ class PyCG:
379379 # -1 restores PyCG's unbounded run-to-convergence behaviour.
380380 _PYCG_MAX_ITER : int = 50
381381
382+ # Iterative decomposition of runaway (timed-out) shards: a shard that the
383+ # wall-clock timeout kills is re-partitioned at half the budget and re-run,
384+ # down to this file-count floor. Below the floor — or for an atomic import
385+ # cycle that won't split — the residue falls back to Jedi-only coverage.
386+ _PYCG_DECOMP_FLOOR : int = 10
387+ _PYCG_MAX_DECOMP_ROUNDS : int = 6
388+
382389 # Directory names that should never be fed to PyCG as entry points, nor
383390 # followed into during import resolution (an in-tree .codeanalyzer venv /
384391 # site-packages lives under project_dir and would otherwise be pulled into
@@ -417,6 +424,7 @@ def __init__(
417424 self .shard_strategy = shard_strategy
418425 self .using_ray = using_ray
419426 self ._CallGraphGenerator : Optional [Any ] = None
427+ self ._resolver : Optional ["_PyCGCallableResolver" ] = None
420428
421429 @staticmethod
422430 def _coalesce_edges (edges : List [PyCallEdge ]) -> List [PyCallEdge ]:
@@ -570,20 +578,26 @@ def _build_sharded_planned(
570578 symbol_table : Dict [str , PyModule ],
571579 resolver : "_PyCGCallableResolver" ,
572580 ) -> List [PyCallEdge ]:
573- """Coupling-aware sharding driven by the Jedi module graph.
574-
575- Unlike :meth:`_build_sharded` (one shard per package directory), the
576- shards here are chosen to *minimise the call edges severed between
577- shards*: :func:`shard_planner.plan_shards` condenses the Jedi call
578- graph by strongly-connected component (so import cycles never split)
579- and clusters it with Louvain so tightly-coupled modules land together.
580- Each shard — an arbitrary set of files — is run through PyCG via a
581- symlinked mini-project (:func:`_shard_symlink_root`) that bounds PyCG
582- to exactly those files.
583-
584- Reported ``cut_ratio`` is the fraction of Jedi edge weight crossing
585- shard boundaries — an upper bound on the PyCG edges lost to sharding.
581+ """Coupling-aware sharding with iterative decomposition of runaways.
582+
583+ Shards are chosen to *minimise the call edges severed between shards*:
584+ :func:`shard_planner.plan_shards` condenses the Jedi call graph by
585+ strongly-connected component (so import cycles never split) and clusters
586+ it with Louvain so tightly-coupled modules land together. Each shard is
587+ run through PyCG via a symlinked mini-project that bounds analysis to its
588+ files.
589+
590+ PyCG's fixpoint diverges on heavy metaclass/mixin clusters, and a uniform
591+ ceiling would force *every* shard small (severing many edges) just to tame
592+ the few that run away. Instead we start coarse (low cut, high recall on
593+ healthy code) and **only re-decompose the shards that time out**: each
594+ runaway's files are re-partitioned at half the budget and re-run, down to
595+ a floor. A runaway shard contributes zero edges, so splitting it recovers
596+ almost all of them while paying cut on its internal seams alone. The
597+ residue that still diverges at the floor (or is an atomic cycle that won't
598+ split) falls back to Jedi-only coverage.
586599 """
600+ self ._resolver = resolver
587601 plan = plan_shards (
588602 symbol_table , jedi_edges , budget = self .shard_ceiling , merge_small = True
589603 )
@@ -594,64 +608,107 @@ def _build_sharded_planned(
594608 int (m ["num_shards" ]), m ["cut_ratio" ],
595609 int (m ["max_shard_files" ]), int (m ["modules" ]),
596610 )
597- if m ["oversized_shards" ]:
598- logger .warning (
599- "PyCG: %d shard(s) exceed the %d-file ceiling — skipped "
600- "(atomic import cycles larger than the budget)" ,
601- int (m ["oversized_shards" ]), self .shard_ceiling ,
611+
612+ runner = (
613+ self ._run_fileset_shards_ray if self .using_ray
614+ else self ._run_fileset_shards_seq
615+ )
616+ all_edges : List [PyCallEdge ] = []
617+ shards = plan .shards
618+ budget = self .shard_ceiling
619+ converged_total = 0
620+ irreducible_files = 0
621+ round_no = 0
622+
623+ while shards :
624+ label = "decomposition round %d (budget %d, %d shard(s))" % (
625+ round_no , budget , len (shards ),
602626 )
627+ logger .info ("PyCG: %s" , label )
628+ edges , runaways = runner (shards )
629+ all_edges .extend (edges )
630+ converged_total += len (shards ) - len (runaways )
631+ if not runaways :
632+ break
603633
604- if self .using_ray :
605- return self ._build_sharded_planned_ray (plan )
634+ next_budget = max (self ._PYCG_DECOMP_FLOOR , budget // 2 )
635+ stop_decomposing = (
636+ round_no >= self ._PYCG_MAX_DECOMP_ROUNDS or next_budget >= budget
637+ )
606638
607- all_edges : List [PyCallEdge ] = []
608- skipped = 0
609- with ProgressBar ( len ( plan . shards ), "Building call graph shards" , item_label = "shards" ) as progress :
610- for idx , files in enumerate ( plan . shards ):
611- n = len ( files )
612- if n > self . shard_ceiling :
613- skipped + = 1
614- progress . advance ( )
639+ next_shards : List [List [ str ] ] = []
640+ for rf in runaways :
641+ # Re-partition this runaway's files alone, at a tighter budget.
642+ # An atomic cycle (or a lone file) that won't shrink is
643+ # irreducible — accept Jedi-only rather than loop forever.
644+ sub_st = { f : symbol_table [ f ] for f in rf if f in symbol_table }
645+ if stop_decomposing or len ( rf ) < = 1 :
646+ irreducible_files += len ( rf )
615647 continue
616- try :
617- with _shard_symlink_root (files , self .project_dir ) as (root , eps ):
618- with _shard_timeout (self .shard_timeout ):
619- edges = self ._run_pycg_batch (eps , root , resolver , prefix = "" )
620- all_edges .extend (edges )
621- logger .debug ("PyCG shard %d: %d edges from %d files" , idx , len (edges ), n )
622- except TimeoutError :
623- logger .warning (
624- "PyCG shard %d timed out after %ds — skipped" ,
625- idx , self .shard_timeout ,
626- )
627- skipped += 1
628- except PyCGExceptions .PyCGAnalysisError as exc :
629- logger .warning ("PyCG shard %d failed — skipped: %s" , idx , exc )
630- skipped += 1
631- progress .advance ()
648+ sub_plan = plan_shards (sub_st , jedi_edges , budget = next_budget )
649+ if len (sub_plan .shards ) <= 1 :
650+ # did not actually split (one atomic SCC) — give up on it
651+ irreducible_files += len (rf )
652+ continue
653+ next_shards .extend (sub_plan .shards )
632654
633- if skipped :
655+ if not next_shards :
656+ break
657+ logger .info (
658+ "PyCG: %d shard(s) ran away — decomposing into %d sub-shard(s) "
659+ "at budget %d" , len (runaways ), len (next_shards ), next_budget ,
660+ )
661+ shards , budget = next_shards , next_budget
662+ round_no += 1
663+
664+ if irreducible_files :
634665 logger .warning (
635- "PyCG: %d/%d shard (s) skipped (ceiling, %ds timeout, or failure)" ,
636- skipped , len ( plan . shards ), self . shard_timeout ,
666+ "PyCG: %d file (s) in irreducibly-divergent shards fall back to "
667+ "Jedi-only coverage" , irreducible_files ,
637668 )
638669
639670 result = self ._coalesce_edges (all_edges )
640671 logger .info (
641- "PyCG: %d edges from %d/%d shard(s) (%d before dedup, Jedi-planned)" ,
642- len (result ), len (plan .shards ) - skipped , len (plan .shards ), len (all_edges ),
672+ "PyCG: %d edges from %d converged shard(s) over %d round(s) "
673+ "(%d before dedup, Jedi-planned%s)" ,
674+ len (result ), converged_total , round_no + 1 , len (all_edges ),
675+ ", Ray-parallel" if self .using_ray else "" ,
643676 )
644677 return result
645678
646- def _build_sharded_planned_ray (self , plan : "ShardPlan" ) -> List [PyCallEdge ]:
647- """Ray-parallel execution of Jedi-planned file-set shards.
679+ def _run_fileset_shards_seq (
680+ self , shards : List [List [str ]],
681+ ) -> Tuple [List [PyCallEdge ], List [List [str ]]]:
682+ """Run each file-set shard sequentially; return ``(edges, runaways)``.
648683
649- Each shard is materialised as a symlink mini-project up front (the
650- trees must outlive their remote tasks), submitted as a Ray task, and
651- collected against a single wall-clock deadline — Ray workers cannot use
652- SIGALRM, so the timeout is enforced at the orchestrator level (mirroring
653- :meth:`_build_sharded_ray`). All symlink trees are removed once the
654- batch completes.
684+ A shard that times out or raises is returned in *runaways* (its file
685+ list) for the caller to re-decompose; it contributes no edges.
686+ """
687+ resolver = self ._resolver
688+ edges_all : List [PyCallEdge ] = []
689+ runaways : List [List [str ]] = []
690+ with ProgressBar (len (shards ), "Building call graph shards" , item_label = "shards" ) as progress :
691+ for files in shards :
692+ try :
693+ with _shard_symlink_root (files , self .project_dir ) as (root , eps ):
694+ with _shard_timeout (self .shard_timeout ):
695+ edges = self ._run_pycg_batch (eps , root , resolver , prefix = "" )
696+ edges_all .extend (edges )
697+ except (TimeoutError , PyCGExceptions .PyCGAnalysisError ):
698+ runaways .append (files )
699+ progress .advance ()
700+ return edges_all , runaways
701+
702+ def _run_fileset_shards_ray (
703+ self , shards : List [List [str ]],
704+ ) -> Tuple [List [PyCallEdge ], List [List [str ]]]:
705+ """Ray-parallel variant of :meth:`_run_fileset_shards_seq`.
706+
707+ Each shard is materialised as a symlink mini-project up front (the trees
708+ must outlive their remote tasks), submitted as a Ray task, and collected
709+ against one wall-clock deadline — Ray workers cannot use SIGALRM, so the
710+ timeout is enforced orchestrator-side. Timed-out/failed shards become
711+ runaways; symlink trees are removed once the batch completes.
655712 """
656713 import os
657714 import ray
@@ -661,22 +718,17 @@ def _build_sharded_planned_ray(self, plan: "ShardPlan") -> List[PyCallEdge]:
661718
662719 roots : List [Path ] = []
663720 futures : List [Any ] = []
664- meta : Dict [Any , tuple ] = {} # ObjectRef -> (shard_idx, n_files)
665- skipped = 0
666- all_edges : List [PyCallEdge ] = []
721+ meta : Dict [Any , List [ str ]] = {} # ObjectRef -> shard file list
722+ edges_all : List [ PyCallEdge ] = []
723+ runaways : List [List [ str ] ] = []
667724 try :
668- with ProgressBar (len (plan .shards ), "Building call graph shards (parallel)" , item_label = "shards" ) as progress :
669- for idx , files in enumerate (plan .shards ):
670- n = len (files )
671- if n > self .shard_ceiling :
672- skipped += 1
673- progress .advance ()
674- continue
725+ with ProgressBar (len (shards ), "Building call graph shards (parallel)" , item_label = "shards" ) as progress :
726+ for files in shards :
675727 root , eps = _materialize_shard_root (files , self .project_dir )
676728 roots .append (root )
677729 fut = remote_fn .remote (eps , str (root ), "" , self .max_iter )
678730 futures .append (fut )
679- meta [fut ] = ( idx , n )
731+ meta [fut ] = files
680732
681733 deadline = (
682734 time .perf_counter () + float (self .shard_timeout )
@@ -696,44 +748,24 @@ def _build_sharded_planned_ray(self, plan: "ShardPlan") -> List[PyCallEdge]:
696748 break
697749
698750 fut = ready [0 ]
699- idx , n = meta [fut ]
700751 try :
701752 triples = ray .get (fut )
702- all_edges .extend (
753+ edges_all .extend (
703754 PyCallEdge (source = s , target = t , weight = w , provenance = ["pycg" ])
704755 for s , t , w in triples
705756 )
706- logger .debug ("PyCG shard %d: %d edges from %d files (Ray)" , idx , len (triples ), n )
707- except Exception as exc :
708- logger .warning ("PyCG shard %d failed — skipped: %s" , idx , exc )
709- skipped += 1
757+ except Exception :
758+ runaways .append (meta [fut ])
710759 progress .advance ()
711760
712- for fut in pending :
713- idx , _ = meta [fut ]
714- logger .warning (
715- "PyCG shard %d timed out after %ds — skipped" ,
716- idx , self .shard_timeout ,
717- )
761+ for fut in pending : # exceeded the deadline
718762 ray .cancel (fut , force = True )
719- skipped += 1
763+ runaways . append ( meta [ fut ])
720764 progress .advance ()
721765 finally :
722766 for root in roots :
723767 shutil .rmtree (root , ignore_errors = True )
724-
725- if skipped :
726- logger .warning (
727- "PyCG: %d/%d shard(s) skipped (ceiling, %ds timeout, or failure)" ,
728- skipped , len (plan .shards ), self .shard_timeout ,
729- )
730-
731- result = self ._coalesce_edges (all_edges )
732- logger .info (
733- "PyCG: %d edges from %d/%d shard(s) (%d before dedup, Jedi-planned, Ray-parallel)" ,
734- len (result ), len (plan .shards ) - skipped , len (plan .shards ), len (all_edges ),
735- )
736- return result
768+ return edges_all , runaways
737769
738770 def _build_sharded (
739771 self ,
0 commit comments