@@ -950,6 +950,63 @@ def test_snp_calls_with_site_class_param(ag3_sim_api: AnophelesSnpData, site_cla
950950 assert ds2 .sizes ["variants" ] < ds1 .sizes ["variants" ]
951951
952952
953+ def test_locate_site_class_cache_is_bounded (ag3_sim_api : AnophelesSnpData ):
954+ """_cache_locate_site_class must never grow beyond _LOCATE_SITE_CLASS_CACHE_MAXSIZE
955+ even when all contigs and site classes are exercised in a single session."""
956+ from malariagen_data .anoph .snp_data import _LOCATE_SITE_CLASS_CACHE_MAXSIZE
957+
958+ site_classes = [
959+ "CDS_DEG_4" ,
960+ "CDS_DEG_2_SIMPLE" ,
961+ "CDS_DEG_0" ,
962+ "INTRON_SHORT" ,
963+ "INTRON_LONG" ,
964+ "INTRON_SPLICE_5PRIME" ,
965+ "INTRON_SPLICE_3PRIME" ,
966+ "UTR_5PRIME" ,
967+ "UTR_3PRIME" ,
968+ "INTERGENIC" ,
969+ ]
970+ for contig in ag3_sim_api .contigs :
971+ for site_class in site_classes :
972+ ag3_sim_api .snp_calls (region = contig , site_class = site_class )
973+
974+ assert len (ag3_sim_api ._cache_locate_site_class ) <= _LOCATE_SITE_CLASS_CACHE_MAXSIZE
975+
976+
977+ def test_snp_calls_cache_is_per_instance (ag3_sim_api : AnophelesSnpData ):
978+ """_cached_snp_calls must be a per-instance lru_cache, not a class-level one.
979+
980+ A class-level @lru_cache stores `self` as a key in a class-global dict,
981+ which prevents garbage collection of stale API instances and leaks all their
982+ subcaches. The fix stores the cache on the instance in __init__, so each
983+ object has its own independent cache that is freed with the object.
984+ """
985+ # (1) The cache wrapper must live on the instance, not on the class.
986+ assert "_cached_snp_calls" in ag3_sim_api .__dict__ , (
987+ "_cached_snp_calls should be an instance attribute (per-instance lru_cache), "
988+ "not a class-level descriptor"
989+ )
990+
991+ # (2) It must be a real lru_cache wrapper (exposes cache_info / cache_clear).
992+ assert hasattr (ag3_sim_api ._cached_snp_calls , "cache_info" )
993+ assert hasattr (ag3_sim_api ._cached_snp_calls , "cache_clear" )
994+
995+ # (3) Populate the cache and confirm it registers hits.
996+ ag3_sim_api .snp_calls (region = "3L" )
997+ ag3_sim_api .snp_calls (region = "3L" ) # second call — should be a cache hit
998+ info = ag3_sim_api ._cached_snp_calls .cache_info ()
999+ assert info .currsize > 0
1000+ assert info .hits >= 1
1001+
1002+ # (4) The class itself must NOT own _cached_snp_calls (it must not be a
1003+ # class-level descriptor installed by @lru_cache).
1004+ assert "_cached_snp_calls" not in AnophelesSnpData .__dict__ , (
1005+ "_cached_snp_calls must not be a class-level attribute; "
1006+ "a class-level @lru_cache would pin `self` in a global cache dict"
1007+ )
1008+
1009+
9531010@pytest .mark .parametrize ("chrom" , ["2RL" , "3RL" ])
9541011def test_snp_calls_with_virtual_contigs (ag3_sim_api , chrom ):
9551012 api = ag3_sim_api
0 commit comments