| 1 | """U2 - nomination ranking: cluster nominated items into named, seed-ranked |
| 2 | candidate topics. |
| 3 | |
| 4 | nominate_topics() is the contract between the nominate stage and the |
| 5 | enrichment fan-out: short distilled names ordered by seed velocity, |
| 6 | casefold-collision-safe, never padded past what the evidence supports. |
| 7 | Naming and junk flags are ALWAYS the deterministic topic_shape heuristics - |
| 8 | the engine-side LLM judge is gone; reasoning-model judgment happens in the |
| 9 | host-judged protocol (see test_discover_handoff.py / test_discover_mode.py). |
| 10 | """ |
| 11 | |
| 12 | from unittest import mock |
| 13 | |
| 14 | from lib import dates, pipeline, render, rerank, schema, topic_shape |
| 15 | |
| 16 | |
| 17 | def _item( |
| 18 | item_id: str, |
| 19 | source: str, |
| 20 | title: str, |
| 21 | *, |
| 22 | published_at: str = "2026-07-09", |
| 23 | engagement: dict[str, int | float] | None = None, |
| 24 | ) -> schema.SourceItem: |
| 25 | return schema.SourceItem( |
| 26 | item_id=item_id, |
| 27 | source=source, |
| 28 | title=title, |
| 29 | body=title, |
| 30 | url=f"https://{source}.example/{item_id}", |
| 31 | published_at=published_at, |
| 32 | engagement=engagement or {}, |
| 33 | snippet=f"Evidence about {title}", |
| 34 | ) |
| 35 | |
| 36 | |
| 37 | def _bundle(items: list[schema.SourceItem]) -> schema.RetrievalBundle: |
| 38 | bundle = schema.RetrievalBundle() |
| 39 | by_source: dict[str, list[schema.SourceItem]] = {} |
| 40 | for item in items: |
| 41 | by_source.setdefault(item.source, []).append(item) |
| 42 | for source, source_items in by_source.items(): |
| 43 | bundle.add_items("discovery-listings", source, source_items) |
| 44 | return bundle |
| 45 | |
| 46 | |
| 47 | def _query_plan(domain: str, sources: list[str]) -> schema.QueryPlan: |
| 48 | return schema.QueryPlan( |
| 49 | intent="breaking_news", |
| 50 | freshness_mode="breaking", |
| 51 | cluster_mode="story", |
| 52 | raw_topic=domain, |
| 53 | subqueries=[schema.SubQuery( |
| 54 | label="discovery-listings", |
| 55 | search_query=domain, |
| 56 | ranking_query=f"What is accelerating in {domain}?", |
| 57 | sources=sources, |
| 58 | )], |
| 59 | source_weights={source: 1.0 for source in sources}, |
| 60 | notes=["discover-mode", "listing-sweep"], |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | def _plan(domain: str, sources: list[str]) -> schema.DiscoveryPlan: |
| 65 | return schema.DiscoveryPlan( |
| 66 | domain=domain, category=None, subreddits=["all"], sources=sources, |
| 67 | ) |
| 68 | |
| 69 | |
| 70 | def test_nominations_ranked_by_seed_velocity(): |
| 71 | """A high-engagement recent story outranks a low-engagement one.""" |
| 72 | items = [ |
| 73 | _item("hot1", "hackernews", "GPT-6 rumors flood the valley", |
| 74 | engagement={"points": 900, "num_comments": 400}), |
| 75 | _item("cold1", "hackernews", "Minor framework patch notes released", |
| 76 | engagement={"points": 3, "num_comments": 1}), |
| 77 | ] |
| 78 | nominations = pipeline.nominate_topics( |
| 79 | _bundle(items), _query_plan("AI", ["hackernews"]), _plan("AI", ["hackernews"]), |
| 80 | to_date="2026-07-10", limit=10, |
| 81 | ) |
| 82 | assert nominations, "expected at least one nomination" |
| 83 | assert "GPT-6" in nominations[0].name |
| 84 | assert nominations[0].seed_score >= (nominations[-1].seed_score) |
| 85 | |
| 86 | |
| 87 | def test_nominations_dedupe_names_casefold(): |
| 88 | """Two clusters resolving to the same casefolded name yield one nomination.""" |
| 89 | items = [ |
| 90 | _item("a1", "hackernews", "OpenAI Agent SDK", |
| 91 | engagement={"points": 500, "num_comments": 100}), |
| 92 | _item("a2", "reddit", "openai agent sdk", |
| 93 | engagement={"score": 300, "num_comments": 80}), |
| 94 | ] |
| 95 | nominations = pipeline.nominate_topics( |
| 96 | _bundle(items), _query_plan("AI agents", ["hackernews", "reddit"]), |
| 97 | _plan("AI agents", ["hackernews", "reddit"]), |
| 98 | to_date="2026-07-10", limit=10, |
| 99 | ) |
| 100 | names = [nomination.name.casefold() for nomination in nominations] |
| 101 | assert len(names) == len(set(names)) |
| 102 | |
| 103 | |
| 104 | def test_fewer_clusters_than_limit_returns_all_without_padding(): |
| 105 | items = [ |
| 106 | _item("only1", "hackernews", "Quantum breakthrough announced", |
| 107 | engagement={"points": 250, "num_comments": 60}), |
| 108 | ] |
| 109 | nominations = pipeline.nominate_topics( |
| 110 | _bundle(items), _query_plan("quantum", ["hackernews"]), |
| 111 | _plan("quantum", ["hackernews"]), |
| 112 | to_date="2026-07-10", limit=8, |
| 113 | ) |
| 114 | assert 1 <= len(nominations) < 8 |
| 115 | |
| 116 | |
| 117 | def test_zero_velocity_clusters_are_dropped(): |
| 118 | """Items with no engagement produce no nomination at all.""" |
| 119 | items = [ |
| 120 | _item("dead1", "hackernews", "Silent post nobody engaged with", |
| 121 | engagement={"points": 0, "num_comments": 0}), |
| 122 | ] |
| 123 | nominations = pipeline.nominate_topics( |
| 124 | _bundle(items), _query_plan("AI", ["hackernews"]), _plan("AI", ["hackernews"]), |
| 125 | to_date="2026-07-10", limit=8, |
| 126 | ) |
| 127 | assert nominations == [] |
| 128 | |
| 129 | |
| 130 | # Real-run shapes from the motivating 2026-07 discovery sweep (see topic_shape). |
| 131 | ANECDOTE_TITLE = ( |
| 132 | "My coworker let an AI agent handle Slack replies while he was " |
| 133 | '"unavailable." It did not go well.' |
| 134 | ) |
| 135 | HELP_TITLE = "I need help starting to learn about AI agents" |
| 136 | |
| 137 | |
| 138 | def test_names_are_short_distilled_topics_not_raw_titles(): |
| 139 | """The nomination's name IS the enrichment search query and the |
| 140 | /last30days handoff - anecdote/question scaffolding must not leak into it.""" |
| 141 | items = [ |
| 142 | _item("story1", "hackernews", ANECDOTE_TITLE, |
| 143 | engagement={"points": 400, "comments": 100}), |
| 144 | ] |
| 145 | nominations = pipeline.nominate_topics( |
| 146 | _bundle(items), _query_plan("AI agents", ["hackernews"]), |
| 147 | _plan("AI agents", ["hackernews"]), |
| 148 | to_date="2026-07-10", limit=10, |
| 149 | ) |
| 150 | assert nominations |
| 151 | name = nominations[0].name |
| 152 | assert len(name.split()) <= 6 |
| 153 | assert not name.lower().startswith("my coworker") |
| 154 | |
| 155 | |
| 156 | def test_no_provider_names_are_distilled_and_deterministic(): |
| 157 | """Nomination is the pure-heuristic path, always: names come from |
| 158 | topic_shape.distill_topic_name, junk flags from is_junk_shape, and two |
| 159 | identical runs produce identical output - no LLM, no randomness.""" |
| 160 | items = [ |
| 161 | _item("story1", "hackernews", ANECDOTE_TITLE, |
| 162 | engagement={"points": 400, "comments": 100}), |
| 163 | _item("junk1", "hackernews", HELP_TITLE, |
| 164 | engagement={"points": 200, "comments": 50}), |
| 165 | ] |
| 166 | bundle = _bundle(items) |
| 167 | |
| 168 | def run() -> list[pipeline.Nomination]: |
| 169 | return pipeline.nominate_topics( |
| 170 | bundle, _query_plan("AI agents", ["hackernews"]), |
| 171 | _plan("AI agents", ["hackernews"]), |
| 172 | to_date="2026-07-10", limit=10, |
| 173 | ) |
| 174 | |
| 175 | first, second = run(), run() |
| 176 | assert [nomination.name for nomination in first] == [ |
| 177 | nomination.name for nomination in second |
| 178 | ] |
| 179 | assert [nomination.junk_shape for nomination in first] == [ |
| 180 | nomination.junk_shape for nomination in second |
| 181 | ] |
| 182 | |
| 183 | by_leader = {nomination.items[0].item_id: nomination for nomination in first} |
| 184 | story = by_leader["story1"] |
| 185 | assert story.name == topic_shape.distill_topic_name(ANECDOTE_TITLE) |
| 186 | assert story.junk_shape is False |
| 187 | assert by_leader["junk1"].junk_shape is True |
| 188 | # No engine judge -> no worthiness signal; ranking stays velocity-only |
| 189 | # (worthiness is host-supplied on the protocol resume leg only). |
| 190 | assert all(nomination.worthiness is None for nomination in first) |
| 191 | |
| 192 | |
| 193 | def test_nomination_carries_leader_summary_and_items(): |
| 194 | items = [ |
| 195 | _item("s1", "hackernews", "Rust rewrite of the Linux scheduler", |
| 196 | engagement={"points": 700, "num_comments": 250}), |
| 197 | ] |
| 198 | nominations = pipeline.nominate_topics( |
| 199 | _bundle(items), _query_plan("Linux", ["hackernews"]), _plan("Linux", ["hackernews"]), |
| 200 | to_date="2026-07-10", limit=8, |
| 201 | ) |
| 202 | assert nominations |
| 203 | top = nominations[0] |
| 204 | assert top.items and top.items[0].item_id == "s1" |
| 205 | assert top.summary |
| 206 | |
| 207 | |
| 208 | # --- casefold collision handling (relocated from the retired judge suite) ----- |
| 209 | # Short distilled names collide far more often than raw titles: distinct |
| 210 | # stories that share a lead entity must disambiguate (appending the later |
| 211 | # cluster's strongest non-shared entity token), while true duplicates dedupe. |
| 212 | |
| 213 | |
| 214 | def test_same_entity_clusters_disambiguate_instead_of_dropping(): |
| 215 | """Two DISTINCT stories whose titles distill to the same heuristic name |
| 216 | both survive: the later cluster's name gains its strongest non-shared |
| 217 | entity token.""" |
| 218 | items = [ |
| 219 | _item("launch1", "hackernews", |
| 220 | "Gemma 4 quietly wrecked every leaderboard chart overnight worldwide", |
| 221 | engagement={"points": 300, "comments": 50}), |
| 222 | _item("price1", "hackernews", |
| 223 | "Gemma 4 pricing revolt stuns skeptical enterprise procurement teams", |
| 224 | engagement={"points": 200, "comments": 40}), |
| 225 | ] |
| 226 | nominations = pipeline.nominate_topics( |
| 227 | _bundle(items), _query_plan("AI agents", ["hackernews"]), |
| 228 | _plan("AI agents", ["hackernews"]), |
| 229 | to_date="2026-07-10", limit=10, |
| 230 | ) |
| 231 | |
| 232 | assert len(nominations) == 2 |
| 233 | names = [n.name for n in nominations] |
| 234 | # Both long titles distill to the bare entity phrase "Gemma 4". |
| 235 | assert names[0] == "Gemma 4" |
| 236 | # Deterministic disambiguation: strongest non-shared entity token, |
| 237 | # alphabetical tie-break ("enterprise" over "pricing"/"revolt"/...). |
| 238 | assert names[1] == "Gemma 4 enterprise" |
| 239 | assert len({name.casefold() for name in names}) == 2 |
| 240 | assert [n.items[0].item_id for n in nominations] == ["launch1", "price1"] |
| 241 | |
| 242 | |
| 243 | def test_third_same_entity_cluster_survives_via_successive_tokens(): |
| 244 | """Three DISTINCT stories distilling to the same name all survive: when |
| 245 | cluster 3's first-choice suffix ("enterprise") collides with cluster 2's |
| 246 | already-disambiguated name, the next distinguishing token is tried instead |
| 247 | of silently dropping the story.""" |
| 248 | items = [ |
| 249 | _item("launch1", "hackernews", |
| 250 | "Gemma 4 quietly wrecked every leaderboard chart overnight worldwide", |
| 251 | engagement={"points": 300, "comments": 50}), |
| 252 | _item("price1", "hackernews", |
| 253 | "Gemma 4 pricing revolt stuns skeptical enterprise procurement teams", |
| 254 | engagement={"points": 200, "comments": 40}), |
| 255 | _item("tier1", "hackernews", |
| 256 | "Gemma 4 enterprise tier surcharge negotiations remain unresolved today", |
| 257 | engagement={"points": 150, "comments": 30}), |
| 258 | ] |
| 259 | nominations = pipeline.nominate_topics( |
| 260 | _bundle(items), _query_plan("AI agents", ["hackernews"]), |
| 261 | _plan("AI agents", ["hackernews"]), |
| 262 | to_date="2026-07-10", limit=10, |
| 263 | ) |
| 264 | |
| 265 | assert len(nominations) == 3 |
| 266 | names = [n.name for n in nominations] |
| 267 | # Cluster 3's strongest non-shared token vs cluster 1 is "enterprise" |
| 268 | # (alphabetical among count-1 ties), which is taken by cluster 2; the |
| 269 | # second token ("negotiations") rescues it with a unique name. |
| 270 | assert names == ["Gemma 4", "Gemma 4 enterprise", "Gemma 4 negotiations"] |
| 271 | assert len({name.casefold() for name in names}) == 3 |
| 272 | assert [n.items[0].item_id for n in nominations] == ["launch1", "price1", "tier1"] |
| 273 | |
| 274 | |
| 275 | def test_indistinguishable_distinct_representative_clusters_still_dedupe(): |
| 276 | """Two colliding clusters with distinct representatives but NO |
| 277 | distinguishing entity token anywhere dedupe to one nomination instead of |
| 278 | crashing or emitting duplicate names.""" |
| 279 | items = [ |
| 280 | _item("bench1", "hackernews", "Gemma 4 benchmarks", |
| 281 | engagement={"points": 300, "comments": 50}), |
| 282 | _item("bench2", "reddit", "Gemma 4 benchmarks", |
| 283 | engagement={"score": 200, "num_comments": 40}), |
| 284 | ] |
| 285 | |
| 286 | def fake_cluster(candidates, plan): |
| 287 | by_leader = { |
| 288 | item.item_id: candidate |
| 289 | for candidate in candidates |
| 290 | for item in candidate.source_items |
| 291 | } |
| 292 | primary, secondary = by_leader["bench1"], by_leader["bench2"] |
| 293 | return [ |
| 294 | schema.Cluster( |
| 295 | cluster_id="cluster-1", |
| 296 | title=primary.title, |
| 297 | candidate_ids=[primary.candidate_id], |
| 298 | representative_ids=[primary.candidate_id], |
| 299 | sources=["hackernews"], |
| 300 | score=primary.final_score, |
| 301 | ), |
| 302 | schema.Cluster( |
| 303 | cluster_id="cluster-2", |
| 304 | title=secondary.title, |
| 305 | candidate_ids=[secondary.candidate_id], |
| 306 | representative_ids=[secondary.candidate_id], |
| 307 | sources=["reddit"], |
| 308 | score=secondary.final_score, |
| 309 | ), |
| 310 | ] |
| 311 | |
| 312 | with mock.patch.object(pipeline, "cluster_candidates", side_effect=fake_cluster): |
| 313 | nominations = pipeline.nominate_topics( |
| 314 | _bundle(items), |
| 315 | _query_plan("AI agents", ["hackernews", "reddit"]), |
| 316 | _plan("AI agents", ["hackernews", "reddit"]), |
| 317 | to_date="2026-07-10", limit=10, |
| 318 | ) |
| 319 | |
| 320 | assert len(nominations) == 1 |
| 321 | assert nominations[0].name == "Gemma 4 benchmarks" |
| 322 | |
| 323 | |
| 324 | def test_clusters_sharing_a_representative_dedupe_to_one(): |
| 325 | """A name collision between clusters that share a representative candidate |
| 326 | is the same story twice: the later cluster is dropped, not renamed.""" |
| 327 | items = [ |
| 328 | _item("bench1", "hackernews", "Gemma 4 benchmarks", |
| 329 | engagement={"points": 300, "comments": 50}), |
| 330 | _item("bench2", "reddit", "gemma 4 benchmarks", |
| 331 | engagement={"score": 200, "num_comments": 40}), |
| 332 | ] |
| 333 | |
| 334 | def fake_cluster(candidates, plan): |
| 335 | primary = next(c for c in candidates if c.title == "Gemma 4 benchmarks") |
| 336 | secondary = next(c for c in candidates if c.title == "gemma 4 benchmarks") |
| 337 | return [ |
| 338 | schema.Cluster( |
| 339 | cluster_id="cluster-1", |
| 340 | title=primary.title, |
| 341 | candidate_ids=[primary.candidate_id], |
| 342 | representative_ids=[primary.candidate_id], |
| 343 | sources=["hackernews"], |
| 344 | score=primary.final_score, |
| 345 | ), |
| 346 | schema.Cluster( |
| 347 | cluster_id="cluster-2", |
| 348 | title=secondary.title, |
| 349 | candidate_ids=[secondary.candidate_id, primary.candidate_id], |
| 350 | representative_ids=[primary.candidate_id], |
| 351 | sources=["hackernews", "reddit"], |
| 352 | score=secondary.final_score, |
| 353 | ), |
| 354 | ] |
| 355 | |
| 356 | with mock.patch.object(pipeline, "cluster_candidates", side_effect=fake_cluster): |
| 357 | nominations = pipeline.nominate_topics( |
| 358 | _bundle(items), |
| 359 | _query_plan("AI agents", ["hackernews", "reddit"]), |
| 360 | _plan("AI agents", ["hackernews", "reddit"]), |
| 361 | to_date="2026-07-10", limit=10, |
| 362 | ) |
| 363 | |
| 364 | assert len(nominations) == 1 |
| 365 | assert nominations[0].name == "Gemma 4 benchmarks" |
| 366 | |
| 367 | |
| 368 | # --- U3 leg 1: nominate-only judge pool --------------------------------------- |
| 369 | |
| 370 | |
| 371 | def test_nominate_topic_pool_pairs_nominations_with_cluster_ids(): |
| 372 | """The pool variant returns the SAME nominations as nominate_topics, each |
| 373 | paired with its non-empty, unique source cluster id.""" |
| 374 | items = [ |
| 375 | _item("hot1", "hackernews", "GPT-6 rumors flood the valley", |
| 376 | engagement={"points": 900, "num_comments": 400}), |
| 377 | _item("warm1", "hackernews", "Quantum error correction milestone announced", |
| 378 | engagement={"points": 250, "num_comments": 60}), |
| 379 | ] |
| 380 | bundle = _bundle(items) |
| 381 | query_plan = _query_plan("AI", ["hackernews"]) |
| 382 | plan = _plan("AI", ["hackernews"]) |
| 383 | pool = pipeline.nominate_topic_pool( |
| 384 | bundle, query_plan, plan, to_date="2026-07-10", limit=10, |
| 385 | ) |
| 386 | nominations = pipeline.nominate_topics( |
| 387 | bundle, query_plan, plan, to_date="2026-07-10", limit=10, |
| 388 | ) |
| 389 | assert [nomination for nomination, _cluster_id in pool] == nominations |
| 390 | cluster_ids = [cluster_id for _nomination, cluster_id in pool] |
| 391 | assert all(cluster_ids) |
| 392 | assert len(set(cluster_ids)) == len(cluster_ids) |
| 393 | |
| 394 | |
| 395 | # Ten clearly distinct stories: enough clusters to prove the judge pool |
| 396 | # reaches past the ENRICH_LIMIT cut that the one-shot path applies. |
| 397 | POOL_TITLES = [ |
| 398 | "Kestrel avionics merger approved by regulators", |
| 399 | "Sourdough robot bakery raises series B", |
| 400 | "Quantum error correction milestone announced", |
| 401 | "Rust rewrite of the Linux scheduler lands", |
| 402 | "Solar balcony panels top German sales charts", |
| 403 | "Deep sea mining moratorium gains momentum", |
| 404 | "Vertical farming startup exits stealth with kale gigafactory", |
| 405 | "Formula E battery swap trial starts in Rome", |
| 406 | "Open source weather models beat commercial forecasts", |
| 407 | "Cheese aging caves converted to data centers", |
| 408 | ] |
| 409 | |
| 410 | |
| 411 | def _hn_raw(item_id: str, title: str, points: int, comments: int, *, date: str = "2026-07-09") -> dict: |
| 412 | return { |
| 413 | "id": item_id, |
| 414 | "title": title, |
| 415 | "url": f"https://example.com/{item_id}", |
| 416 | "hn_url": f"https://news.ycombinator.com/item?id={item_id}", |
| 417 | # Distinct authors: weighted_rrf caps the pool per author, and this |
| 418 | # fixture exists to overflow the ENRICH_LIMIT cut, not that cap. |
| 419 | "author": f"author-{item_id}", |
| 420 | "date": date, |
| 421 | "engagement": {"points": points, "comments": comments}, |
| 422 | "relevance": 0.9, |
| 423 | } |
| 424 | |
| 425 | |
| 426 | def _nominate_only(items_by_source: dict[str, list[dict]], **kwargs) -> "pipeline.DiscoverNominateResult": |
| 427 | def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True): |
| 428 | return items_by_source.get(source, []), None |
| 429 | |
| 430 | with mock.patch.object( |
| 431 | pipeline, "available_sources", return_value=list(items_by_source), |
| 432 | ), mock.patch.object( |
| 433 | pipeline, "_fetch_discovery_source", side_effect=fake_fetch, |
| 434 | ): |
| 435 | return pipeline.run_discover_nominate( |
| 436 | domain=kwargs.pop("domain", ""), |
| 437 | config={}, |
| 438 | as_of_date="2026-07-10", |
| 439 | **kwargs, |
| 440 | ) |
| 441 | |
| 442 | |
| 443 | def _full_pool_items() -> dict[str, list[dict]]: |
| 444 | return {"hackernews": [ |
| 445 | _hn_raw(f"hn{index}", title, 900 - index * 40, 120 - index * 5) |
| 446 | for index, title in enumerate(POOL_TITLES) |
| 447 | ]} |
| 448 | |
| 449 | |
| 450 | def test_nominate_only_emits_full_judge_pool_beyond_enrich_cut(): |
| 451 | """Leg 1 hands the host the FULL judge pool (up to JUDGE_POOL_LIMIT), not |
| 452 | the one-shot path's post-cut enrichment list.""" |
| 453 | result = _nominate_only(_full_pool_items()) |
| 454 | assert len(result.pool) > pipeline.ENRICH_LIMIT |
| 455 | assert len(result.pool) <= rerank.JUDGE_POOL_LIMIT |
| 456 | names = [nomination.name.casefold() for nomination, _cluster_id in result.pool] |
| 457 | assert len(names) == len(set(names)) |
| 458 | |
| 459 | |
| 460 | def test_nominate_only_is_heuristic_deterministic_and_provider_free(): |
| 461 | """Leg 1 never resolves a reasoning provider: names/junk flags are the |
| 462 | deterministic topic_shape heuristics and two runs agree exactly.""" |
| 463 | items = _full_pool_items() |
| 464 | items["hackernews"].append( |
| 465 | _hn_raw("junk1", HELP_TITLE, 400, 90) |
| 466 | ) |
| 467 | with mock.patch.object(pipeline.providers, "resolve_runtime") as resolve: |
| 468 | first = _nominate_only(items) |
| 469 | second = _nominate_only(items) |
| 470 | resolve.assert_not_called() |
| 471 | assert [ |
| 472 | (nomination.name, nomination.junk_shape, cluster_id) |
| 473 | for nomination, cluster_id in first.pool |
| 474 | ] == [ |
| 475 | (nomination.name, nomination.junk_shape, cluster_id) |
| 476 | for nomination, cluster_id in second.pool |
| 477 | ] |
| 478 | assert all(nomination.worthiness is None for nomination, _ in first.pool) |
| 479 | junk_flags = { |
| 480 | nomination.items[0].item_id: nomination.junk_shape |
| 481 | for nomination, _ in first.pool |
| 482 | } |
| 483 | assert junk_flags.get("junk1") is True |
| 484 | |
| 485 | |
| 486 | def test_nominate_only_window_matches_sweep_dates(): |
| 487 | result = _nominate_only(_full_pool_items(), lookback_days=7) |
| 488 | assert (result.from_date, result.to_date) == dates.get_date_range( |
| 489 | 7, as_of_date="2026-07-10" |
| 490 | ) |
| 491 | |
| 492 | |
| 493 | def test_nominate_only_never_enriches_or_researches(): |
| 494 | """No enrichment, no full research sub-runs on leg 1 - the host judges |
| 495 | the seed evidence first.""" |
| 496 | with mock.patch.object(pipeline, "enrich_nominations") as enrich, \ |
| 497 | mock.patch.object(pipeline, "run") as full_run: |
| 498 | result = _nominate_only(_full_pool_items()) |
| 499 | enrich.assert_not_called() |
| 500 | full_run.assert_not_called() |
| 501 | assert result.pool |
| 502 | |
| 503 | |
| 504 | def test_nominate_only_zero_pool_renders_nothing_solid_brief(): |
| 505 | """An empty sweep short-circuits to the existing nothing-solid brief.""" |
| 506 | result = _nominate_only({"hackernews": []}) |
| 507 | assert result.pool == [] |
| 508 | report = pipeline.nominate_nothing_solid_report(result) |
| 509 | assert report.outcome == "nothing-solid" |
| 510 | assert report.topics == [] |
| 511 | assert (report.range_from, report.range_to) == (result.from_date, result.to_date) |
| 512 | rendered = render.render_discovery(report) |
| 513 | assert "Nothing solid this window." in rendered |
| 514 |