"""Tests for source_facts token limiting in recall. Covers: - max_source_facts_tokens: total token budget across all source facts - max_source_facts_tokens_per_observation: per-observation cap Both parameters are tested at the recall_async level and verified to produce fewer source facts when the budget is tight vs. unlimited. """ import pytest from hindsight_api.config import _get_raw_config from hindsight_api.engine.memory_engine import Budget @pytest.fixture(autouse=True) def enable_observations(): config = _get_raw_config() original = config.enable_observations config.enable_observations = True yield config.enable_observations = original async def _setup_bank_with_observations(memory, bank_id, request_context): """Retain several memories and trigger consolidation to produce observations with source facts.""" contents = [ "Alice is a software engineer who loves Python programming.", "Alice has been working at TechCorp for 5 years.", "Alice recently completed a machine learning certification course.", "Alice mentors junior developers on the team.", "Alice prefers functional programming patterns in her code.", ] for content in contents: await memory.retain_async( bank_id=bank_id, content=content, request_context=request_context, ) await memory.run_consolidation(bank_id=bank_id, request_context=request_context) class TestRecallSourceFactsPerObservationCap: @pytest.mark.asyncio async def test_per_observation_cap_reduces_source_facts(self, memory, request_context): """A tight per-observation token cap should return fewer source facts than unlimited.""" bank_id = "test-sf-per-obs-cap" try: await _setup_bank_with_observations(memory, bank_id, request_context) result_limited = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=True, max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts budget=Budget.MID, request_context=request_context, ) result_unlimited = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=True, max_source_facts_tokens_per_observation=-1, budget=Budget.MID, request_context=request_context, ) unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0 limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0 if unlimited_count > 0: assert limited_count <= unlimited_count, ( f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})" ) finally: await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context): """Each observation's source facts are capped independently — not as a shared pool.""" bank_id = "test-sf-per-obs-independent" try: await _setup_bank_with_observations(memory, bank_id, request_context) # With a generous per-observation limit each observation can have facts; # with a global limit of 1 token the first observation would consume the whole budget. result_per_obs = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=True, max_source_facts_tokens=4096, # large global budget max_source_facts_tokens_per_observation=512, # reasonable per-obs limit budget=Budget.MID, request_context=request_context, ) # Should not raise; source_facts may be populated for multiple observations assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0 finally: await memory.delete_bank(bank_id, request_context=request_context) class TestRecallSourceFactsTotalBudget: @pytest.mark.asyncio async def test_total_budget_limits_source_facts(self, memory, request_context): """A tight total token budget should return fewer source facts than unlimited.""" bank_id = "test-sf-total-budget" try: await _setup_bank_with_observations(memory, bank_id, request_context) result_tight = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=True, max_source_facts_tokens=1, # Effectively cuts all source facts budget=Budget.MID, request_context=request_context, ) result_unlimited = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=True, max_source_facts_tokens=-1, budget=Budget.MID, request_context=request_context, ) unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0 tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0 if unlimited_count > 0: assert tight_count <= unlimited_count, ( f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})" ) finally: await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_no_source_facts_without_flag(self, memory, request_context): """source_facts should be None when include_source_facts is not set.""" bank_id = "test-sf-no-flag" try: await _setup_bank_with_observations(memory, bank_id, request_context) result = await memory.recall_async( bank_id=bank_id, query="Alice engineer", fact_type=["observation"], max_tokens=4096, include_source_facts=False, # default budget=Budget.MID, request_context=request_context, ) assert result.source_facts is None or len(result.source_facts) == 0 finally: await memory.delete_bank(bank_id, request_context=request_context)