5f054380c5
Pass `--competitors` on a single-entity topic and the engine auto-discovers 2-6 peer entities via web search, runs the full pipeline on each in parallel, and returns one N-way comparison reusing the existing 9-axis Head-to-Head scaffold. `last30days OpenAI --competitors` resolves to Anthropic + xAI + Google Gemini; `last30days Kanye West --competitors` resolves to Drake + Kendrick Lamar + one more peer. - New CLI flags: --competitors, --competitors=N, --competitors-list - New scripts/lib/competitors.py — mirrors resolve.auto_resolve pattern (web search + deterministic text extraction, no internal LLM) - New scripts/lib/fanout.py — ThreadPoolExecutor orchestrator; per-entity failures degrade gracefully as long as >=2 entities survive - Multi-report render in scripts/lib/render.py reuses the comparison scaffold for the synthesis table - LAW 7-style stderr when no backend and no list, pointing the hosting reasoning model at --competitors-list - 38 new tests across CLI parsing, discovery, fanout, and rendering Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
5.2 KiB
Python
161 lines
5.2 KiB
Python
# ruff: noqa: E402
|
|
"""Tests for scripts/lib/fanout.run_competitor_fanout."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
from lib import fanout
|
|
|
|
|
|
def _fake_report(topic: str):
|
|
"""Build a lightweight Report stand-in. Tests only check identity."""
|
|
class _R:
|
|
pass
|
|
|
|
r = _R()
|
|
r.topic = topic
|
|
return r
|
|
|
|
|
|
class FanoutOrchestratorTests(unittest.TestCase):
|
|
def test_main_plus_two_competitors_all_succeed(self):
|
|
def main_runner():
|
|
return _fake_report("OpenAI")
|
|
|
|
def comp_runner(entity):
|
|
return _fake_report(entity)
|
|
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=main_runner,
|
|
competitors=["Anthropic", "xAI"],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
labels = [label for label, _ in results]
|
|
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
|
self.assertEqual(results[0][1].topic, "OpenAI")
|
|
self.assertEqual(results[1][1].topic, "Anthropic")
|
|
|
|
def test_one_competitor_failure_degrades_gracefully(self):
|
|
def main_runner():
|
|
return _fake_report("OpenAI")
|
|
|
|
def comp_runner(entity):
|
|
if entity == "BrokenCo":
|
|
raise RuntimeError("upstream offline")
|
|
return _fake_report(entity)
|
|
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=main_runner,
|
|
competitors=["Anthropic", "BrokenCo", "xAI"],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
labels = [label for label, _ in results]
|
|
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
|
self.assertIn("BrokenCo", err.getvalue())
|
|
self.assertIn("upstream offline", err.getvalue())
|
|
|
|
def test_main_topic_failure_leaves_only_competitors(self):
|
|
def main_runner():
|
|
raise RuntimeError("main exploded")
|
|
|
|
def comp_runner(entity):
|
|
return _fake_report(entity)
|
|
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=main_runner,
|
|
competitors=["Anthropic", "xAI"],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
labels = [label for label, _ in results]
|
|
self.assertEqual(labels, ["Anthropic", "xAI"])
|
|
self.assertIn("main exploded", err.getvalue())
|
|
|
|
def test_empty_competitor_list_runs_only_main(self):
|
|
def main_runner():
|
|
return _fake_report("OpenAI")
|
|
|
|
def comp_runner(_entity):
|
|
raise AssertionError("should not be called when competitors=[]")
|
|
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=main_runner,
|
|
competitors=[],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
|
|
|
def test_sub_runs_execute_in_parallel(self):
|
|
"""Wall clock should be closer to max(latency) than sum(latency)."""
|
|
delay = 0.2
|
|
call_count = 3 # main + 2 competitors
|
|
|
|
def make_runner(_label):
|
|
def runner():
|
|
time.sleep(delay)
|
|
return _fake_report(_label)
|
|
return runner
|
|
|
|
def comp_runner(entity):
|
|
return make_runner(entity)()
|
|
|
|
start = time.monotonic()
|
|
with redirect_stderr(io.StringIO()):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=make_runner("OpenAI"),
|
|
competitors=["Anthropic", "xAI"],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
elapsed = time.monotonic() - start
|
|
self.assertEqual(len(results), 3)
|
|
# Generous margin: parallel execution should finish well under
|
|
# sum(call_count * delay) == 0.6s. We accept anything under 0.5s.
|
|
self.assertLess(
|
|
elapsed, delay * call_count,
|
|
f"Expected parallel execution < {delay * call_count:.2f}s, "
|
|
f"got {elapsed:.2f}s (sub-runs likely serialized)",
|
|
)
|
|
|
|
def test_all_competitors_fail_leaves_main_only(self):
|
|
def main_runner():
|
|
return _fake_report("OpenAI")
|
|
|
|
def comp_runner(_entity):
|
|
raise RuntimeError("all offline")
|
|
|
|
with redirect_stderr(io.StringIO()):
|
|
results = fanout.run_competitor_fanout(
|
|
main_topic="OpenAI",
|
|
main_runner=main_runner,
|
|
competitors=["A", "B", "C"],
|
|
competitor_runner=comp_runner,
|
|
)
|
|
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|