diff --git a/app/services/comparison.py b/app/services/comparison.py index 0187a82..b5a1620 100644 --- a/app/services/comparison.py +++ b/app/services/comparison.py @@ -8,7 +8,8 @@ MIN_SHARED_FOR_SCORE = 5 RESULT_LIMIT = 5 METHOD = ( - 'We compare rank positions directly. Smaller differences mean closer agreement.' + 'Each gap is the absolute difference between your two rank positions. ' + 'Alignment is 100% minus the average gap as a share of the longer shelf’s rank span.' ) @@ -17,6 +18,13 @@ def _rank_gap(your_rank: int, their_rank: int) -> int: return abs(your_rank - their_rank) +def _alignment_score(gaps: list[int], longer_shelf_count: int) -> int: + """Scale the average raw gap against the longer shelf's possible span.""" + rank_span = max(1, longer_shelf_count - 1) + mean_gap = sum(gaps) / len(gaps) + return round(max(0.0, 1 - (mean_gap / rank_span)) * 100) + + def _item(catalog, **extra) -> dict: return { 'id': str(catalog.id), @@ -72,6 +80,11 @@ def compare_shelf( # pylint: disable=too-many-locals getattr(row, shelf.join_col): row for row in db.query(tracker).filter(tracker.user_id == viewer.pk).all() } + viewer_ranked_count = sum( + 1 + for row in viewer_trackers.values() + if row.on_rankings and row.rank is not None + ) base['recommendations'] = [ _item( item, @@ -106,8 +119,10 @@ def compare_shelf( # pylint: disable=too-many-locals shared, key=lambda item: (item['gap'], item['title'].lower()) )[:RESULT_LIMIT] if len(shared) >= MIN_SHARED_FOR_SCORE: - mean_gap = sum(item['gap'] for item in shared) / len(shared) - base['alignment_score'] = round(max(0.0, 100 - mean_gap)) + base['alignment_score'] = _alignment_score( + [item['gap'] for item in shared], + max(viewer_ranked_count, len(target_rows)), + ) base['alignment_status'] = 'ready' if watchlist_visible: diff --git a/tests/integration/router_comparison_test.py b/tests/integration/router_comparison_test.py index aeda991..08171b7 100644 --- a/tests/integration/router_comparison_test.py +++ b/tests/integration/router_comparison_test.py @@ -104,7 +104,7 @@ def test_comparison_scores_visible_rankings_and_marks_watchlist( ) assert movies['shared_ranked_count'] == 5 assert movies['alignment_status'] == 'ready' - assert isinstance(movies['alignment_score'], int) + assert movies['alignment_score'] == 60 assert [item['title'] for item in movies['common_watchlist']] == ['Movie 8'] assert [item['title'] for item in movies['recommendations']] == [ 'Movie 6', diff --git a/tests/unit/services/comparison_test.py b/tests/unit/services/comparison_test.py index 2ab5f72..e115715 100644 --- a/tests/unit/services/comparison_test.py +++ b/tests/unit/services/comparison_test.py @@ -1,7 +1,7 @@ # pylint: disable=missing-function-docstring """The direct rank-distance calculation behind comparisons.""" -from app.services.comparison import _rank_gap +from app.services.comparison import _alignment_score, _rank_gap def test_equal_ranks_have_no_gap(): @@ -11,3 +11,8 @@ def test_equal_ranks_have_no_gap(): def test_gap_is_the_absolute_rank_difference(): assert _rank_gap(88, 10) == 78 assert _rank_gap(10, 88) == 78 + + +def test_alignment_scales_average_gap_against_longer_shelf_span(): + assert _alignment_score([4, 4, 2, 2, 0], longer_shelf_count=7) == 60 + assert _alignment_score([0, 0, 0, 0, 0], longer_shelf_count=100) == 100