feat: --competitors flag for auto-discovered comparison fan-out (#308)

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>
This commit is contained in:
Matt Van Horn
2026-04-22 21:28:36 -07:00
committed by GitHub
parent ff21243517
commit 5f054380c5
13 changed files with 1611 additions and 20 deletions
+131
View File
@@ -0,0 +1,131 @@
# ruff: noqa: E402
"""CLI parsing and validation for --competitors / --competitors-list."""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
def _parse(*argv: str):
parser = cli.build_parser()
args, _extra = parser.parse_known_args(argv)
return args
class CompetitorsCliTests(unittest.TestCase):
def test_flag_absent_returns_disabled(self):
args = _parse("Kanye West")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertFalse(enabled)
self.assertEqual(count, 0)
self.assertEqual(explicit, [])
def test_bare_flag_defaults_to_three(self):
args = _parse("Kanye West", "--competitors")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 3)
self.assertEqual(explicit, [])
def test_explicit_count(self):
args = _parse("OpenAI", "--competitors", "4")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 4)
self.assertEqual(explicit, [])
def test_explicit_list_preferred_over_discovery(self):
args = _parse(
"OpenAI",
"--competitors",
"--competitors-list",
"Anthropic,xAI,Google Gemini",
)
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 3)
self.assertEqual(explicit, ["Anthropic", "xAI", "Google Gemini"])
def test_explicit_list_without_flag_implies_enabled(self):
args = _parse("OpenAI", "--competitors-list", "Anthropic,xAI")
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 2)
self.assertEqual(explicit, ["Anthropic", "xAI"])
def test_list_whitespace_normalized(self):
args = _parse("OpenAI", "--competitors-list", " Anthropic , xAI , Gemini ")
_enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertEqual(count, 3)
self.assertEqual(explicit, ["Anthropic", "xAI", "Gemini"])
def test_zero_count_rejected(self):
args = _parse("Topic", "--competitors", "0")
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
cli.resolve_competitors_args(args)
self.assertEqual(cm.exception.code, 2)
self.assertIn("--competitors must be >= 1", err.getvalue())
def test_negative_count_rejected(self):
args = _parse("Topic", "--competitors", "-1")
with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()):
cli.resolve_competitors_args(args)
def test_over_max_count_clamps_with_warning(self):
args = _parse("Topic", "--competitors", "99")
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, cli.COMPETITORS_MAX)
self.assertEqual(explicit, [])
self.assertIn("clamping", err.getvalue())
def test_overlong_list_clamps_with_warning(self):
args = _parse(
"Topic",
"--competitors-list",
"A,B,C,D,E,F,G,H",
)
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, cli.COMPETITORS_MAX)
self.assertEqual(len(explicit), cli.COMPETITORS_MAX)
self.assertIn("clamping to", err.getvalue())
def test_list_count_mismatch_warns(self):
args = _parse(
"Topic",
"--competitors",
"5",
"--competitors-list",
"A,B",
)
err = io.StringIO()
with redirect_stderr(err):
enabled, count, explicit = cli.resolve_competitors_args(args)
self.assertTrue(enabled)
self.assertEqual(count, 2)
self.assertEqual(explicit, ["A", "B"])
self.assertIn("--competitors=5 ignored", err.getvalue())
def test_empty_list_rejected(self):
args = _parse("Topic", "--competitors-list", ",, ,")
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
cli.resolve_competitors_args(args)
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()