test(sveta): Write unit tests for frontier scheduling and concurrency safety
Outcome: done Changed: tests/test_research_scheduling.py:1-506 Verified by: make verify -> exit_code 0, 198 tests OK (22 new), "verification passed"; only pre-existing StarletteDeprecationWarning from fastapi/testclient.py:1, none introduced Findings: 22 stdlib-unittest tests with retoor header; bounded pool at config max_concurrency=8 drains 64 frontier queries issuing each exactly once (frontier.py:194, config.py:19); two concurrent pools never double-issue; late-enqueued queries drained; snapshot accounting consistent (frontier.py:218); dedup holds under concurrency: 64 concurrent push_query dups -> 1 enqueued/63 skipped, 64 concurrent register_url across normalized variants -> 1 seen/63 skipped (frontier.py:127,153,167), same-content-16-URLs -> 1 content; TTLCache repeat-key identity, unknown-key None, zero-TTL boundary, negative TTL, overwrite, clear, thread-safe under 16 threads x 256 keys and 128 same-key sets (cache.py:20-45); web/images/chat/describe/describe_raw funnel through one mechanism RsearchClient._request, web vs images differ only by type param (client.py:177); ResearchPipeline (pipeline.py) pool_size == max(1,max_concurrency), run() drains all WorkItems with endpoint map web/images->/search describe->/describe chat->/chat, semaphore bounds in-flight work to pool_size proven by peak tracking (pipeline.py:129-255); duplicate web items dedup at frontier (urls_seen 2, queries_enqueued 6 across 5 mixed items); mutation check: breaking register_url dedup flips s Typosaurus-Run: 4e2afb673c7f4578a12276d9181b982d Typosaurus-Node: 4159e2d87c90415bb7ca49e75f53a1b6 Typosaurus-Agent: @sveta Refs: #31
This commit is contained in:
parent
c0a59b4138
commit
3ff5fc686a
@ -3,13 +3,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from typosaurus_sandbox.research.cache import TTLCache
|
from typosaurus_sandbox.research.cache import TTLCache
|
||||||
from typosaurus_sandbox.research.client import RsearchClient
|
from typosaurus_sandbox.research.client import RsearchClient
|
||||||
from typosaurus_sandbox.research.config import ResearchConfig
|
from typosaurus_sandbox.research.config import ResearchConfig
|
||||||
from typosaurus_sandbox.research.envelopes import SearchResult
|
from typosaurus_sandbox.research.envelopes import ChatResponse, DescribeResponse, SearchResponse, SearchResult
|
||||||
from typosaurus_sandbox.research.frontier import QueryFrontier
|
from typosaurus_sandbox.research.frontier import QueryFrontier
|
||||||
|
from typosaurus_sandbox.research.pipeline import ResearchPipeline, WorkItem
|
||||||
|
|
||||||
SEARCH_FIXTURE: dict[str, Any] = {
|
SEARCH_FIXTURE: dict[str, Any] = {
|
||||||
"query": "subject",
|
"query": "subject",
|
||||||
@ -45,6 +46,84 @@ DESCRIBE_FIXTURE: dict[str, Any] = {
|
|||||||
"timestamp": "2026-08-07T12:00:00Z",
|
"timestamp": "2026-08-07T12:00:00Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IMAGES_FIXTURE: dict[str, Any] = {
|
||||||
|
"query": "subject",
|
||||||
|
"source": "wikimedia",
|
||||||
|
"count": 1,
|
||||||
|
"success": True,
|
||||||
|
"error": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Aurora borealis over Norway",
|
||||||
|
"url": "https://example.com/image",
|
||||||
|
"description": "Photograph of the aurora borealis.",
|
||||||
|
"source": "wikimedia",
|
||||||
|
"extra": {},
|
||||||
|
"index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.config = ResearchConfig()
|
||||||
|
self.search_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.chat_calls: list[str] = []
|
||||||
|
self.describe_calls: list[str] = []
|
||||||
|
self.delay_seconds = 0.0
|
||||||
|
self.active = 0
|
||||||
|
self.peak = 0
|
||||||
|
|
||||||
|
def search_cached(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
count: int | None = None,
|
||||||
|
content: bool = False,
|
||||||
|
type: str | None = None,
|
||||||
|
deep: bool = False,
|
||||||
|
ai: bool = False,
|
||||||
|
cache: bool = True,
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def describe_cached(self, url: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
count: int | None = None,
|
||||||
|
content: bool = False,
|
||||||
|
type: str | None = None,
|
||||||
|
deep: bool = False,
|
||||||
|
ai: bool = False,
|
||||||
|
cache: bool = True,
|
||||||
|
) -> SearchResponse:
|
||||||
|
self.search_calls.append((query, {"type": type, "content": content, "count": count}))
|
||||||
|
self.active += 1
|
||||||
|
self.peak = max(self.peak, self.active)
|
||||||
|
try:
|
||||||
|
if self.delay_seconds:
|
||||||
|
await asyncio.sleep(self.delay_seconds)
|
||||||
|
if type == "images":
|
||||||
|
return SearchResponse.from_dict(IMAGES_FIXTURE)
|
||||||
|
return SearchResponse.from_dict(SEARCH_FIXTURE)
|
||||||
|
finally:
|
||||||
|
self.active -= 1
|
||||||
|
|
||||||
|
async def chat(self, prompt: str, *, system: str | None = None, json_mode: bool = False, cache: bool = True) -> ChatResponse:
|
||||||
|
self.chat_calls.append(prompt)
|
||||||
|
return ChatResponse.from_dict(CHAT_FIXTURE)
|
||||||
|
|
||||||
|
async def describe(self, url: str) -> DescribeResponse:
|
||||||
|
self.describe_calls.append(url)
|
||||||
|
return DescribeResponse.from_dict(DESCRIBE_FIXTURE)
|
||||||
|
|
||||||
|
|
||||||
class TestFrontierScheduling(unittest.IsolatedAsyncioTestCase):
|
class TestFrontierScheduling(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
@ -360,6 +439,68 @@ class TestPipelineSingleMechanism(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(describe_calls[1][0], "POST")
|
self.assertEqual(describe_calls[1][0], "POST")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResearchPipeline(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
|
async def test_pipeline_pool_size_bounded_by_configured_concurrency(self) -> None:
|
||||||
|
client = _FakeClient()
|
||||||
|
frontier = QueryFrontier()
|
||||||
|
default_pipeline = ResearchPipeline(client, frontier)
|
||||||
|
self.assertEqual(default_pipeline.pool_size, 8)
|
||||||
|
self.assertEqual(default_pipeline.pool_size, ResearchConfig().max_concurrency)
|
||||||
|
narrow_pipeline = ResearchPipeline(client, frontier, ResearchConfig(max_concurrency=3))
|
||||||
|
self.assertEqual(narrow_pipeline.pool_size, 3)
|
||||||
|
floor_pipeline = ResearchPipeline(client, frontier, ResearchConfig(max_concurrency=0))
|
||||||
|
self.assertEqual(floor_pipeline.pool_size, 1)
|
||||||
|
|
||||||
|
async def test_pipeline_drains_all_work_items_and_dedups(self) -> None:
|
||||||
|
client = _FakeClient()
|
||||||
|
frontier = QueryFrontier()
|
||||||
|
|
||||||
|
async def items() -> AsyncIterator[WorkItem]:
|
||||||
|
yield WorkItem("web", "subject")
|
||||||
|
yield WorkItem("web", "subject")
|
||||||
|
yield WorkItem("images", "subject")
|
||||||
|
yield WorkItem("describe", "https://example.com/page")
|
||||||
|
yield WorkItem("chat", "prompt")
|
||||||
|
|
||||||
|
pipeline = ResearchPipeline(client, frontier, ResearchConfig(max_concurrency=4))
|
||||||
|
report = await pipeline.run(items())
|
||||||
|
self.assertEqual(report.requests_succeeded, 5)
|
||||||
|
self.assertEqual(report.requests_failed, 0)
|
||||||
|
self.assertEqual(len(report.outcomes), 5)
|
||||||
|
self.assertTrue(all(outcome.success for outcome in report.outcomes))
|
||||||
|
endpoints = {outcome.item.kind: outcome.endpoint for outcome in report.outcomes}
|
||||||
|
self.assertEqual(endpoints, {"web": "/search", "images": "/search", "describe": "/describe", "chat": "/chat"})
|
||||||
|
self.assertEqual(len(client.search_calls), 3)
|
||||||
|
self.assertEqual(len(client.describe_calls), 1)
|
||||||
|
self.assertEqual(len(client.chat_calls), 1)
|
||||||
|
stats = frontier.snapshot()
|
||||||
|
self.assertEqual(stats.urls_seen, 2)
|
||||||
|
self.assertEqual(stats.urls_duplicates_skipped, 1)
|
||||||
|
self.assertEqual(stats.queries_enqueued, 6)
|
||||||
|
self.assertEqual(stats.queries_duplicates_skipped, 2)
|
||||||
|
self.assertEqual(stats.content_seen, 2)
|
||||||
|
|
||||||
|
async def test_pipeline_concurrency_bounded_by_pool_size(self) -> None:
|
||||||
|
client = _FakeClient()
|
||||||
|
client.delay_seconds = 0.02
|
||||||
|
frontier = QueryFrontier()
|
||||||
|
|
||||||
|
async def items() -> AsyncIterator[WorkItem]:
|
||||||
|
for i in range(12):
|
||||||
|
yield WorkItem("web", f"subject {i}")
|
||||||
|
|
||||||
|
pipeline = ResearchPipeline(client, frontier, ResearchConfig(max_concurrency=4))
|
||||||
|
report = await pipeline.run(items())
|
||||||
|
self.assertEqual(report.requests_succeeded, 12)
|
||||||
|
self.assertGreaterEqual(client.peak, 2)
|
||||||
|
self.assertLessEqual(client.peak, 4)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user