@@ -4803,8 +4803,9 @@ def test_unknown_rank_excluded_from_lca(self):
48034803class TestModelAgreementForProject (APITestCase ):
48044804 """Aggregation function over a filtered Occurrence queryset.
48054805
4806- Covers the four bucket transitions: unverified, verified+exact-agreed,
4807- verified+under-order-agreed, verified+disagreed-above-order.
4806+ Covers four bucket transitions: unverified, verified+exact-agreed,
4807+ verified+any-rank-agreed (no threshold), verified+disagreed-no-shared-rank.
4808+ Optional coarsest_rank threshold cases handled in the viewset tests below.
48084809 """
48094810
48104811 def setUp (self ) -> None :
@@ -4842,7 +4843,11 @@ def test_empty_project_returns_zeros_not_nans(self):
48424843 self .assertEqual (result ["verified_count" ], 0 )
48434844 self .assertEqual (result ["verified_pct" ], 0.0 )
48444845 self .assertEqual (result ["agreed_exact_pct" ], 0.0 )
4845- self .assertEqual (result ["agreed_under_order_pct" ], 0.0 )
4846+ self .assertEqual (result ["agreed_any_rank_pct" ], 0.0 )
4847+ # No threshold passed → coarser-rank fields null.
4848+ self .assertIsNone (result ["agreement_coarsest_rank" ])
4849+ self .assertIsNone (result ["agreed_coarser_rank_count" ])
4850+ self .assertIsNone (result ["agreed_coarser_rank_pct" ])
48464851
48474852 def test_buckets_canonical_cases (self ):
48484853 from ami .main .models_future .occurrence import model_agreement_for_project
@@ -4851,20 +4856,43 @@ def test_buckets_canonical_cases(self):
48514856 self .assertEqual (len (occurrences ), 4 )
48524857 # 0: verified, machine == user (exact agreement at SPECIES)
48534858 self ._identify (occurrences [0 ], self .vanessa_atalanta )
4854- # 1: verified, sister species (under-order at GENUS)
4859+ # 1: verified, sister species (LCA at GENUS)
48554860 self ._identify (occurrences [1 ], self .vanessa_cardui )
4856- # 2: verified, different family same order (under-order at ORDER)
4861+ # 2: verified, different family same order (LCA at ORDER)
48574862 self ._identify (occurrences [2 ], self .pieris_brassicae )
48584863 # 3: unverified
48594864
48604865 result = model_agreement_for_project (Occurrence .objects .filter (project = self .project ))
48614866 self .assertEqual (result ["total_occurrences" ], 4 )
48624867 self .assertEqual (result ["verified_count" ], 3 )
48634868 self .assertEqual (result ["agreed_exact_count" ], 1 )
4864- self .assertEqual (result ["agreed_under_order_count " ], 3 )
4869+ self .assertEqual (result ["agreed_any_rank_count " ], 3 )
48654870 self .assertAlmostEqual (result ["verified_pct" ], 0.75 )
48664871 self .assertAlmostEqual (result ["agreed_exact_pct" ], 1 / 3 , places = 3 )
4867- self .assertAlmostEqual (result ["agreed_under_order_pct" ], 1.0 )
4872+ self .assertAlmostEqual (result ["agreed_any_rank_pct" ], 1.0 )
4873+
4874+ def test_coarsest_rank_threshold_filters_shallow_lcas (self ):
4875+ """With coarsest_rank=FAMILY, an ORDER-only LCA pair is excluded."""
4876+ from ami .main .models import TaxonRank
4877+ from ami .main .models_future .occurrence import model_agreement_for_project
4878+
4879+ occurrences = list (Occurrence .objects .filter (project = self .project ).order_by ("pk" ))
4880+ # 0: exact (SPECIES) — counts in both
4881+ self ._identify (occurrences [0 ], self .vanessa_atalanta )
4882+ # 1: sister species (LCA = GENUS, deeper than FAMILY) — counts in both
4883+ self ._identify (occurrences [1 ], self .vanessa_cardui )
4884+ # 2: different family same order (LCA = ORDER, NOT >= FAMILY) — counts in any_rank only
4885+ self ._identify (occurrences [2 ], self .pieris_brassicae )
4886+
4887+ result = model_agreement_for_project (
4888+ Occurrence .objects .filter (project = self .project ),
4889+ coarsest_rank = TaxonRank .FAMILY ,
4890+ )
4891+ self .assertEqual (result ["agreed_any_rank_count" ], 3 )
4892+ self .assertEqual (result ["agreement_coarsest_rank" ], "FAMILY" )
4893+ # exact + GENUS LCA = 2; ORDER LCA excluded
4894+ self .assertEqual (result ["agreed_coarser_rank_count" ], 2 )
4895+ self .assertAlmostEqual (result ["agreed_coarser_rank_pct" ], 2 / 3 , places = 3 )
48684896
48694897
48704898class TestOccurrenceStatsViewSet (APITestCase ):
@@ -4973,7 +5001,11 @@ def test_agreement_empty_returns_zero_pcts(self):
49735001 self .assertEqual (body ["verified_count" ], 0 )
49745002 self .assertEqual (body ["verified_pct" ], 0.0 )
49755003 self .assertEqual (body ["agreed_exact_pct" ], 0.0 )
4976- self .assertEqual (body ["agreed_under_order_pct" ], 0.0 )
5004+ self .assertEqual (body ["agreed_any_rank_pct" ], 0.0 )
5005+ # No ?agreement_coarsest_rank → threshold + coarser fields null.
5006+ self .assertIsNone (body ["agreement_coarsest_rank" ])
5007+ self .assertIsNone (body ["agreed_coarser_rank_count" ])
5008+ self .assertIsNone (body ["agreed_coarser_rank_pct" ])
49775009
49785010 def test_agreement_happy_path (self ):
49795011 """One verified occurrence; user agrees with the machine prediction → exact match.
@@ -4996,15 +5028,14 @@ def test_agreement_happy_path(self):
49965028 self .assertEqual (body ["verified_with_prediction_count" ], 1 )
49975029 self .assertEqual (body ["no_prediction_count" ], 0 )
49985030 self .assertEqual (body ["agreed_exact_count" ], 1 )
4999- self .assertEqual (body ["agreed_under_order_count " ], 1 )
5031+ self .assertEqual (body ["agreed_any_rank_count " ], 1 )
50005032
5001- def test_agreement_under_order_bucket (self ):
5002- """Disagreement at species but same genus → counted under-order , not exact.
5033+ def test_agreement_any_rank_bucket (self ):
5034+ """Disagreement at species but same genus → counted as any-rank agreement , not exact.
50035035
50045036 Pick the machine prediction's sister species (same parent genus) for the
5005- identification. LCA between the two species is GENUS, which is >= ORDER,
5006- so the occurrence falls into the under-order bucket without contributing
5007- to agreed_exact_count.
5037+ identification. LCA between the two species is GENUS, so the occurrence
5038+ falls into the any-rank bucket without contributing to agreed_exact_count.
50085039 """
50095040 occurrence = Occurrence .objects .filter (project = self .project ).order_by ("pk" ).first ()
50105041 machine_taxon = occurrence .detections .first ().classifications .first ().taxon
@@ -5024,10 +5055,34 @@ def test_agreement_under_order_bucket(self):
50245055 self .assertEqual (body ["verified_count" ], 1 )
50255056 self .assertEqual (body ["verified_with_prediction_count" ], 1 )
50265057 self .assertEqual (body ["agreed_exact_count" ], 0 )
5027- self .assertEqual (body ["agreed_under_order_count " ], 1 )
5028- # 0/1 exact, 1/1 under-order
5058+ self .assertEqual (body ["agreed_any_rank_count " ], 1 )
5059+ # 0/1 exact, 1/1 any-rank
50295060 self .assertEqual (body ["agreed_exact_pct" ], 0.0 )
5030- self .assertEqual (body ["agreed_under_order_pct" ], 1.0 )
5061+ self .assertEqual (body ["agreed_any_rank_pct" ], 1.0 )
5062+
5063+ def test_agreement_coarsest_rank_invalid_returns_400 (self ):
5064+ response = self .client .get (
5065+ f"{ self .agreement_url } ?project_id={ self .project .pk } &agreement_coarsest_rank=GARBAGE"
5066+ )
5067+ self .assertEqual (response .status_code , 400 )
5068+ self .assertIn ("agreement_coarsest_rank" , response .json ())
5069+
5070+ def test_agreement_coarsest_rank_unknown_rejected (self ):
5071+ """UNKNOWN is a real enum member but not a meaningful threshold."""
5072+ response = self .client .get (
5073+ f"{ self .agreement_url } ?project_id={ self .project .pk } &agreement_coarsest_rank=UNKNOWN"
5074+ )
5075+ self .assertEqual (response .status_code , 400 )
5076+
5077+ def test_agreement_coarsest_rank_echoed_in_response (self ):
5078+ response = self .client .get (f"{ self .agreement_url } ?project_id={ self .project .pk } &agreement_coarsest_rank=family" )
5079+ self .assertEqual (response .status_code , 200 )
5080+ body = response .json ()
5081+ # Param is case-insensitive; response echoes enum name (uppercase).
5082+ self .assertEqual (body ["agreement_coarsest_rank" ], "FAMILY" )
5083+ # No verified occurrences in this fixture → coarser fields present but zero.
5084+ self .assertEqual (body ["agreed_coarser_rank_count" ], 0 )
5085+ self .assertEqual (body ["agreed_coarser_rank_pct" ], 0.0 )
50315086
50325087 def test_agreement_filter_passthrough (self ):
50335088 """`?deployment=` should narrow the set."""
0 commit comments