|
13 | 13 | TEST_USER_ID = 99999 |
14 | 14 |
|
15 | 15 |
|
| 16 | +def _patch_user_info(nsessions: int = 0, nmemes_sent: int = 0, **extra): |
| 17 | + user_info = defaultdict(int, {"nmemes_sent": nmemes_sent, "nsessions": nsessions, **extra}) |
| 18 | + return patch( |
| 19 | + "src.recommendations.meme_queue.get_user_info", |
| 20 | + new_callable=AsyncMock, |
| 21 | + return_value=user_info, |
| 22 | + ) |
| 23 | + |
| 24 | + |
16 | 25 | @pytest.fixture(autouse=True) |
17 | 26 | def mock_redis(): |
18 | 27 | """Mock Redis and user_info calls — these tests validate blending logic, not Redis.""" |
@@ -488,3 +497,151 @@ class TestRetriever(CandidatesRetriever): |
488 | 497 | for nmemes in [0, 3, 8, 12, 20, 25]: |
489 | 498 | candidates = await generate_recommendations(TEST_USER_ID, 10, nmemes, TestRetriever()) |
490 | 499 | assert len(candidates) == 0, f"Expected empty at nmemes_sent={nmemes}" |
| 500 | + |
| 501 | + |
| 502 | +# ── FFM-1161: nsessions gate ── |
| 503 | + |
| 504 | + |
| 505 | +def _growing_retriever_class(): |
| 506 | + """Retriever covering both cold_start engines and the growing-user blender.""" |
| 507 | + |
| 508 | + async def cold_start_explore(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 509 | + return [{"id": 101, "recommended_by": "cold_start_explore"}] |
| 510 | + |
| 511 | + async def cold_start_adapt(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 512 | + return [{"id": 201, "recommended_by": "cold_start_adapt"}] |
| 513 | + |
| 514 | + async def lr_smoothed(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 515 | + return [{"id": 301, "recommended_by": "lr_smoothed"}] |
| 516 | + |
| 517 | + async def best_uploaded_memes(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 518 | + return [{"id": 401, "recommended_by": "best_uploaded_memes"}] |
| 519 | + |
| 520 | + async def like_spread_and_recent_memes(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 521 | + return [{"id": 501, "recommended_by": "like_spread_and_recent_memes"}] |
| 522 | + |
| 523 | + async def recently_liked(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 524 | + return [{"id": 601, "recommended_by": "recently_liked"}] |
| 525 | + |
| 526 | + async def goat(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 527 | + return [{"id": 701, "recommended_by": "goat"}] |
| 528 | + |
| 529 | + async def es_ranked(self, user_id, limit=10, exclude_meme_ids=[], **kw): |
| 530 | + return [{"id": 801, "recommended_by": "es_ranked"}] |
| 531 | + |
| 532 | + class TestRetriever(CandidatesRetriever): |
| 533 | + engine_map = { |
| 534 | + "cold_start_explore": cold_start_explore, |
| 535 | + "cold_start_adapt": cold_start_adapt, |
| 536 | + "lr_smoothed": lr_smoothed, |
| 537 | + "best_uploaded_memes": best_uploaded_memes, |
| 538 | + "like_spread_and_recent_memes": like_spread_and_recent_memes, |
| 539 | + "recently_liked": recently_liked, |
| 540 | + "goat": goat, |
| 541 | + "es_ranked": es_ranked, |
| 542 | + } |
| 543 | + |
| 544 | + return TestRetriever |
| 545 | + |
| 546 | + |
| 547 | +@pytest.mark.asyncio |
| 548 | +async def test_gate_off_dormant_returner_still_uses_cold_start(): |
| 549 | + """Default (gate disabled): nsessions is ignored — cold_start still routes by nmemes_sent.""" |
| 550 | + retriever = _growing_retriever_class()() |
| 551 | + with ( |
| 552 | + _patch_user_info(nsessions=5, nmemes_sent=8), |
| 553 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", False), |
| 554 | + ): |
| 555 | + candidates = await generate_recommendations( |
| 556 | + TEST_USER_ID, 10, nmemes_sent=8, retriever=retriever |
| 557 | + ) |
| 558 | + assert any(c["recommended_by"] == "cold_start_adapt" for c in candidates) |
| 559 | + |
| 560 | + |
| 561 | +@pytest.mark.asyncio |
| 562 | +async def test_gate_on_first_session_routes_to_cold_start_explore(): |
| 563 | + """Gate on + nsessions<=1 + nmemes_sent<6 → cold_start_explore (Phase 1).""" |
| 564 | + retriever = _growing_retriever_class()() |
| 565 | + with ( |
| 566 | + _patch_user_info(nsessions=0, nmemes_sent=0), |
| 567 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", True), |
| 568 | + ): |
| 569 | + candidates = await generate_recommendations( |
| 570 | + TEST_USER_ID, 10, nmemes_sent=0, retriever=retriever |
| 571 | + ) |
| 572 | + assert len(candidates) == 1 |
| 573 | + assert candidates[0]["recommended_by"] == "cold_start_explore" |
| 574 | + |
| 575 | + |
| 576 | +@pytest.mark.asyncio |
| 577 | +async def test_gate_on_first_session_phase2_routes_to_cold_start_adapt(): |
| 578 | + """Gate on + nsessions<=1 + 6<=nmemes_sent<16 → cold_start_adapt (Phase 2).""" |
| 579 | + retriever = _growing_retriever_class()() |
| 580 | + with ( |
| 581 | + _patch_user_info(nsessions=1, nmemes_sent=8), |
| 582 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", True), |
| 583 | + ): |
| 584 | + candidates = await generate_recommendations( |
| 585 | + TEST_USER_ID, 10, nmemes_sent=8, retriever=retriever |
| 586 | + ) |
| 587 | + assert any(c["recommended_by"] == "cold_start_adapt" for c in candidates) |
| 588 | + |
| 589 | + |
| 590 | +@pytest.mark.asyncio |
| 591 | +async def test_gate_on_dormant_returner_falls_through_to_growing_blender(): |
| 592 | + """Gate on + nsessions>=2 + nmemes_sent<30 → growing-user blender, NO cold_start engines.""" |
| 593 | + retriever = _growing_retriever_class()() |
| 594 | + with ( |
| 595 | + _patch_user_info(nsessions=3, nmemes_sent=12), |
| 596 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", True), |
| 597 | + ): |
| 598 | + candidates = await generate_recommendations( |
| 599 | + TEST_USER_ID, 10, nmemes_sent=12, retriever=retriever, random_seed=42 |
| 600 | + ) |
| 601 | + sources = {c["recommended_by"] for c in candidates} |
| 602 | + assert "cold_start_explore" not in sources |
| 603 | + assert "cold_start_adapt" not in sources |
| 604 | + # Growing blender is pinned at lr_smoothed in position 0 |
| 605 | + assert candidates[0]["recommended_by"] == "lr_smoothed" |
| 606 | + |
| 607 | + |
| 608 | +@pytest.mark.asyncio |
| 609 | +async def test_gate_on_mature_user_unchanged(): |
| 610 | + """Gate on + mature user (nmemes_sent>=100) → blender_v2 path, untouched by gate.""" |
| 611 | + retriever = _growing_retriever_class()() |
| 612 | + with ( |
| 613 | + _patch_user_info(nsessions=5, nmemes_sent=120), |
| 614 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", True), |
| 615 | + patch( |
| 616 | + "src.recommendations.meme_queue.get_recently_liked_blender_v2_weights", |
| 617 | + new_callable=AsyncMock, |
| 618 | + return_value=MATURE_BLENDER_TREATMENT_WEIGHTS, |
| 619 | + ) as get_weights, |
| 620 | + ): |
| 621 | + candidates = await generate_recommendations( |
| 622 | + TEST_USER_ID, 10, nmemes_sent=120, retriever=retriever, random_seed=42 |
| 623 | + ) |
| 624 | + sources = {c["recommended_by"] for c in candidates} |
| 625 | + assert "cold_start_explore" not in sources |
| 626 | + assert "cold_start_adapt" not in sources |
| 627 | + get_weights.assert_awaited_once_with(TEST_USER_ID) |
| 628 | + |
| 629 | + |
| 630 | +@pytest.mark.asyncio |
| 631 | +async def test_gate_on_missing_nsessions_treated_as_zero(): |
| 632 | + """Stale cache without nsessions key → treated as 0, cold_start still applies.""" |
| 633 | + retriever = _growing_retriever_class()() |
| 634 | + # user_info lacks 'nsessions' (defaultdict(int) returns 0) |
| 635 | + stale_info = defaultdict(int, {"nmemes_sent": 4}) |
| 636 | + with ( |
| 637 | + patch( |
| 638 | + "src.recommendations.meme_queue.get_user_info", |
| 639 | + new_callable=AsyncMock, |
| 640 | + return_value=stale_info, |
| 641 | + ), |
| 642 | + patch("src.config.settings.COLD_START_NSESSIONS_GATE_ENABLED", True), |
| 643 | + ): |
| 644 | + candidates = await generate_recommendations( |
| 645 | + TEST_USER_ID, 10, nmemes_sent=4, retriever=retriever |
| 646 | + ) |
| 647 | + assert candidates[0]["recommended_by"] == "cold_start_explore" |
0 commit comments