返回 last30days-skill
test_discover_floor.py
根目录 / tests / test_discover_floor.py
1 """U4 - confidence floor: the fix for discovery's ranked-junk failure mode.
2
3 The named 2026-07-12 regression: on quiet windows ("sports", "AI") the sweep
4 ranked noise against noise and emitted five 1-like tweets as a trend list.
5 These tests pin the new contract: sub-floor evidence never ranks, and the
6 honest outcome is "nothing-solid" with the strongest weak signal named.
7 """
8
9 from unittest import mock
10
11 from lib import discovery_handoff, pipeline, render, rerank, schema
12
13
14 def _x_item(item_id: str, text: str, likes: int, *, date: str = "2026-07-09") -> dict:
15 return {
16 "id": item_id,
17 "text": text,
18 "url": f"https://x.com/example/status/{item_id}",
19 "author_handle": "example",
20 "date": date,
21 "engagement": {"likes": likes, "reposts": 0, "replies": 0, "quotes": 0},
22 "relevance": 0.9,
23 }
24
25
26 def _hn_item(item_id: str, title: str, points: int, comments: int, *, date: str = "2026-07-09") -> dict:
27 return {
28 "id": item_id,
29 "title": title,
30 "url": f"https://example.com/{item_id}",
31 "hn_url": f"https://news.ycombinator.com/item?id={item_id}",
32 "author": "example",
33 "date": date,
34 "engagement": {"points": points, "comments": comments},
35 "relevance": 0.9,
36 }
37
38
39 def _reddit_item(item_id: str, title: str, score: int, comments: int, *, date: str = "2026-07-09") -> dict:
40 return {
41 "id": item_id,
42 "title": title,
43 "url": f"https://reddit.com/r/example/comments/{item_id}",
44 "subreddit": "example",
45 "date": date,
46 "engagement": {"score": score, "num_comments": comments},
47 "selftext": title,
48 "relevance": 0.9,
49 }
50
51
52 def _run_discover_with(items_by_source: dict[str, list[dict]], **kwargs) -> schema.DiscoveryReport:
53 def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
54 return items_by_source.get(source, []), None
55
56 with mock.patch.object(
57 pipeline, "available_sources", return_value=list(items_by_source),
58 ), mock.patch.object(
59 pipeline, "_fetch_discovery_source", side_effect=fake_fetch,
60 ):
61 return pipeline.run_discover(
62 domain=kwargs.pop("domain", "sports"),
63 config={},
64 as_of_date="2026-07-10",
65 **kwargs,
66 )
67
68
69 def test_junk_corpus_returns_nothing_solid_not_ranked_noise():
70 """THE regression: five single-source 1-like tweets (the 'sports' corpus)
71 must produce an honest empty result, never a ranked junk list."""
72 report = _run_discover_with({
73 "x": [
74 _x_item("junk1", "Wii Sports nostalgia thread about sports", 1),
75 _x_item("junk2", "kids travel sports burnout post", 1),
76 _x_item("junk3", "motorsports vs stick and ball sports", 1),
77 _x_item("junk4", "midjourney skateboarder sports prompt", 1),
78 _x_item("junk5", "manga review mentioning sports matches", 1),
79 ],
80 })
81
82 assert report.topics == []
83 assert report.outcome == "nothing-solid"
84 assert report.weak_signal is not None
85 assert any("confidence floor" in warning for warning in report.warnings)
86
87
88 def test_strong_single_source_spike_clears_floor():
89 """A 1,084-point / 577-comment single-source HN thread (the '60% of US
90 consumers' case) is a real story and must rank."""
91 report = _run_discover_with(
92 {"hackernews": [_hn_item("big1", "Sixty percent of consumers say AI in sports ads is a turnoff", 1084, 577)]},
93 domain="sports",
94 )
95
96 assert report.outcome == "ok"
97 assert len(report.topics) == 1
98 assert "turnoff" in report.topics[0].name.lower() or report.topics[0].velocity_score > 0
99
100
101 def test_weak_single_source_item_stays_buried():
102 """A 30-upvote single-source post is not a trend."""
103 report = _run_discover_with(
104 {"reddit": [_reddit_item("meh1", "Mildly interesting sports take", 25, 4)]},
105 )
106
107 assert report.topics == []
108 assert report.outcome == "nothing-solid"
109 assert report.weak_signal is not None
110
111
112 def test_mixed_corpus_emits_only_floor_clearing_topics():
113 """Strong multi-source story ranks; 1-like junk is silently dropped."""
114 report = _run_discover_with({
115 "hackernews": [_hn_item("story1", "NBA finals collapse shocks sports world", 450, 200)],
116 "reddit": [_reddit_item("story1r", "NBA finals collapse shocks sports world", 900, 400)],
117 "x": [_x_item("junkA", "random sports meme", 1)],
118 })
119
120 assert report.outcome == "ok"
121 assert len(report.topics) >= 1
122 names = " ".join(topic.name.lower() for topic in report.topics)
123 assert "nba" in names or "finals" in names
124 assert all(topic.velocity_score > 0 for topic in report.topics)
125 # The 1-like junk never appears.
126 assert all("meme" not in topic.name.lower() for topic in report.topics)
127
128
129 def test_enriched_evidence_is_judged_not_seed_evidence():
130 """With enrich=True, a topic whose seed was thin but whose full-pipeline
131 corpus is rich clears the floor on the enriched evidence."""
132 seed = {"x": [_x_item("seed1", "quiet sports story gathering steam", 40)]}
133
134 def fake_run(*, topic, **_kwargs):
135 items = {
136 "reddit": [
137 schema.SourceItem(
138 item_id="e1", source="reddit", title=topic, body=topic,
139 url="https://reddit.com/r/x/1", published_at="2026-07-09",
140 engagement={"score": 800, "num_comments": 300}, snippet=topic,
141 ),
142 ],
143 "hackernews": [
144 schema.SourceItem(
145 item_id="e2", source="hackernews", title=topic, body=topic,
146 url="https://example.com/e2", published_at="2026-07-09",
147 engagement={"points": 400, "comments": 150}, snippet=topic,
148 ),
149 ],
150 }
151 return schema.Report(
152 topic=topic,
153 range_from="2026-06-10", range_to="2026-07-10",
154 generated_at="2026-07-10T00:00:00+00:00",
155 provider_runtime=schema.ProviderRuntime(
156 reasoning_provider="none",
157 planner_model="deterministic",
158 rerank_model="deterministic",
159 ),
160 query_plan=schema.QueryPlan(
161 intent="factual", freshness_mode="balanced_recent",
162 cluster_mode="none", raw_topic=topic, subqueries=[],
163 source_weights={},
164 ),
165 clusters=[], ranked_candidates=[],
166 items_by_source=items, errors_by_source={},
167 )
168
169 with mock.patch.object(pipeline, "run", side_effect=fake_run):
170 report = _run_discover_with(seed, enrich=True)
171
172 assert report.outcome == "ok"
173 assert len(report.topics) == 1
174 topic = report.topics[0]
175 # Judged on the enriched corpus: multi-source, enriched engagement.
176 assert set(topic.sources) == {"hackernews", "reddit"}
177 assert "evidence item" in topic.why_spiking
178
179
180 def test_passes_discovery_floor_policy():
181 floor = rerank.passes_discovery_floor
182 # Absolute junk gate.
183 assert not floor(source_count=1, engagement_total=1, item_count=1)
184 assert not floor(source_count=3, engagement_total=10, item_count=5)
185 assert not floor(source_count=2, engagement_total=500, item_count=0)
186 # Multi-source with modest engagement clears.
187 assert floor(source_count=2, engagement_total=30, item_count=2)
188 # Single-source needs a genuinely strong spike.
189 assert not floor(source_count=1, engagement_total=100, item_count=3)
190 assert floor(source_count=1, engagement_total=1600, item_count=1)
191
192
193 # --- U3 junk-shape gate ------------------------------------------------------
194 # Extends the frozen corpus above (existing cases stay byte-identical). A
195 # junk-shaped nomination (help-me/beginner/musing, per the host judge or
196 # topic_shape heuristics) loses the single-source engagement bypass, and its
197 # corroboration is counted against SEED listing sources - never the enriched
198 # corpus, which is multi-source for almost any topic that enriches cleanly.
199
200
201 def test_junk_shape_blocks_single_source_engagement_bypass():
202 """A 226-comment single-source 'help me' thread is a busy support thread,
203 not a story: junk shape disables the engagement bypass."""
204 report = _run_discover_with({
205 "reddit": [_reddit_item(
206 "junkhelp1", "Help me understand the new Karvella sports doping ruling", 30, 226,
207 )],
208 })
209
210 assert report.topics == []
211 assert report.outcome == "nothing-solid"
212 assert report.weak_signal is not None
213
214
215 def test_same_engagement_without_junk_shape_surfaces():
216 """The identical engagement in a statement shape is a genuine
217 single-source spike and must still rank (non-junk behavior unchanged)."""
218 report = _run_discover_with({
219 "reddit": [_reddit_item(
220 "story2", "Karvella sports doping ruling rocks the league", 30, 226,
221 )],
222 })
223
224 assert report.outcome == "ok"
225 assert len(report.topics) == 1
226
227
228 def test_junk_shape_with_two_seed_sources_surfaces():
229 """A junk-shaped story corroborated across two SEED listing sources
230 (reddit + hackernews) clears the floor."""
231 title = "Help me understand the Marseille sports betting collapse"
232 report = _run_discover_with({
233 "reddit": [_reddit_item("junk2a", title, 40, 30)],
234 "hackernews": [_hn_item("junk2b", title, 35, 20)],
235 })
236
237 assert report.outcome == "ok"
238 assert len(report.topics) == 1
239 assert set(report.topics[0].sources) == {"hackernews", "reddit"}
240
241
242 def test_junk_corroboration_counts_seed_sources_not_enriched_corpus():
243 """THE key case: a junk-shaped topic with ONE seed listing source must
244 fail the floor even when enrichment succeeded and returned a rich
245 multi-source corpus - a successful enrichment pass pulls a multi-source
246 corpus for almost any topic, so an enriched-count check would never bind."""
247 seed = {"reddit": [_reddit_item(
248 "junkseed1", "Help me understand the new Karvella sports doping ruling", 40, 226,
249 )]}
250
251 def fake_run(*, topic, **_kwargs):
252 items = {
253 "reddit": [
254 schema.SourceItem(
255 item_id="e1", source="reddit", title=topic, body=topic,
256 url="https://reddit.com/r/x/1", published_at="2026-07-09",
257 engagement={"score": 800, "num_comments": 300}, snippet=topic,
258 ),
259 ],
260 "hackernews": [
261 schema.SourceItem(
262 item_id="e2", source="hackernews", title=topic, body=topic,
263 url="https://example.com/e2", published_at="2026-07-09",
264 engagement={"points": 400, "comments": 150}, snippet=topic,
265 ),
266 ],
267 }
268 return schema.Report(
269 topic=topic,
270 range_from="2026-06-10", range_to="2026-07-10",
271 generated_at="2026-07-10T00:00:00+00:00",
272 provider_runtime=schema.ProviderRuntime(
273 reasoning_provider="none",
274 planner_model="deterministic",
275 rerank_model="deterministic",
276 ),
277 query_plan=schema.QueryPlan(
278 intent="factual", freshness_mode="balanced_recent",
279 cluster_mode="none", raw_topic=topic, subqueries=[],
280 source_weights={},
281 ),
282 clusters=[], ranked_candidates=[],
283 items_by_source=items, errors_by_source={},
284 )
285
286 with mock.patch.object(pipeline, "run", side_effect=fake_run):
287 report = _run_discover_with(seed, enrich=True)
288
289 assert report.topics == []
290 assert report.outcome == "nothing-solid"
291 assert report.weak_signal is not None
292
293
294 def test_weak_signal_prefers_non_junk_failure():
295 """A nothing-solid brief names the strongest NON-junk weak signal even
296 when a junk-shaped failure has higher velocity."""
297 report = _run_discover_with({
298 "reddit": [_reddit_item("junkfast1", "Help me pick my first sports bike", 30, 100)],
299 "hackernews": [_hn_item("slow1", "Zion Bay sports arena funding vote stalls", 20, 10)],
300 })
301
302 assert report.topics == []
303 assert report.outcome == "nothing-solid"
304 assert report.weak_signal is not None
305 assert "zion" in report.weak_signal.lower()
306
307
308 def test_weak_signal_named_when_all_failures_junk():
309 """When every sub-floor failure is junk-shaped, the brief still names one
310 (never empty when failures exist)."""
311 report = _run_discover_with({
312 "reddit": [
313 _reddit_item("alljunk1", "Help me pick my first sports bike", 40, 20),
314 _reddit_item("alljunk2", "Any advice on sports nutrition apps", 25, 15),
315 ],
316 })
317
318 assert report.topics == []
319 assert report.outcome == "nothing-solid"
320 assert report.weak_signal is not None
321
322
323 def test_one_shot_live_run_emits_heuristics_note_once(capsys):
324 """Every non-mock one-shot run must say LOUDLY (exactly once) that names
325 are deterministic heuristics with no angles, pointing at the host-judged
326 SKILL.md protocol - never at provider API keys (the engine-side judge is
327 gone; no key would change this path)."""
328 report = _run_discover_with(
329 {"hackernews": [_hn_item("big1", "Sixty percent of consumers say AI in sports ads is a turnoff", 1084, 577)]},
330 )
331
332 assert report.outcome == "ok"
333 err = capsys.readouterr().err
334 assert err.count("deterministic heuristics") == 1
335 assert "host-judged" in err
336 assert "SKILL.md" in err
337 for key_advice in ("API key", "GEMINI_API_KEY", "XAI_API_KEY",
338 "OPENROUTER_API_KEY", "OpenAI auth"):
339 assert key_advice not in err
340 # The one-shot path generates no angles at all, so no angle lines render.
341 assert all(
342 topic.podcast_angle is None and topic.x_article_angle is None
343 for topic in report.topics
344 )
345 rendered = render.render_discovery(report)
346 assert "**Podcast angle:**" not in rendered
347 assert "**X article angle:**" not in rendered
348
349
350 # --- Same-story fold + velocity rank order -----------------------------------
351 # Real-run regression (2026-07): "China open-weights AI strategy is winning"
352 # and "Chinese models" surfaced as two ranked topics quoting the IDENTICAL
353 # 1,635-vote comment. Survivors that share enriched evidence are the same
354 # story: fold them, keep the higher velocity, and rank by displayed velocity.
355
356 KESTREL_TITLE = "Kestrel Avionics Merger Approved"
357 SOURDOUGH_TITLE = "Sourdough Robot Bakery Funding"
358
359 _SHARED_COMMENT = {
360 "text": "The merger filings quietly admit the avionics unit was insolvent",
361 "score": 1635,
362 "author": "modelwatcher",
363 }
364
365
366 def _evidence_item(
367 item_id: str,
368 source: str,
369 title: str,
370 url: str,
371 *,
372 score: int = 500,
373 comments: int = 200,
374 top_comments: list[dict] | None = None,
375 ) -> schema.SourceItem:
376 engagement = (
377 {"score": score, "num_comments": comments}
378 if source == "reddit"
379 else {"points": score, "comments": comments}
380 )
381 return schema.SourceItem(
382 item_id=item_id, source=source, title=title, body=title,
383 url=url, published_at="2026-07-09",
384 engagement=engagement, snippet=title,
385 metadata={"top_comments": top_comments} if top_comments else {},
386 )
387
388
389 def _fake_report(topic: str, items: list[schema.SourceItem]) -> schema.Report:
390 by_source: dict[str, list[schema.SourceItem]] = {}
391 for item in items:
392 by_source.setdefault(item.source, []).append(item)
393 return schema.Report(
394 topic=topic,
395 range_from="2026-06-10", range_to="2026-07-10",
396 generated_at="2026-07-10T00:00:00+00:00",
397 provider_runtime=schema.ProviderRuntime(
398 reasoning_provider="none",
399 planner_model="deterministic",
400 rerank_model="deterministic",
401 ),
402 query_plan=schema.QueryPlan(
403 intent="factual", freshness_mode="balanced_recent",
404 cluster_mode="none", raw_topic=topic, subqueries=[],
405 source_weights={},
406 ),
407 clusters=[], ranked_candidates=[],
408 items_by_source=by_source, errors_by_source={},
409 )
410
411
412 def _run_discover_enriched(reports_by_key: dict[str, list[schema.SourceItem]]) -> schema.DiscoveryReport:
413 """Two strong seed stories (Kestrel first / higher seed velocity), each
414 enriched via a fake pipeline.run keyed on the topic name."""
415 seed = {"hackernews": [
416 _hn_item("k1", KESTREL_TITLE, 900, 400),
417 _hn_item("s1", SOURDOUGH_TITLE, 700, 300),
418 ]}
419
420 def fake_run(*, topic, **_kwargs):
421 for key, items in reports_by_key.items():
422 if key in topic.lower():
423 return _fake_report(topic, items)
424 raise AssertionError(f"unexpected enrichment topic: {topic!r}")
425
426 with mock.patch.object(pipeline, "run", side_effect=fake_run):
427 return _run_discover_with(seed, enrich=True)
428
429
430 def test_same_story_survivors_fold_to_higher_velocity_one(capsys):
431 """Two distinct-named survivors quoting the IDENTICAL top comment (and
432 sharing 2 evidence URLs) are one story: only the higher-velocity one
433 ships, a fold line reaches stderr, ranks are contiguous from 1, and the
434 surviving topic ships without engine-written angles."""
435 report = _run_discover_enriched({
436 "kestrel": [
437 _evidence_item("ka", "reddit", KESTREL_TITLE,
438 "https://reddit.com/r/aero/comments/shared1",
439 score=900, comments=300, top_comments=[_SHARED_COMMENT]),
440 _evidence_item("kb", "hackernews", KESTREL_TITLE,
441 "https://news.example.com/shared2",
442 score=500, comments=200),
443 ],
444 "sourdough": [
445 _evidence_item("sa", "reddit", SOURDOUGH_TITLE,
446 "https://reddit.com/r/aero/comments/shared1",
447 score=300, comments=100, top_comments=[_SHARED_COMMENT]),
448 _evidence_item("sb", "hackernews", SOURDOUGH_TITLE,
449 "https://news.example.com/shared2",
450 score=200, comments=80),
451 ],
452 })
453
454 assert report.outcome == "ok"
455 assert len(report.topics) == 1
456 survivor = report.topics[0]
457 assert survivor.rank == 1
458 assert "kestrel" in survivor.name.lower()
459 err = capsys.readouterr().err
460 assert "folded duplicate story" in err
461 assert survivor.podcast_angle is None
462 assert survivor.x_article_angle is None
463
464
465 def test_distinct_stories_do_not_fold():
466 """No shared URLs, different comments: both genuinely distinct stories
467 survive with contiguous ranks."""
468 report = _run_discover_enriched({
469 "kestrel": [
470 _evidence_item("ka", "reddit", KESTREL_TITLE,
471 "https://reddit.com/r/aero/comments/k1",
472 score=900, comments=300,
473 top_comments=[{"text": "Regulators folded like a cheap suit here", "score": 40, "author": "a"}]),
474 _evidence_item("kb", "hackernews", KESTREL_TITLE,
475 "https://news.example.com/k2", score=500, comments=200),
476 ],
477 "sourdough": [
478 _evidence_item("sa", "reddit", SOURDOUGH_TITLE,
479 "https://reddit.com/r/bread/comments/s1",
480 score=300, comments=100,
481 top_comments=[{"text": "The starter culture is doing the heavy lifting", "score": 30, "author": "b"}]),
482 _evidence_item("sb", "hackernews", SOURDOUGH_TITLE,
483 "https://news.example.com/s2", score=200, comments=80),
484 ],
485 })
486
487 assert len(report.topics) == 2
488 assert [topic.rank for topic in report.topics] == [1, 2]
489
490
491 def test_rank_order_follows_displayed_velocity():
492 """Seed order inverts enriched velocity: rank 1 must be the topic with the
493 higher DISPLAYED velocity, and rank values equal list positions."""
494 report = _run_discover_enriched({
495 # Kestrel is the stronger SEED story but enriches thin.
496 "kestrel": [
497 _evidence_item("ka", "reddit", KESTREL_TITLE,
498 "https://reddit.com/r/aero/comments/k1",
499 score=100, comments=50),
500 _evidence_item("kb", "hackernews", KESTREL_TITLE,
501 "https://news.example.com/k2", score=60, comments=20),
502 ],
503 "sourdough": [
504 _evidence_item("sa", "reddit", SOURDOUGH_TITLE,
505 "https://reddit.com/r/bread/comments/s1",
506 score=900, comments=300),
507 _evidence_item("sb", "hackernews", SOURDOUGH_TITLE,
508 "https://news.example.com/s2", score=500, comments=200),
509 ],
510 })
511
512 assert len(report.topics) == 2
513 assert [topic.rank for topic in report.topics] == [1, 2]
514 assert "sourdough" in report.topics[0].name.lower()
515 assert "kestrel" in report.topics[1].name.lower()
516 assert report.topics[0].velocity_score > report.topics[1].velocity_score
517
518
519 def test_url_only_overlap_folds_without_shared_comment(capsys):
520 """No top comments at all, but 3 shared evidence URLs: still one story."""
521 shared_urls = [
522 "https://reddit.com/r/aero/comments/shared1",
523 "https://reddit.com/r/aero/comments/shared2",
524 "https://news.example.com/shared3",
525 ]
526 report = _run_discover_enriched({
527 "kestrel": [
528 _evidence_item("ka", "reddit", KESTREL_TITLE, shared_urls[0], score=900, comments=300),
529 _evidence_item("kb", "reddit", KESTREL_TITLE, shared_urls[1], score=400, comments=100),
530 _evidence_item("kc", "hackernews", KESTREL_TITLE, shared_urls[2], score=500, comments=200),
531 ],
532 "sourdough": [
533 _evidence_item("sa", "reddit", SOURDOUGH_TITLE, shared_urls[0], score=300, comments=100),
534 _evidence_item("sb", "reddit", SOURDOUGH_TITLE, shared_urls[1], score=100, comments=40),
535 _evidence_item("sc", "hackernews", SOURDOUGH_TITLE, shared_urls[2], score=200, comments=80),
536 ],
537 })
538
539 assert len(report.topics) == 1
540 assert report.topics[0].rank == 1
541 assert "kestrel" in report.topics[0].name.lower()
542 assert "folded duplicate story" in capsys.readouterr().err
543
544
545 def test_single_shared_url_with_different_comments_does_not_fold():
546 """Exactly 1 shared URL and different top comments is corroboration
547 overlap, not the same story."""
548 report = _run_discover_enriched({
549 "kestrel": [
550 _evidence_item("ka", "reddit", KESTREL_TITLE,
551 "https://reddit.com/r/aero/comments/k1",
552 score=900, comments=300,
553 top_comments=[{"text": "Regulators folded like a cheap suit here", "score": 40, "author": "a"}]),
554 _evidence_item("kb", "hackernews", KESTREL_TITLE,
555 "https://news.example.com/shared", score=500, comments=200),
556 ],
557 "sourdough": [
558 _evidence_item("sa", "reddit", SOURDOUGH_TITLE,
559 "https://reddit.com/r/bread/comments/s1",
560 score=300, comments=100,
561 top_comments=[{"text": "The starter culture is doing the heavy lifting", "score": 30, "author": "b"}]),
562 _evidence_item("sb", "hackernews", SOURDOUGH_TITLE,
563 "https://news.example.com/shared", score=200, comments=80),
564 ],
565 })
566
567 assert len(report.topics) == 2
568 assert [topic.rank for topic in report.topics] == [1, 2]
569
570
571 def _fold_record(
572 name: str,
573 velocity: float,
574 urls: list[str],
575 comment: str | None = None,
576 ) -> dict:
577 """Minimal floor-survivor record: only the fields the fold reads."""
578 return {
579 "name": name,
580 "velocity_score": velocity,
581 "top_comment": comment,
582 "evidence_urls": urls,
583 }
584
585
586 def test_fold_three_way_chain_collapses_to_one_survivor(capsys):
587 """F18: after a replacement fold, the survivor re-scans to a fixpoint. A
588 kept, B kept, then C (highest velocity) shares the comment with A and two
589 URLs with B: one survivor, and BOTH folds are logged by name."""
590 a = _fold_record("Story A", 10.0, ["https://a/1", "https://a/2"],
591 comment="the shared 1,635-vote take")
592 b = _fold_record("Story B", 5.0, ["https://b/1", "https://b/2"])
593 c = _fold_record("Story C", 20.0, ["https://b/1", "https://b/2", "https://c/1"],
594 comment="the shared 1,635-vote take")
595
596 folded = pipeline._fold_same_story_records([a, b, c])
597
598 assert [record["name"] for record in folded] == ["Story C"]
599 err = capsys.readouterr().err
600 assert "folded duplicate story 'Story A' into 'Story C'" in err
601 assert "folded duplicate story 'Story B' into 'Story C'" in err
602
603
604 def test_fold_velocity_inversion_replaces_kept_twin_and_logs_names(capsys):
605 """F13: the first-processed LOWER-velocity twin is replaced by the
606 second-processed higher-velocity twin, and the log line names the right
607 direction (low folded INTO high)."""
608 low = _fold_record("Low velocity twin", 5.0, ["https://s/1", "https://s/2"])
609 high = _fold_record("High velocity twin", 9.0, ["https://s/1", "https://s/2"])
610
611 folded = pipeline._fold_same_story_records([low, high])
612
613 assert [record["name"] for record in folded] == ["High velocity twin"]
614 err = capsys.readouterr().err
615 assert (
616 "folded duplicate story 'Low velocity twin' into 'High velocity twin'"
617 in err
618 )
619
620
621 def test_passes_discovery_floor_junk_params():
622 floor = rerank.passes_discovery_floor
623 # Junk + single seed source: no engagement bypass, however huge.
624 assert not floor(source_count=1, engagement_total=999, item_count=3,
625 junk_shape=True, seed_source_count=1)
626 # Junk corroboration binds on SEED sources - a rich enriched corpus
627 # (source_count high) cannot rescue a single-seed-source junk topic.
628 assert not floor(source_count=5, engagement_total=999, item_count=10,
629 junk_shape=True, seed_source_count=1)
630 # Junk + seed corroboration >= FLOOR_MIN_SOURCES clears.
631 assert floor(source_count=1, engagement_total=30, item_count=2,
632 junk_shape=True, seed_source_count=2)
633 # Junk still needs the absolute engagement minimum.
634 assert not floor(source_count=2, engagement_total=10, item_count=2,
635 junk_shape=True, seed_source_count=2)
636 # Junk without a seed count falls back to the evidence source count -
637 # corroboration still required, bypass still off.
638 assert floor(source_count=2, engagement_total=30, item_count=2, junk_shape=True)
639 assert not floor(source_count=1, engagement_total=999, item_count=1, junk_shape=True)
640 # Non-junk behavior is unchanged, seed count present or not.
641 assert floor(source_count=1, engagement_total=1600, item_count=1,
642 junk_shape=False, seed_source_count=1)
643 assert floor(source_count=2, engagement_total=30, item_count=2,
644 junk_shape=False, seed_source_count=1)
645
646
647 # --- U4 leg 2 resume: host judgments, slots, floor, fold ----------------------
648 # The resume leg replays the SAME floor/fold/rank code path over judged rows:
649 # host-junk rows never contend for enrichment slots, heuristic-junk fallback
650 # rows keep the seed-corroboration rule, and every velocity/momentum figure is
651 # scored against the bundle's momentum window - never the resume-time clock.
652
653
654 def _seed_item(
655 item_id: str,
656 source: str,
657 title: str,
658 *,
659 points: int = 300,
660 comments: int = 40,
661 published_at: str = "2026-07-09",
662 ) -> schema.SourceItem:
663 engagement = (
664 {"score": points, "num_comments": comments}
665 if source == "reddit"
666 else {"points": points, "comments": comments}
667 )
668 return schema.SourceItem(
669 item_id=item_id,
670 source=source,
671 title=title,
672 body=title,
673 url=f"https://{source}.example/{item_id}",
674 published_at=published_at,
675 engagement=engagement,
676 snippet=f"Evidence about {title}",
677 )
678
679
680 def _bundle_row(
681 nomination_id: str,
682 name: str,
683 items: list[schema.SourceItem],
684 *,
685 heuristic_junk: bool = False,
686 ) -> discovery_handoff.BundleNomination:
687 return discovery_handoff.BundleNomination(
688 nomination_id=nomination_id,
689 nomination=pipeline.Nomination(
690 name=name,
691 seed_score=50.0,
692 items=items,
693 summary=f"Summary of {name}",
694 junk_shape=heuristic_junk,
695 worthiness=None,
696 ),
697 cluster_id=f"c-{nomination_id}",
698 heuristic_name=name,
699 heuristic_junk=heuristic_junk,
700 sources=sorted({item.source for item in items}),
701 engagement_by_source={},
702 )
703
704
705 def _resume_bundle(
706 rows: list[discovery_handoff.BundleNomination],
707 *,
708 tier: str = "deep",
709 to_date: str = "2026-07-10",
710 ) -> discovery_handoff.NominationsBundle:
711 return discovery_handoff.NominationsBundle(
712 schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
713 bundle_id="cafef00dcafef00d",
714 generated_at=f"{to_date}T00:00:00Z",
715 from_date="2026-06-10",
716 to_date=to_date,
717 domain="AI agents",
718 tier=tier,
719 enrichment_source_boundary=None,
720 requested_sources=None,
721 lookback_days=30,
722 nominations=rows,
723 )
724
725
726 def _judgment(name=None, junk=None, worthiness=None) -> discovery_handoff.HostJudgment:
727 return discovery_handoff.HostJudgment(name=name, junk=junk, worthiness=worthiness)
728
729
730 def _enrich_spy(seen: dict):
731 def spy(nominations, **kwargs):
732 seen["nominations"] = list(nominations)
733 seen.update(kwargs)
734 return [pipeline.EnrichedTopic(nomination=n) for n in nominations]
735 return spy
736
737
738 def test_resume_host_junk_never_takes_a_slot_next_candidate_does():
739 """AE4: a host-junk nomination is excluded from slot contention outright,
740 so the next blended candidate inherits its slot; a heuristic-junk fallback
741 row with a single seed source is skipped pre-enrichment (it structurally
742 cannot pass the floor's seed-corroboration rule)."""
743 rows = [
744 _bundle_row(f"n{index}", f"Story {chr(64 + index)}",
745 [_seed_item(f"s{index}", "hackernews", f"Story {chr(64 + index)}",
746 points=900 - 50 * index)])
747 for index in range(1, 8) # n1..n7: one more than ENRICH_LIMIT
748 ]
749 rows.append(_bundle_row(
750 "n8", "Help me pick a framework",
751 [_seed_item("s8", "reddit", "Help me pick a framework", points=500)],
752 heuristic_junk=True,
753 ))
754 assert pipeline.ENRICH_LIMIT == 6
755 seen: dict = {}
756 judgments = {"n1": _judgment(junk=True)}
757 with mock.patch.object(pipeline, "enrich_nominations", side_effect=_enrich_spy(seen)):
758 pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
759
760 enriched_names = [n.name for n in seen["nominations"]]
761 assert len(enriched_names) == pipeline.ENRICH_LIMIT
762 assert "Story A" not in enriched_names # host-junk: no slot
763 assert "Story G" in enriched_names # n7 takes the freed slot
764 assert "Help me pick a framework" not in enriched_names # sub-floor junk fallback
765
766
767 def test_resume_quiet_but_worthy_survives_the_cut():
768 """Relocated from the retired engine-judge suite, retargeted to the
769 judgments-file path: host worthiness blends into slot ranking BEFORE the
770 ENRICH_LIMIT cut, so a low-velocity worthiness-90 row survives while the
771 weakest of six high-velocity worthiness-10 rows is the one cut."""
772 rows = [
773 _bundle_row(f"n{index}", f"Viral story {chr(64 + index)}",
774 [_seed_item(f"v{index}", "hackernews",
775 f"Viral story {chr(64 + index)}",
776 points=100 - index, comments=20)])
777 for index in range(1, 7) # n1..n6 fill every slot on velocity alone
778 ]
779 rows.append(_bundle_row(
780 "n7", "Quiet maintainer burnout wave",
781 [_seed_item("q1", "hackernews", "Quiet maintainer burnout wave",
782 points=45, comments=15)],
783 ))
784 judgments = {
785 f"n{index}": _judgment(worthiness=10) for index in range(1, 7)
786 }
787 judgments["n7"] = _judgment(worthiness=90)
788 seen: dict = {}
789 with mock.patch.object(pipeline, "enrich_nominations", side_effect=_enrich_spy(seen)):
790 pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
791
792 enriched_names = [n.name for n in seen["nominations"]]
793 assert len(enriched_names) == pipeline.ENRICH_LIMIT
794 # The quiet-but-worthy row outranks every viral-but-junky one (blend
795 # multipliers span 0.5x-1.5x) and takes the top slot.
796 assert enriched_names[0] == "Quiet maintainer burnout wave"
797 # The weakest viral row is the one cut, not the quiet rescue.
798 assert "Viral story F" not in enriched_names
799
800
801 def test_resume_judgments_omitting_row_falls_back_to_heuristics():
802 """AE2: a judgments file that omits a nomination is legal - the omitted
803 row keeps the bundle's heuristic name and junk flag, and the run
804 completes with both topics ranked."""
805 rows = [
806 _bundle_row("n1", "Kestrel avionics merger",
807 [_seed_item("k1", "hackernews", "Kestrel avionics merger",
808 points=900, comments=400)]),
809 _bundle_row("n2", "Sourdough robot bakery",
810 [_seed_item("s1", "reddit", "Sourdough robot bakery",
811 points=700, comments=300)]),
812 ]
813 judgments = {"n1": _judgment(name="Kestrel Merger Fallout", worthiness=80)}
814 topics_run: list[str] = []
815
816 def fake_run(*, topic, **_kwargs):
817 topics_run.append(topic)
818 raise RuntimeError("enrichment down") # nomination-only is fine here
819
820 with mock.patch.object(pipeline, "run", side_effect=fake_run):
821 result = pipeline.run_discover_resume(_resume_bundle(rows), {}, config={})
822 report_heuristic_only = result.report
823 topics_run.clear()
824 result = pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
825
826 report = result.report
827 assert report.outcome == "ok"
828 assert report_heuristic_only.outcome == "ok"
829 assert sorted(topics_run) == ["Kestrel Merger Fallout", "Sourdough robot bakery"]
830 names = [topic.name for topic in report.topics]
831 assert "Kestrel Merger Fallout" in names # host name applied
832 assert "Sourdough robot bakery" in names # omitted row: heuristic name
833 assert set(result.angle_inputs) == {"n1", "n2"}
834
835
836 def test_resume_host_not_junk_clears_heuristic_junk_shape_at_floor():
837 """A host verdict junk=false overrides a junk heuristic shape: the row
838 reaches the floor with junk_shape=False, so the single-source engagement
839 bypass applies again."""
840 rows = [_bundle_row(
841 "n1", "Help me understand the Karvella doping ruling",
842 [_seed_item("s1", "reddit",
843 "Help me understand the Karvella doping ruling",
844 points=30, comments=400)],
845 heuristic_junk=True,
846 )]
847 judgments = {"n1": _judgment(name="Karvella doping ruling", junk=False)}
848 with mock.patch.object(
849 pipeline, "run", side_effect=RuntimeError("enrichment down"),
850 ):
851 result = pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
852
853 report = result.report
854 assert report.outcome == "ok"
855 assert [topic.name for topic in report.topics] == ["Karvella doping ruling"]
856
857
858 def test_resume_heuristic_junk_fallback_keeps_seed_corroboration_rule():
859 """A judgment-omitted junk-shaped row keeps its heuristic flag: with two
860 seed listing sources it earns a slot and clears the junk floor; the
861 single-seed-source twin never even enriches."""
862 title = "Help me understand the Marseille betting collapse"
863 corroborated = _bundle_row(
864 "n1", title,
865 [
866 _seed_item("s1", "reddit", title, points=40, comments=30),
867 _seed_item("s2", "hackernews", title, points=35, comments=20),
868 ],
869 heuristic_junk=True,
870 )
871 seen: dict = {}
872 with mock.patch.object(pipeline, "enrich_nominations", side_effect=_enrich_spy(seen)):
873 result = pipeline.run_discover_resume(
874 _resume_bundle([corroborated]), {}, config={},
875 )
876
877 assert [n.name for n in seen["nominations"]] == [title]
878 assert seen["nominations"][0].junk_shape is True # heuristic flag survives
879 report = result.report
880 assert report.outcome == "ok"
881 assert [topic.name for topic in report.topics] == [title]
882
883
884 def test_resume_zero_survivors_prefers_non_junk_weak_signal():
885 """Nothing-solid on the resume path: the strongest NON-junk floor failure
886 is named ahead of a higher-velocity host-junk exclusion, and no enrichment
887 slot is ever spent."""
888 rows = [
889 _bundle_row("n1", "Viral junk story",
890 [_seed_item("s1", "hackernews", "Viral junk story", points=900)]),
891 _bundle_row("n2", "Quiet real story",
892 [_seed_item("s2", "reddit", "Quiet real story",
893 points=20, comments=6)]),
894 ]
895 judgments = {"n1": _judgment(junk=True)}
896 with mock.patch.object(
897 pipeline, "run", side_effect=RuntimeError("enrichment down"),
898 ):
899 result = pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
900
901 report = result.report
902 assert report.topics == []
903 assert report.outcome == "nothing-solid"
904 assert report.weak_signal == "Quiet real story"
905 assert result.angle_inputs == {}
906 assert any("confidence floor" in warning for warning in report.warnings)
907
908
909 def test_resume_all_host_junk_names_junk_weak_signal_and_skips_enrichment():
910 """Every row host-junked: the brief still names the strongest signal
911 (junk-tracked, never empty when failures exist) and enrichment never runs."""
912 rows = [
913 _bundle_row("n1", "Junk story one",
914 [_seed_item("s1", "hackernews", "Junk story one", points=900)]),
915 _bundle_row("n2", "Junk story two",
916 [_seed_item("s2", "reddit", "Junk story two", points=100)]),
917 ]
918 judgments = {"n1": _judgment(junk=True), "n2": _judgment(junk=True)}
919 with mock.patch.object(pipeline, "enrich_nominations") as enrich:
920 result = pipeline.run_discover_resume(_resume_bundle(rows), judgments, config={})
921
922 enrich.assert_not_called()
923 report = result.report
924 assert report.topics == []
925 assert report.outcome == "nothing-solid"
926 assert report.weak_signal == "Junk story one"
927
928
929 def test_resume_velocity_and_momentum_pinned_to_bundle_window():
930 """Scenario 7: with a bundle whose to_date is NOT today, velocity and
931 momentum must be computed against the bundle window - identical to an
932 in-memory computation at that as_of date, and different from today's."""
933 items = [_seed_item("s1", "hackernews", "Window pinned story",
934 points=900, comments=400, published_at="2026-07-09")]
935 rows = [_bundle_row("n1", "Window pinned story", items)]
936 with mock.patch.object(
937 pipeline, "run", side_effect=RuntimeError("enrichment down"),
938 ):
939 result = pipeline.run_discover_resume(
940 _resume_bundle(rows, to_date="2026-07-10"), {}, config={},
941 )
942
943 report = result.report
944 assert len(report.topics) == 1
945 topic = report.topics[0]
946 expected = round(rerank.discovery_velocity_score(items, as_of_date="2026-07-10"), 2)
947 assert topic.velocity_score == expected
948 from datetime import date as _date
949 today = _date.today().isoformat()
950 at_today = round(rerank.discovery_velocity_score(items, as_of_date=today), 2)
951 assert topic.velocity_score != at_today
952 # Published 1 day before the bundle window's end: new-this-week by the
953 # bundle clock even though it is weeks old by the resume-time clock.
954 assert topic.momentum == "new-this-week"
955
956
957 def test_resume_reuses_same_story_fold_and_velocity_ranks(capsys):
958 """The committed fold/rank path runs on leg 2 too: two judged survivors
959 sharing enriched evidence fold to the higher-velocity one, and the angle
960 inputs are keyed by the SURVIVING nomination id only."""
961 shared_comment = {
962 "text": "The merger filings quietly admit the unit was insolvent",
963 "score": 1635,
964 "author": "modelwatcher",
965 }
966 rows = [
967 _bundle_row("n1", KESTREL_TITLE,
968 [_seed_item("k1", "hackernews", KESTREL_TITLE, points=900)]),
969 _bundle_row("n2", SOURDOUGH_TITLE,
970 [_seed_item("s1", "hackernews", SOURDOUGH_TITLE, points=700)]),
971 ]
972
973 def fake_run(*, topic, **_kwargs):
974 strong = "Kestrel" in topic
975 return _fake_report(topic, [
976 _evidence_item(
977 f"{topic[:4]}-a", "reddit", topic,
978 "https://reddit.com/r/aero/comments/shared1",
979 score=900 if strong else 300,
980 comments=300 if strong else 100,
981 top_comments=[shared_comment],
982 ),
983 _evidence_item(
984 f"{topic[:4]}-b", "hackernews", topic,
985 "https://news.example.com/shared2",
986 score=500 if strong else 200,
987 comments=200 if strong else 80,
988 ),
989 ])
990
991 with mock.patch.object(pipeline, "run", side_effect=fake_run):
992 result = pipeline.run_discover_resume(_resume_bundle(rows), {}, config={})
993
994 report = result.report
995 assert report.outcome == "ok"
996 assert len(report.topics) == 1
997 assert report.topics[0].rank == 1
998 assert "Kestrel" in report.topics[0].name
999 assert list(result.angle_inputs) == ["n1"]
1000 entry = result.angle_inputs["n1"]
1001 assert set(entry) == {"name", "titles", "top_comment", "engagement"}
1002 assert entry["name"] == report.topics[0].name
1003 assert "folded duplicate story" in capsys.readouterr().err
1004
1005
1006 def test_resume_report_carries_restored_leg1_source_status_and_warning():
1007 """F1b: the resume report's source_status is the bundle's restored leg-1
1008 sweep status - a degraded feed from the sweep reaches the leg-2 report
1009 and its degraded-sources warning, exactly as the one-shot reports it."""
1010 import dataclasses
1011
1012 status = {
1013 "hackernews": schema.SourceOutcome(
1014 source="hackernews", state="ok", items_returned=1,
1015 ),
1016 "reddit": schema.SourceOutcome(
1017 source="reddit", state=schema.UNREACHABLE, detail="dns failure",
1018 ),
1019 }
1020 rows = [_bundle_row(
1021 "n1", "Window pinned story",
1022 [_seed_item("s1", "hackernews", "Window pinned story",
1023 points=900, comments=400)],
1024 )]
1025 bundle = dataclasses.replace(_resume_bundle(rows), source_status=status)
1026 with mock.patch.object(
1027 pipeline, "run", side_effect=RuntimeError("enrichment down"),
1028 ):
1029 result = pipeline.run_discover_resume(bundle, {}, config={})
1030
1031 report = result.report
1032 assert report.source_status == status
1033 assert any(
1034 "Some discovery sources degraded: reddit" in warning
1035 for warning in report.warnings
1036 )
1037
1037 lines PYTHON