返回 last30days-skill
test_discover_nominate_topics.py
根目录 / tests / test_discover_nominate_topics.py
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
514 lines PYTHON