WIP: feat: Most efficient deep research system ever made #32

Draft
typosaurus wants to merge 13 commits from typosaurus/31-most-efficient-deep-research-system-ever-made into main
5 changed files with 679 additions and 10 deletions
Showing only changes of commit 32a17e3f5c - Show all commits

140
deepresearch.md Normal file
View File

@ -0,0 +1,140 @@
# retoor <retoor@molodetz.nl>
# Deep Research Engine — Design, Optimality Argument and Verification Evidence
This document describes the exhaustive deep research engine in `src/typosaurus_sandbox/research/`,
the mathematical argument that recursive query expansion with URL/content deduplication and
closure detection is the most aggressive feasible research strategy over the rsearch API, and the
four recursive verification passes executed against it. Every claim is traceable to the run's
verified nodes (fact sheet node d5d9e290; optimality node b042b1d23; tester nodes f7f10c64,
fde105db, 2e6bd38b; engine node 1b0176bf) and to source path:line references.
## 1. Scope and constraints
- Only search API: `https://rsearch.app.molodetz.nl`; the client issues requests only to the
`/search`, `/chat` and `/describe` endpoints (client.py:211). `/search` is GET-only.
- Content-type agnostic: web results, image results (`type=images`), describe and chat flow
through one asynchronous pipeline; no per-type special casing beyond parameter selection.
- Native Python 3.12, standard library only (`asyncio`, `urllib`); no new dependency was added.
- No artificial depth cap, page cap or time budget stops a run before closure; the engine stops
only when a full round adds zero new URLs and zero new queries (least fixed point).
## 2. Architecture (module map)
| Module | Public symbol | Path:line |
|---|---|---|
| config | `ResearchConfig` (base_url, TTLs, `max_concurrency=8`, default_count) | `src/typosaurus_sandbox/research/config.py:12` |
| client | `RsearchClient`, `RsearchError` (search/chat/describe, `_request`) | `src/typosaurus_sandbox/research/client.py:72` |
| cache | `TTLCache`, `CacheEntry` (thread-safe, monotonic expiry) | `src/typosaurus_sandbox/research/cache.py:20` |
| envelopes | `SearchResponse`, `SearchResult`, `DeepReport`, `ChatResponse`, `DescribeResponse` | `src/typosaurus_sandbox/research/envelopes.py:103` |
| frontier | `QueryFrontier`, `DedupStats`, URL normalization, content fingerprint | `src/typosaurus_sandbox/research/frontier.py:102` |
| pipeline | `ResearchPipeline`, `WorkItem`, `PipelineReport` (bounded worker pool) | `src/typosaurus_sandbox/research/pipeline.py:125` |
| engine | `ResearchEngine`, `ResearchReport`, `RoundSummary` (closure loop) | `src/typosaurus_sandbox/research/engine.py:85` |
| entry | `main()` CLI | `src/typosaurus_sandbox/research/__main__.py:24` |
## 3. Concurrency model
- Bounded asyncio worker pool: `asyncio.Semaphore(pool_size)` with
`pool_size = max(1, max_concurrency)` and `max_concurrency = 8`
(config.py:18, pipeline.py:126-136).
- `run()` drains the frontier through a bounded queue (pool * 4) with pool-size workers and
`None` sentinels; every request runs via `asyncio.to_thread` over `urllib` (no extra deps).
- Pool size is logged at INFO; every request outcome (endpoint, query/url, status, cache hit)
at INFO, every extraction at DEBUG.
## 4. Deduplication and closure strategy
- Query dedup key: whitespace-collapsed `casefold` (frontier.py:28); length window 2-200 chars.
- URL dedup: `normalize_url` lowercases scheme/host, applies IDNA, strips default port,
userinfo and fragment, collapses slashes (frontier.py:28).
- Content dedup: SHA-256 fingerprint of whitespace-normalized text (frontier.py:61).
- One `threading.Lock` guards all seen-sets and counters for concurrent worker access
(frontier.py:103).
- Closure rule: a round that adds 0 new URLs and 0 new queries halts the run
(engine.py:178-183). The engine is closed-loop verified: a fixed-fixture fake client closed
in 3 rounds with all four content types, and a 4-level chain client closed in 5 rounds,
proving no depth cap (engine node 1b0176bf).
## 5. Content-type agnosticism
- One worker path serves all kinds: `web` -> `search(content=True)`, `images` ->
`search(type="images")`, `describe` -> GET `/describe?url=`, `chat` -> POST `/chat`
(pipeline.py:138-143, engine.py:106).
- Extraction yields new URLs and new query seeds from titles, descriptions and `extra` fields
of every content type (frontier.py:66).
## 6. Optimality argument
Let `R(q)` be the set of result URLs returned by the aggregator for query `q`, `gen(u)` the
query variants generated from URL/content `u`, and `S` the set of collected URLs.
- Completeness: the process is coverage-complete for subject `t` iff it halts at the least
fixed point `S* = lfp(F)` with `F(S) = S _{u∈S, q∈gen(u)} R(q)`; the halt condition is
"a full round adds 0 new URLs and 0 new queries" (node b042b1d23).
- Dominance: depth-`d` iteration reaches `F^d(S0) ⊆ S*`; the inclusion is strict whenever the
discovery chain exceeds `d`, so every fixed-depth strategy is incomplete. Closure iterates
`F` to its unique least fixed point (Knaster-Tarski), attaining the maximum reachable
coverage; any strategy that stops before the fixed point is strictly dominated.
- Cost model: `Cost = Σ_{q∈Q_issued} c(q) + Σ_{u∈F_issued} c_c(u)`. Search (5 min) and content
(24 h) caches (config.py:16-17) make repeat queries near-free; the dominant cost is
`|Q_issued| + |F_issued|`, and query/URL dedup touches each element exactly once.
- Stated assumptions and limits: single aggregator (rsearch only), no pagination API,
documented count bound 1-100 with the provider capping at 10, and content retrieval only
through the aggregator. Optimality is proven within these constraints.
- Dated references (tier): rsearch docs https://rsearch.app.molodetz.nl/about (2026-08-07, 1);
Gemini https://blog.google/products-and-platforms/products/gemini/google-gemini-deep-research/
(2024-12-11, 1); OpenAI https://openai.com/index/introducing-deep-research/ (Feb-2025, 1) +
https://techcrunch.com/2025/02/02/openai-unveils-a-new-chatgpt-agent-for-deep-research/ (4);
Ntoulas 2005 ACM JCDL 10.1145/1065385.1065407 (3); Chakrabarti 1999 Computer Networks
10.1016/S1389-1286(99)00052-3 (3); Olston & Najork 2010 FnTIR 10.1561/1500000017 (3).
## 7. Four recursive verification passes
Each pass re-checks the previous pass's optimality claim ("recursive closure over the rsearch
aggregator is the most aggressive feasible strategy") and records its own evidence. All four
passes passed.
- Pass 1 — Optimality argument: formal completeness criterion, cost model and Knaster-Tarski
dominance proof produced with seven dated, tiered sources (node b042b1d23, 2026-08-07).
- Pass 2 — Engine matches the argument: all eight engine acceptance criteria executed with
pass verdicts and exact commands (node f7f10c64): rsearch-only source, bounded pool at
max_concurrency=8, one web/images/describe/chat pipeline, URL+content dedup (64 concurrent
same-query pushes -> 1 enqueued, 63 skipped), closure decision (NullClient probe closed in 1
round with 0 new URLs and 0 new queries), logging/annotations, no TODOs, and
`make verify` -> "Ran 199 tests in 2.168s OK verification passed".
- Pass 3 — Live probe coverage/cost (node fde105db, 2026-08-07): subject "python asyncio",
max_concurrency=8, count=10, 240 s guard: queries_issued=86, urls_seen=754, contents_seen=281,
164 network requests (search 105 / chat 46 / describe 13), X-AI-Cost-USD sum $0.002075, wall
elapsed 264.91 s. Adversarial subjects ("", spaces, tabs) raised ValueError
"research subject must not be empty" (engine.py:125) before any API call; urlopen delta 0.
- Pass 4 — Closure and determinism (node 1b0176bf, confirmed by fact sheet d5d9e290):
fixed-fixture fake client closed in 3 rounds with all 4 content types; 4-level chain closed
in 5 rounds (no depth cap); live `python -m typosaurus_sandbox.research` logged INFO rounds,
closure and the typed report JSON; final gate `make verify` green (199 tests OK, git clean).
## 8. Usage
```sh
python -m typosaurus_sandbox.research [subject]
```
Run a research session on `subject` (default "typosaurus sandbox") until closure; rounds and
closure decisions are logged at INFO, and the typed `ResearchReport` JSON is logged at the end.
Configuration (base_url, TTLs, max_concurrency, default_count) is loaded from `.env.json` under
the `research` key with plug-and-play defaults (config.py:22).
Verification gate:
```sh
make verify
```
## 9. Verification status
- `make verify`: exit 0, "Ran 199 tests OK verification passed" (2026-08-07); only the
pre-existing Starlette deprecation warning from the FastAPI test client remains, none
introduced by the research package.
- Re-run at document time: `make verify` exit 0, "Ran 217 tests in 2.162s OK", verification
passed; same pre-existing Starlette deprecation warning only.
- No TODO, FIXME, placeholder or stub in the research package (grep verified, node f7f10c64).

View File

@ -0,0 +1,198 @@
# retoor <retoor@molodetz.nl>
import unittest
from collections.abc import Callable
from typosaurus_sandbox.research.config import ResearchConfig
from typosaurus_sandbox.research.engine import ResearchEngine
from typosaurus_sandbox.research.envelopes import ChatResponse, DescribeResponse, SearchResponse, SearchResult
class FakeResearchClient:
def __init__(
self,
*,
web_results: list[SearchResult] | None = None,
web_result_factory: Callable[[str], list[SearchResult]] | None = None,
chat_text: str = "",
describe_text: str = "",
) -> None:
self.config = ResearchConfig(max_concurrency=4, default_count=5)
self._web_results = web_results if web_results is not None else []
self._web_result_factory = web_result_factory
self._chat_text = chat_text
self._describe_text = describe_text
self.calls: list[tuple[str, str, str | None]] = []
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.calls.append(("search", query, type))
if type == "images":
return SearchResponse(query=query, source="wikimedia", count=0, success=True, results=[])
results = self._web_result_factory(query) if self._web_result_factory is not None else list(self._web_results)
return SearchResponse(query=query, source="duckduckgo", count=len(results), success=True, results=results)
async def chat(self, prompt: str, *, system: str | None = None, json_mode: bool = False, cache: bool = True) -> ChatResponse:
self.calls.append(("chat", prompt, None))
return ChatResponse(response=self._chat_text, prompt=prompt)
async def describe(self, url: str) -> DescribeResponse:
self.calls.append(("describe", url, None))
return DescribeResponse(description=self._describe_text, url=url)
class TestEngineClosureDetection(unittest.IsolatedAsyncioTestCase):
async def test_run_closes_after_single_round_when_nothing_new(self) -> None:
client = FakeResearchClient(web_results=[], chat_text="", describe_text="")
engine = ResearchEngine(client=client)
report = await engine.run(" deep research ")
self.assertEqual(report.subject, "deep research")
self.assertTrue(report.closed)
self.assertEqual(report.total_rounds, 1)
self.assertEqual(len(report.rounds), 1)
first = report.rounds[0]
self.assertEqual(first.number, 1)
self.assertEqual(first.items_processed, 3)
self.assertEqual(first.requests_succeeded, 3)
self.assertEqual(first.requests_failed, 0)
self.assertEqual(first.new_urls, 0)
self.assertEqual(first.new_queries, 0)
self.assertTrue(first.closed)
self.assertEqual(report.queries_issued, 1)
self.assertEqual(report.queries_enqueued, 1)
self.assertEqual(report.urls_collected, 0)
self.assertEqual(report.contents_seen, 0)
self.assertEqual(report.content_types, {"web": 1, "images": 1, "chat": 1})
self.assertEqual(len(client.calls), 3)
self.assertEqual({kind for kind, _, _ in client.calls}, {"search", "chat"})
async def test_run_discovery_rounds_then_closes(self) -> None:
client = FakeResearchClient(
web_results=[
SearchResult(
title="topic alpha",
url="https://example.com/alpha",
description="alpha details",
content="alpha body",
)
],
chat_text="",
describe_text="",
)
engine = ResearchEngine(client=client)
report = await engine.run("deep research")
self.assertTrue(report.closed)
self.assertEqual(report.total_rounds, 2)
first = report.rounds[0]
self.assertEqual(first.new_urls, 1)
self.assertEqual(first.new_queries, 2)
self.assertEqual(first.new_contents, 1)
self.assertFalse(first.closed)
second = report.rounds[1]
self.assertEqual(second.new_urls, 0)
self.assertEqual(second.new_queries, 0)
self.assertTrue(second.closed)
self.assertEqual(report.queries_generated, 7)
self.assertEqual(report.queries_enqueued, 3)
self.assertEqual(report.queries_issued, 3)
self.assertEqual(report.queries_duplicates_skipped, 4)
self.assertEqual(report.urls_collected, 1)
self.assertEqual(report.urls_duplicates_skipped, 2)
self.assertEqual(report.contents_seen, 1)
self.assertEqual(report.content_duplicates_skipped, 2)
self.assertEqual(report.requests_succeeded, 10)
self.assertEqual(report.requests_failed, 0)
self.assertEqual(report.cache_hits, 0)
self.assertEqual(report.cache_misses, 10)
self.assertEqual(report.content_types, {"web": 3, "images": 3, "chat": 3, "describe": 1})
self.assertEqual(len(client.calls), 10)
self.assertEqual(sum(1 for kind, _, type_value in client.calls if kind == "search" and type_value is None), 3)
self.assertEqual(sum(1 for kind, _, type_value in client.calls if kind == "search" and type_value == "images"), 3)
self.assertEqual(sum(1 for kind, _, _ in client.calls if kind == "chat"), 3)
self.assertEqual(sum(1 for kind, value, _ in client.calls if kind == "describe"), 1)
self.assertIn(("describe", "https://example.com/alpha", None), client.calls)
async def test_new_content_alone_does_not_prevent_closure(self) -> None:
def factory(query: str) -> list[SearchResult]:
return [
SearchResult(
title="dup title",
url="https://example.com/dup",
description="dup details",
content=f"body for {query}",
)
]
client = FakeResearchClient(web_result_factory=factory, chat_text="", describe_text="")
engine = ResearchEngine(client=client)
report = await engine.run("subject")
self.assertTrue(report.closed)
self.assertEqual(report.total_rounds, 2)
first = report.rounds[0]
self.assertEqual(first.new_urls, 1)
self.assertEqual(first.new_queries, 2)
self.assertFalse(first.closed)
second = report.rounds[1]
self.assertEqual(second.new_urls, 0)
self.assertEqual(second.new_queries, 0)
self.assertEqual(second.new_contents, 2)
self.assertTrue(second.closed)
self.assertEqual(report.contents_seen, 3)
async def test_round_summary_dict_is_serialisable(self) -> None:
client = FakeResearchClient(web_results=[], chat_text="", describe_text="")
engine = ResearchEngine(client=client)
report = await engine.run("serialisable subject")
summary_dict = report.rounds[0].to_dict()
self.assertEqual(summary_dict["number"], 1)
self.assertTrue(summary_dict["closed"])
report_dict = report.to_dict()
self.assertEqual(report_dict["subject"], "serialisable subject")
self.assertEqual(report_dict["total_rounds"], 1)
self.assertTrue(report_dict["closed"])
class TestEngineInputValidation(unittest.IsolatedAsyncioTestCase):
async def test_empty_subject_raises_without_requests(self) -> None:
client = FakeResearchClient(web_results=[], chat_text="", describe_text="")
engine = ResearchEngine(client=client)
with self.assertRaises(ValueError) as ctx:
await engine.run(" \n\t ")
self.assertEqual(str(ctx.exception), "research subject must not be empty")
self.assertEqual(client.calls, [])
if __name__ == "__main__":
unittest.main()

View File

@ -1,8 +1,8 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import unittest
import urllib.request
from typing import Any, AsyncIterator
from unittest import mock
@ -15,10 +15,53 @@ PROBE_SUBJECT = "python asyncio"
RSEARCH_BASE_URL = "https://rsearch.app.molodetz.nl"
RUN_TIMEOUT_SECONDS = 60.0
SEARCH_FIXTURE: dict[str, Any] = {
"query": PROBE_SUBJECT,
"source": "duckduckgo",
"count": 2,
"success": True,
"error": None,
"results": [
{
"title": "asyncio documentation",
"url": "https://docs.python.org/3/library/asyncio.html",
"description": "Asynchronous I/O event loop.",
"source": "docs.python.org",
"extra": {},
"index": 0,
"content": "The asyncio module provides infrastructure for writing single-threaded concurrent code.",
},
{
"title": "asyncio in Python",
"url": "https://example.com/asyncio",
"description": "Tutorial on asyncio.",
"source": "example.com",
"extra": {},
"index": 1,
"content": "A tutorial covering the asyncio event loop and coroutines.",
},
],
}
class TestLiveResearchProbe(unittest.TestCase):
def test_bounded_probe_runs_against_live_rsearch_api(self) -> None:
class _FakeResponse:
def __init__(self, status: int, body: bytes) -> None:
self.status = status
self._body = body
def __enter__(self) -> "_FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
def read(self) -> bytes:
return self._body
class TestBoundedOfflineProbe(unittest.TestCase):
def test_bounded_probe_runs_against_mocked_transport_only(self) -> None:
config = ResearchConfig(
base_url=RSEARCH_BASE_URL,
max_concurrency=2,
@ -30,13 +73,11 @@ class TestLiveResearchProbe(unittest.TestCase):
frontier = QueryFrontier(PROBE_SUBJECT)
requested: list[str] = []
original_urlopen = urllib.request.urlopen
def recording_urlopen(request: urllib.request.Request, timeout: float | None = None) -> Any:
def fake_urlopen(request: Any, timeout: float | None = None) -> _FakeResponse:
requested.append(request.get_full_url())
return original_urlopen(request, timeout=timeout)
return _FakeResponse(200, json.dumps(SEARCH_FIXTURE).encode())
with mock.patch("urllib.request.urlopen", side_effect=recording_urlopen):
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
first = asyncio.run(self._bounded_run(client, frontier))
first_request_count = len(requested)
second = asyncio.run(self._bounded_run(client, frontier))
@ -68,3 +109,4 @@ class TestLiveResearchProbe(unittest.TestCase):
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,276 @@
# retoor <retoor@molodetz.nl>
import unittest
from typing import Any, AsyncIterator
from typosaurus_sandbox.research.client import RsearchError
from typosaurus_sandbox.research.config import ResearchConfig
from typosaurus_sandbox.research.envelopes import ChatResponse, DeepReport, DescribeResponse, SearchResponse, SearchResult
from typosaurus_sandbox.research.frontier import QueryFrontier
from typosaurus_sandbox.research.pipeline import (
Extraction,
ResearchPipeline,
WorkItem,
apply_extraction,
extract_response,
)
class StubResearchClient:
def __init__(self) -> None:
self.config = ResearchConfig(max_concurrency=4, default_count=5)
self.cache_hits: dict[tuple[str, str], Any] = {}
self.error_on: set[tuple[str, str]] = set()
self.chat_response = ChatResponse(response="chat answer")
self.describe_response = DescribeResponse(description="described page")
self.calls: list[tuple[str, str]] = []
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,
) -> Any:
if type == "images":
return self.cache_hits.get(("images", query))
return self.cache_hits.get(("web", query))
def describe_cached(self, url: str) -> Any:
return self.cache_hits.get(("describe", url))
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.calls.append(("search", query))
if ("search", query) in self.error_on:
raise RsearchError("search failed", 503)
return SearchResponse(
query=query,
source="duckduckgo",
count=1,
success=True,
results=[SearchResult(title=query, url=f"https://example.com/{query}", description="details", content="body")],
)
async def chat(self, prompt: str, *, system: str | None = None, json_mode: bool = False, cache: bool = True) -> ChatResponse:
self.calls.append(("chat", prompt))
if ("chat", prompt) in self.error_on:
raise RsearchError("chat failed", 400)
return self.chat_response
async def describe(self, url: str) -> DescribeResponse:
self.calls.append(("describe", url))
if ("describe", url) in self.error_on:
raise RsearchError("describe failed", 500)
return self.describe_response
class TestExtractResponse(unittest.TestCase):
def test_web_results_extract_urls_seeds_and_content(self) -> None:
item = WorkItem("web", "query")
response = SearchResponse(
query="query",
source="duckduckgo",
count=2,
success=True,
results=[
SearchResult(title="First", url="https://a.example/1", description="First details", content="first body"),
SearchResult(title="Second", url="https://b.example/2", description="Second details", content=None),
],
)
extraction = extract_response(item, response)
self.assertEqual(extraction.urls, ("https://a.example/1", "https://b.example/2"))
self.assertEqual(
extraction.query_seeds,
(
("First", "title"),
("First details", "description"),
("Second", "title"),
("Second details", "description"),
),
)
self.assertEqual(extraction.content_texts, ("first body",))
def test_ai_response_adds_content_seed_and_urls(self) -> None:
item = WorkItem("web", "query")
response = SearchResponse(
query="query",
source="ai",
count=0,
success=True,
results=[],
ai_response="Overview at https://docs.example.org/x and https://blog.example.org/y.",
)
extraction = extract_response(item, response)
self.assertEqual(extraction.urls, ("https://docs.example.org/x", "https://blog.example.org/y"))
self.assertEqual(
extraction.query_seeds,
(("Overview at https://docs.example.org/x and https://blog.example.org/y.", "ai_response"),),
)
self.assertEqual(
extraction.content_texts,
("Overview at https://docs.example.org/x and https://blog.example.org/y.",),
)
def test_deep_report_sources_and_markdown_extracted(self) -> None:
item = WorkItem("web", "query")
response = SearchResponse(
query="query",
source="google",
count=1,
success=True,
results=[],
deep=DeepReport(
query="query",
markdown="# Deep\n\nSee https://deep.example.org/report for details.",
sources=[SearchResult(title="Deep source", url="https://deep.example.org/source", description="Deep details")],
),
)
extraction = extract_response(item, response)
self.assertEqual(extraction.urls, ("https://deep.example.org/source", "https://deep.example.org/report"))
self.assertEqual(
extraction.query_seeds,
(("Deep source", "title"), ("Deep details", "description")),
)
self.assertEqual(extraction.content_texts, ("# Deep\n\nSee https://deep.example.org/report for details.",))
def test_chat_response_extracts_content_seed_and_urls(self) -> None:
item = WorkItem("chat", "prompt")
response = ChatResponse(response="Answer at https://chat.example.org/a.")
extraction = extract_response(item, response)
self.assertEqual(extraction.urls, ("https://chat.example.org/a",))
self.assertEqual(extraction.query_seeds, (("Answer at https://chat.example.org/a.", "chat"),))
self.assertEqual(extraction.content_texts, ("Answer at https://chat.example.org/a.",))
def test_describe_response_extracts_description_seed_and_urls(self) -> None:
item = WorkItem("describe", "https://page.example.org/x")
response = DescribeResponse(description="Image shows a cat. More at https://gallery.example.org/cat.")
extraction = extract_response(item, response)
self.assertEqual(extraction.urls, ("https://gallery.example.org/cat",))
self.assertEqual(
extraction.query_seeds,
(("Image shows a cat. More at https://gallery.example.org/cat.", "describe"),),
)
self.assertEqual(extraction.content_texts, ("Image shows a cat. More at https://gallery.example.org/cat.",))
class TestApplyExtraction(unittest.IsolatedAsyncioTestCase):
async def test_registers_each_kind_and_returns_counts(self) -> None:
frontier = QueryFrontier()
extraction = Extraction(
urls=("https://x.example/1", "https://y.example/2"),
query_seeds=(("variant one", "title"), ("variant two", "description")),
content_texts=("body one", "body two"),
)
self.assertEqual(apply_extraction(frontier, extraction), (2, 2, 2))
self.assertEqual(apply_extraction(frontier, extraction), (0, 0, 0))
stats = frontier.snapshot()
self.assertEqual(stats.urls_seen, 2)
self.assertEqual(stats.queries_enqueued, 2)
self.assertEqual(stats.content_seen, 2)
class TestPipelineCacheProbe(unittest.IsolatedAsyncioTestCase):
async def test_web_probe_reflects_search_cache(self) -> None:
client = StubResearchClient()
frontier = QueryFrontier()
pipeline = ResearchPipeline(client, frontier)
self.assertFalse(pipeline._probe_cache(WorkItem("web", "alpha")))
client.cache_hits[("web", "alpha")] = SearchResponse(query="alpha", success=True)
self.assertTrue(pipeline._probe_cache(WorkItem("web", "alpha")))
async def test_images_probe_uses_images_search_cache(self) -> None:
client = StubResearchClient()
frontier = QueryFrontier()
pipeline = ResearchPipeline(client, frontier)
self.assertFalse(pipeline._probe_cache(WorkItem("images", "alpha")))
client.cache_hits[("images", "alpha")] = SearchResponse(query="alpha", success=True)
self.assertTrue(pipeline._probe_cache(WorkItem("images", "alpha")))
async def test_describe_probe_reflects_describe_cache(self) -> None:
client = StubResearchClient()
frontier = QueryFrontier()
pipeline = ResearchPipeline(client, frontier)
self.assertFalse(pipeline._probe_cache(WorkItem("describe", "https://z.example/page")))
client.cache_hits[("describe", "https://z.example/page")] = DescribeResponse(description="cached")
self.assertTrue(pipeline._probe_cache(WorkItem("describe", "https://z.example/page")))
async def test_chat_never_probes_cache(self) -> None:
client = StubResearchClient()
frontier = QueryFrontier()
pipeline = ResearchPipeline(client, frontier)
self.assertFalse(pipeline._probe_cache(WorkItem("chat", "question")))
class TestPipelineOutcomes(unittest.IsolatedAsyncioTestCase):
async def test_chat_cached_response_marks_outcome_cache_hit(self) -> None:
client = StubResearchClient()
client.chat_response = ChatResponse(response="cached answer", cached=True)
frontier = QueryFrontier()
pipeline = ResearchPipeline(client, frontier)
outcome = await pipeline.process(WorkItem("chat", "question"))
self.assertTrue(outcome.success)
self.assertTrue(outcome.cache_hit)
self.assertEqual(outcome.endpoint, "/chat")
async def test_search_error_produces_failure_outcome_and_pool_survives(self) -> None:
client = StubResearchClient()
client.error_on.add(("search", "bad"))
frontier = QueryFrontier()
async def items() -> AsyncIterator[WorkItem]:
yield WorkItem("web", "bad")
yield WorkItem("web", "good")
pipeline = ResearchPipeline(client, frontier)
report = await pipeline.run(items())
self.assertEqual(report.requests_succeeded, 1)
self.assertEqual(report.requests_failed, 1)
self.assertEqual(len(report.outcomes), 2)
failed = next(outcome for outcome in report.outcomes if not outcome.success)
self.assertEqual(failed.endpoint, "/search")
self.assertEqual(failed.status_code, 503)
self.assertEqual(failed.error, "search failed")
async def test_run_drains_all_four_content_types(self) -> None:
client = StubResearchClient()
frontier = QueryFrontier()
async def items() -> AsyncIterator[WorkItem]:
yield WorkItem("web", "alpha")
yield WorkItem("images", "alpha")
yield WorkItem("describe", "https://z.example/page")
yield WorkItem("chat", "question")
pipeline = ResearchPipeline(client, frontier)
report = await pipeline.run(items())
self.assertEqual(report.requests_succeeded, 4)
self.assertEqual(report.requests_failed, 0)
self.assertEqual(len(report.outcomes), 4)
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"})
if __name__ == "__main__":
unittest.main()

View File

@ -4,6 +4,7 @@ import asyncio
import unittest
from concurrent.futures import ThreadPoolExecutor
from typing import Any, AsyncIterator
from unittest import mock
from typosaurus_sandbox.research.cache import TTLCache
from typosaurus_sandbox.research.client import RsearchClient
@ -362,6 +363,20 @@ class TestTTLCacheBehaviour(unittest.TestCase):
for i in range(10):
self.assertIsNone(cache.get(f"key-{i}"))
def test_entry_expires_after_ttl_elapses(self) -> None:
cache = TTLCache[str]("expiry", ttl_seconds=10.0)
with mock.patch("typosaurus_sandbox.research.cache.time.monotonic", side_effect=[100.0, 100.0, 111.0]):
cache.set("key", "value")
self.assertEqual(cache.get("key"), "value")
self.assertIsNone(cache.get("key"))
def test_entry_expires_exactly_at_ttl_boundary(self) -> None:
cache = TTLCache[str]("boundary", ttl_seconds=10.0)
with mock.patch("typosaurus_sandbox.research.cache.time.monotonic", side_effect=[100.0, 100.0, 110.0]):
cache.set("key", "value")
self.assertEqual(cache.get("key"), "value")
self.assertIsNone(cache.get("key"))
class TestTTLCacheConcurrency(unittest.TestCase):
@ -502,5 +517,3 @@ if __name__ == "__main__":