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()
+160
View File
@@ -0,0 +1,160 @@
# 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()
+152
View File
@@ -0,0 +1,152 @@
# ruff: noqa: E402
"""Tests for scripts/lib/competitors.discover_competitors."""
from __future__ import annotations
import io
import sys
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 competitors
def _serp(items: list[tuple[str, str]]) -> list[dict]:
"""Build a minimal SERP items list from (title, snippet) pairs."""
return [
{"title": title, "snippet": snippet, "url": "https://example.test/"}
for title, snippet in items
]
OPENAI_SERP = _serp(
[
("OpenAI vs Anthropic vs xAI: which is better?", "xAI and Anthropic now compete directly with OpenAI."),
("Top OpenAI alternatives in 2026", "Anthropic, Google Gemini, and xAI are the leading alternatives this year."),
("xAI and Anthropic challenge OpenAI dominance", "xAI and Anthropic push Google Gemini hard; xAI keeps shipping."),
("Anthropic vs xAI: head to head", "Anthropic and xAI trade punches; Google Gemini is not far behind."),
]
)
KANYE_SERP = _serp(
[
("Kanye West vs Drake: the feud explained", "Drake responded to Kanye with a diss track."),
("Top rappers of the decade: Kendrick Lamar, Drake, J Cole", "Kendrick Lamar released a new album; Drake toured Europe."),
("Drake and Kendrick Lamar trade shots", "J Cole stayed out of the Drake vs Kendrick Lamar feud."),
]
)
class CompetitorDiscoveryTests(unittest.TestCase):
def _run(self, serp: list[dict], topic: str, count: int = 3) -> list[str]:
config = {"BRAVE_API_KEY": "test-key"}
with mock.patch.object(
competitors.grounding, "web_search", return_value=(serp, {})
):
with redirect_stderr(io.StringIO()):
return competitors.discover_competitors(topic, count, config)
def test_openai_surfaces_anthropic_and_peers(self):
results = self._run(OPENAI_SERP, "OpenAI", count=3)
self.assertEqual(len(results), 3)
joined = " ".join(results)
self.assertIn("Anthropic", joined)
self.assertIn("xAI", joined)
# Should not surface the topic itself
self.assertNotIn("OpenAI", results)
self.assertFalse(
any("OpenAI" in entity for entity in results),
f"Topic token leaked into results: {results}",
)
def test_kanye_surfaces_rap_peers(self):
results = self._run(KANYE_SERP, "Kanye West", count=2)
self.assertEqual(len(results), 2)
joined = " ".join(results)
self.assertTrue(
"Drake" in joined and "Kendrick Lamar" in joined,
f"Expected Drake and Kendrick Lamar in {results}",
)
def test_empty_serp_returns_empty(self):
results = self._run([], "OpenAI", count=3)
self.assertEqual(results, [])
def test_no_backend_returns_empty(self):
err = io.StringIO()
with redirect_stderr(err):
results = competitors.discover_competitors("OpenAI", 3, config={})
self.assertEqual(results, [])
self.assertIn("No web search backend", err.getvalue())
def test_backend_error_returns_empty(self):
config = {"BRAVE_API_KEY": "test-key"}
def boom(*_args, **_kwargs):
raise RuntimeError("SERP provider offline")
err = io.StringIO()
with mock.patch.object(competitors.grounding, "web_search", side_effect=boom):
with redirect_stderr(err):
results = competitors.discover_competitors("OpenAI", 3, config)
self.assertEqual(results, [])
self.assertIn("Search failed", err.getvalue())
def test_topic_tokens_filtered(self):
"""Candidates overlapping topic tokens are rejected."""
serp = _serp(
[
("Open AI vs Anthropic", "Open AI, Anthropic, and Google lead."),
("OpenAI Alternatives: Anthropic", "Anthropic is a competitor to Open AI."),
]
)
results = self._run(serp, "OpenAI", count=3)
# "Open AI" shares the "openai" lowercased-concatenation? Actually tokenizer
# splits "Open AI" into ["open", "ai"]. Topic "OpenAI" tokenizes to ["openai"].
# They do not overlap at the token level, which is fine — the filter is
# best-effort. We only assert that bare "OpenAI" is filtered and real
# competitors still surface.
self.assertNotIn("OpenAI", results)
self.assertIn("Anthropic", results)
def test_deduplicates_case_insensitively(self):
serp = _serp(
[
("Anthropic vs Gemini", "anthropic is strong."),
("ANTHROPIC makes Claude", "Anthropic announced Claude 4."),
]
)
results = self._run(serp, "OpenAI", count=3)
# "Anthropic" should appear exactly once (first-seen capitalization wins).
anthropic_matches = [r for r in results if r.lower() == "anthropic"]
self.assertEqual(len(anthropic_matches), 1)
def test_count_one_returns_single(self):
results = self._run(OPENAI_SERP, "OpenAI", count=1)
self.assertEqual(len(results), 1)
def test_stopword_only_candidates_rejected(self):
serp = _serp(
[
("Top Alternatives", "Best Competitors and Top Tools."),
("Free Software Reviews", "Complete Guide to The Options."),
]
)
results = self._run(serp, "Widget", count=5)
self.assertEqual(
results, [],
f"Stopword-only phrases should not be returned: got {results}",
)
def test_count_zero_returns_empty(self):
results = self._run(OPENAI_SERP, "OpenAI", count=0)
self.assertEqual(results, [])
if __name__ == "__main__":
unittest.main()
+201
View File
@@ -0,0 +1,201 @@
# ruff: noqa: E402
"""Tests for render.render_comparison_multi and emit_comparison_output."""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
from lib import render, schema
def _build_report(topic: str, cluster_titles: list[str]) -> schema.Report:
query_plan = schema.QueryPlan(
intent="comparison",
freshness_mode="balanced_recent",
cluster_mode="debate",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic,
ranking_query=topic,
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
)
clusters: list[schema.Cluster] = []
candidates: list[schema.Candidate] = []
for idx, title in enumerate(cluster_titles):
candidate_id = f"{topic.lower().replace(' ', '-')}-c{idx}"
item = schema.SourceItem(
source="grounding",
item_id=f"g-{candidate_id}",
title=f"{title} evidence",
body=f"Body for {title}",
url=f"https://example.test/{candidate_id}",
snippet=f"Snippet for {title}",
published_at="2026-04-20",
)
candidate = schema.Candidate(
candidate_id=candidate_id,
item_id=item.item_id,
source="grounding",
title=item.title,
url=item.url,
snippet=item.snippet,
subquery_labels=["primary"],
native_ranks={"grounding": idx + 1},
local_relevance=0.8 - idx * 0.1,
freshness=5,
engagement=10,
source_quality=0.9,
rrf_score=0.6 - idx * 0.05,
sources=["grounding"],
source_items=[item],
final_score=80.0 - idx * 5,
)
candidates.append(candidate)
clusters.append(
schema.Cluster(
cluster_id=f"cl-{idx}",
title=title,
candidate_ids=[candidate_id],
representative_ids=[candidate_id],
score=80.0 - idx * 5,
sources=["grounding"],
)
)
return schema.Report(
topic=topic,
range_from="2026-03-23",
range_to="2026-04-22",
generated_at="2026-04-22T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock-planner",
rerank_model="mock-rerank",
),
query_plan=query_plan,
clusters=clusters,
ranked_candidates=candidates,
items_by_source={"grounding": [c.source_items[0] for c in candidates]},
errors_by_source={},
)
class RenderComparisonMultiTests(unittest.TestCase):
def test_three_entity_table(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop", "API pricing cut"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7 ship", "MCP rollout"])),
("xAI", _build_report("xAI", ["Grok 4 release", "Memphis cluster"])),
]
rendered = render.render_comparison_multi(reports)
# All three entities appear in the header
self.assertIn("OpenAI vs Anthropic vs xAI", rendered)
# Each entity has its own evidence section
self.assertIn("## OpenAI", rendered)
self.assertIn("## Anthropic", rendered)
self.assertIn("## xAI", rendered)
# Scaffold table header has a column per entity
self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
# Envelope scaffolding present
self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
def test_two_entity_table_has_two_columns(self):
reports = [
("Kanye West", _build_report("Kanye West", ["Donda 2 release"])),
("Drake", _build_report("Drake", ["For All The Dogs"])),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("| Dimension | Kanye West | Drake |", rendered)
self.assertIn("## Kanye West", rendered)
self.assertIn("## Drake", rendered)
def test_empty_clusters_renders_placeholder(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("ObscureCompetitor", _build_report("ObscureCompetitor", [])),
]
rendered = render.render_comparison_multi(reports)
self.assertIn("## ObscureCompetitor", rendered)
self.assertIn("no significant discussion this month", rendered)
# Main still has its cluster
self.assertIn("GPT-5 drop", rendered)
def test_warnings_aggregated_and_labeled(self):
report_a = _build_report("OpenAI", ["GPT-5 drop"])
report_b = _build_report("Anthropic", ["Claude 4.7"])
report_a.warnings.append("Brave quota exhausted")
report_b.warnings.append("Exa returned 0 results")
rendered = render.render_comparison_multi(
[("OpenAI", report_a), ("Anthropic", report_b)]
)
self.assertIn("[OpenAI] Brave quota exhausted", rendered)
self.assertIn("[Anthropic] Exa returned 0 results", rendered)
def test_raises_on_empty_input(self):
with self.assertRaises(ValueError):
render.render_comparison_multi([])
def test_context_emit(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
]
out = render.render_comparison_multi_context(reports)
self.assertIn("Comparison: OpenAI vs Anthropic", out)
self.assertIn("## OpenAI", out)
self.assertIn("## Anthropic", out)
self.assertIn("GPT-5 drop", out)
class EmitComparisonOutputTests(unittest.TestCase):
def test_json_emit_nests_per_entity(self):
reports = [
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
]
out = cli.emit_comparison_output(reports, emit="json")
payload = json.loads(out)
self.assertTrue(payload["comparison"])
self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"])
self.assertEqual(len(payload["reports"]), 2)
self.assertEqual(payload["reports"][0]["entity"], "OpenAI")
self.assertIn("topic", payload["reports"][0]["report"])
def test_compact_and_md_both_route_to_multi(self):
reports = [
("A", _build_report("A", ["Thing A"])),
("B", _build_report("B", ["Thing B"])),
]
compact = cli.emit_comparison_output(reports, emit="compact")
md = cli.emit_comparison_output(reports, emit="md")
self.assertIn("| Dimension | A | B |", compact)
self.assertEqual(compact, md)
def test_context_emit_goes_to_context_renderer(self):
reports = [
("A", _build_report("A", ["Thing A"])),
("B", _build_report("B", ["Thing B"])),
]
out = cli.emit_comparison_output(reports, emit="context")
self.assertIn("Comparison: A vs B", out)
def test_unsupported_emit_raises(self):
reports = [("A", _build_report("A", ["Thing A"]))]
with self.assertRaises(SystemExit):
cli.emit_comparison_output(reports, emit="xml")
if __name__ == "__main__":
unittest.main()