返回 last30days-skill
test_first_party_floor.py
根目录 / tests / test_first_party_floor.py
1 """Retrieval-floor exemption for posts authored by a handle the run is searching.
2
3 The production failure this pins: a mixed batch where the mention lane clears
4 the relevance floor and the from lane does not. Because
5 `prune_low_relevance` ends with `return filtered or items`, the all-fail rescue
6 only fires when *everything* fails. A mixed batch is therefore the exact shape
7 that silently loses the subject's own posts, and no prior test exercised it --
8 existing supplement-lane tests use single-item batches that trip the rescue.
9 """
10
11 from lib import schema, signals
12
13
14 def _x_item(item_id: str, author: str, relevance: float, engagement: dict | None = None):
15 item = schema.SourceItem(
16 item_id=item_id,
17 source="x",
18 title="",
19 body="post body",
20 url=f"https://x.com/{author}/status/{item_id}",
21 author=author,
22 engagement=engagement or {},
23 )
24 item.local_relevance = relevance
25 return item
26
27
28 def _mixed_batch():
29 """From-lane items score 0.0 (a post rarely names its own author);
30 mention-lane items clear the floor because they contain the handle."""
31 return [
32 _x_item("1", "steipete", 0.0, {"likes": 7773, "reposts": 391}),
33 _x_item("2", "steipete", 0.0, {"likes": 3466, "reposts": 128}),
34 _x_item("3", "someone_else", 0.32, {"likes": 78, "reposts": 8}),
35 _x_item("4", "another_acct", 0.23, {"likes": 8, "reposts": 1}),
36 ]
37
38
39 def _annotate(items):
40 """Populate engagement_score the way the real pipeline does."""
41 scores = signals.normalize([signals.engagement_raw(i) for i in items])
42 for item, score in zip(items, scores, strict=True):
43 item.engagement_score = score
44 return items
45
46
47 def test_mixed_batch_keeps_first_party_and_drops_off_topic():
48 items = _annotate(_mixed_batch())
49 kept = signals.prune_low_relevance(items, first_party_handles={"steipete"})
50 authors = sorted(i.author for i in kept)
51 assert "steipete" in authors, (
52 "first-party posts were pruned from a mixed batch; this is the measured "
53 "defect where 8 subject-authored posts never reached the report"
54 )
55 assert len([a for a in authors if a == "steipete"]) == 2
56
57
58 def test_mixed_batch_without_exemption_still_loses_first_party():
59 """Characterizes the defect: without the exemption the batch drops them."""
60 items = _annotate(_mixed_batch())
61 kept = signals.prune_low_relevance(items)
62 assert all(i.author != "steipete" for i in kept), (
63 "expected the unexempted path to still drop zero-relevance first-party "
64 "posts; if this now passes, the floor changed and the exemption's "
65 "justification needs rechecking"
66 )
67
68
69 def test_non_first_party_below_floor_is_still_pruned():
70 """The exemption must be scoped, not a blanket floor removal."""
71 items = _annotate(_mixed_batch() + [_x_item("5", "spam_acct", 0.02, {"likes": 0})])
72 kept = signals.prune_low_relevance(items, first_party_handles={"steipete"})
73 assert all(i.author != "spam_acct" for i in kept)
74
75
76 def test_batch_minimum_is_not_treated_as_zero_engagement():
77 """`normalize` maps the batch minimum to exactly 0, so an item with real
78 engagement was being given the stricter 1.5x social threshold purely for
79 being the least-engaged item present."""
80 items = _annotate([
81 _x_item("1", "acct_a", 0.20, {"likes": 500, "reposts": 40}),
82 _x_item("2", "acct_b", 0.20, {"likes": 5000, "reposts": 400}),
83 ])
84 least = next(i for i in items if i.author == "acct_a")
85 assert least.engagement_score == 0, "precondition: min-max maps batch min to 0"
86 kept = signals.prune_low_relevance(items)
87 assert any(i.author == "acct_a" for i in kept), (
88 "an item with 500 likes was pruned by the zero-engagement gate purely "
89 "because it was the batch minimum"
90 )
91
92
93 def test_genuinely_zero_engagement_still_gets_stricter_threshold():
94 """The stricter gate must survive for real zero-engagement social noise."""
95 items = _annotate([
96 _x_item("1", "acct_a", 0.20, {"likes": 0, "reposts": 0}),
97 _x_item("2", "acct_b", 0.90, {"likes": 5000, "reposts": 400}),
98 ])
99 kept = signals.prune_low_relevance(items)
100 assert all(i.author != "acct_a" for i in kept), (
101 "a genuinely zero-engagement item at 0.20 should fail the 0.225 gate"
102 )
103
104
105 def test_all_fail_rescue_is_unchanged():
106 items = _annotate([_x_item("1", "acct_a", 0.01), _x_item("2", "acct_b", 0.02)])
107 kept = signals.prune_low_relevance(items)
108 assert len(kept) == 2, "the all-fail rescue must still return the batch"
109
110
111 def test_batch_with_no_first_party_behaves_as_before():
112 items = _annotate(_mixed_batch())
113 assert signals.prune_low_relevance(items, first_party_handles=frozenset()) == \
114 signals.prune_low_relevance(items)
115
116
117 def test_handles_are_matched_case_insensitively():
118 items = _annotate(_mixed_batch())
119 kept = signals.prune_low_relevance(items, first_party_handles={"SteiPete"})
120 assert any(i.author == "steipete" for i in kept)
121
122
123 # --- KTD8: one owner for the entity-miss predicate -------------------------
124
125 def _candidate(explanation: str, final_score: float, author: str = "someone"):
126 url = f"https://x.com/{author}/status/1"
127 cand = schema.Candidate(
128 candidate_id="c1",
129 item_id="i1",
130 source="x",
131 title="t",
132 url=url,
133 snippet="s",
134 subquery_labels=["primary"],
135 native_ranks={"primary:x": 1},
136 local_relevance=0.0,
137 freshness=80,
138 engagement=50,
139 source_quality=0.68,
140 rrf_score=0.02,
141 )
142 cand.source_items = [
143 schema.SourceItem(
144 item_id="i1", source="x", title="t", body="b", url=url, author=author,
145 )
146 ]
147 cand.explanation = explanation
148 cand.final_score = final_score
149 return cand
150
151
152 def test_render_delegates_to_shared_predicate():
153 """render must not carry its own copy of the entity-miss test."""
154 from lib import render, rerank
155 cand = _candidate("fallback-local-score (entity-miss demotion)", 40.0)
156 assert render._best_take_relevance_ok(cand) is rerank.candidate_relevance_ok(cand)
157 ok = _candidate("llm-scored", 40.0)
158 assert render._best_take_relevance_ok(ok) is rerank.candidate_relevance_ok(ok)
159
160
161 def test_shared_predicate_rejects_entity_miss_and_zero_score():
162 from lib import rerank
163 assert not rerank.candidate_relevance_ok(
164 _candidate("fallback-local-score (entity-miss demotion)", 40.0)
165 )
166 assert not rerank.candidate_relevance_ok(_candidate("llm-scored", 0.0))
167 assert rerank.candidate_relevance_ok(_candidate("llm-scored", 40.0))
168
169
170 def test_first_party_carveout_reaches_render_side_gate():
171 """The measured KTD8 failure: a first-party post demoted on the LLM path
172 was floored by rerank but still discarded at render because the render-side
173 copy re-tested the explanation string."""
174 from lib import render, rerank
175 cand = _candidate("fallback-local-score (entity-miss demotion)", 0.0, author="steipete")
176 assert not render._best_take_relevance_ok(cand), "precondition: demoted before the floor runs"
177 rerank._apply_first_party_floor([cand], resolved_handles={"steipete"})
178 assert cand.final_score >= rerank.FIRST_PARTY_FLOOR
179 assert render._best_take_relevance_ok(cand), (
180 "first-party carve-out applied in rerank did not propagate to the "
181 "render-side relevance gate"
182 )
183
184
185 def test_non_first_party_demotion_survives_the_floor_pass():
186 from lib import render, rerank
187 cand = _candidate("fallback-local-score (entity-miss demotion)", 0.0, author="rando")
188 rerank._apply_first_party_floor([cand], resolved_handles={"steipete"})
189 assert not render._best_take_relevance_ok(cand), (
190 "an off-topic collision post must stay buried"
191 )
192
193
194 # --- Phase 1 / quick-depth wiring (Greptile) --------------------------------
195
196 def test_phase_one_normalize_receives_the_explicit_handles():
197 """Quick runs skip Phase 2 entirely, so an exemption reaching only the
198 supplement path leaves quick-depth reports discarding the subject's posts."""
199 import inspect
200 from lib import pipeline
201 src = inspect.getsource(pipeline.run)
202 assert "explicit_first_party = {" in src, (
203 "the user-named handles must be resolved before retrieval, not after"
204 )
205 assert "first_party_handles=explicit_first_party," in src, (
206 "the Phase 1 per-source normalize must receive the exemption"
207 )
208
209
210 def test_explicit_handles_are_available_before_any_retrieval():
211 """The entity-extracted set does not exist until Phase 2; the explicit one
212 must be built from run()'s own arguments so Phase 1 can use it."""
213 import inspect
214 from lib import pipeline
215 src = inspect.getsource(pipeline.run)
216 build_at = src.index("explicit_first_party = {")
217 first_use = src.index("first_party_handles=explicit_first_party,")
218 assert build_at < first_use
219
220
221 def test_related_handles_lane_gets_the_exemption():
222 import inspect
223 from lib import pipeline
224 src = inspect.getsource(pipeline._run_supplemental_searches)
225 assert "first_party_handles=related_handles," in src
226
227
228 def test_quick_run_without_an_explicit_handle_still_protects_the_subject():
229 """The gap in the first fix: covering only user-typed handles does nothing
230 for a quick run, where nobody typed one and Phase 2's automatic resolution
231 never executes."""
232 from lib import pipeline
233 candidates = pipeline._topic_first_party_candidates("Peter Steinberger steipete")
234 assert "steipete" in candidates
235
236 items = _annotate(_mixed_batch())
237 kept = signals.prune_low_relevance(items, first_party_handles=candidates)
238 assert any(i.author == "steipete" for i in kept), (
239 "a quick search naming the subject must not discard what they wrote"
240 )
241
242
243 def test_topic_candidates_include_explicit_mentions():
244 from lib import pipeline
245 assert "getenergy_" in pipeline._topic_first_party_candidates("@GetEnergy_ launch")
246
247
248 def test_topic_candidates_exclude_stopwords():
249 from lib import pipeline
250 got = pipeline._topic_first_party_candidates("the best of the year")
251 assert "the" not in got and "of" not in got
252
253
254 def test_topic_candidates_do_not_exempt_unrelated_authors():
255 """Candidates only matter when a post's author matches one, so ordinary
256 words cost nothing -- no account is named 'lunch'."""
257 from lib import pipeline
258 candidates = pipeline._topic_first_party_candidates("bentgo lunch boxes")
259 items = _annotate(_mixed_batch() + [_x_item("9", "spam_acct", 0.01, {"likes": 0})])
260 kept = signals.prune_low_relevance(items, first_party_handles=candidates)
261 assert all(i.author != "spam_acct" for i in kept)
262
263
264 def test_topic_candidates_are_unioned_into_the_explicit_set():
265 import inspect
266 from lib import pipeline
267 src = inspect.getsource(pipeline.run)
268 assert "_topic_first_party_candidates(topic)" in src
269 build = src.index("explicit_first_party")
270 use = src.index("first_party_handles=explicit_first_party,")
271 assert build < use
272
273
274 def test_name_only_topic_resolves_the_subject_from_mentions():
275 """The hard case: search "Peter Steinberger" with no handle anywhere. His
276 handle is @steipete, which matches no topic token, and Phase 2's resolution
277 has not run. Posts *about* him mention him, which is the signal the engine
278 already uses -- just later than the prune."""
279 from lib import pipeline
280 raw = [
281 {"text": "Great thread from @steipete on agent loops"},
282 {"text": "@steipete nailed this one"},
283 {"text": "watching @steipete build in public is wild"},
284 {"text": "unrelated chatter with no mention"},
285 ]
286 assert "steipete" in pipeline._batch_subject_handles(raw)
287
288
289 def test_batch_subject_keys_on_mentions_not_authors():
290 """A prolific commentator inflates author counts; being mentioned by other
291 accounts is what identifies the subject."""
292 from lib import pipeline
293 raw = [
294 {"author_handle": "spam_acct", "text": "buy now"},
295 {"author_handle": "spam_acct", "text": "buy now again"},
296 {"author_handle": "spam_acct", "text": "and again"},
297 {"author_handle": "someone", "text": "actually useful thread by @realsubject"},
298 ]
299 got = pipeline._batch_subject_handles(raw)
300 assert "spam_acct" not in got
301 assert "realsubject" in got
302
303
304 def test_batch_subject_is_capped():
305 from lib import pipeline
306 raw = [{"text": f"@acct{i} said something"} for i in range(10)]
307 assert len(pipeline._batch_subject_handles(raw)) <= 2
308
309
310 def test_batch_subject_is_empty_without_mentions():
311 from lib import pipeline
312 assert pipeline._batch_subject_handles([{"text": "no mentions here"}]) == set()
313 assert pipeline._batch_subject_handles([]) == set()
314
315
316 def test_batch_inference_is_unioned_not_gated():
317 """Regression: gating this on "no handles supplied" made it dead code.
318
319 The caller's set is derived partly from topic tokens, so it is non-empty
320 for essentially every real topic -- a fallback would never fire and the
321 name-only case would stay broken while looking fixed.
322 """
323 import inspect
324 from lib import pipeline
325 src = inspect.getsource(pipeline._normalize_score_dedupe)
326 assert "floor_handles |= _batch_subject_handles(raw_items)" in src
327 assert "not floor_handles" not in src, (
328 "batch inference must union, never gate on an empty supplied set"
329 )
330
331
332 def test_name_only_topic_keeps_subject_posts_end_to_end():
333 """The full path: topic names a person, handle appears nowhere in it, and
334 the subject's own zero-relevance posts still survive the floor."""
335 from lib import pipeline
336 supplied = pipeline._topic_first_party_candidates("Peter Steinberger")
337 assert "steipete" not in supplied, "precondition: the handle is not in the topic"
338 raw = [
339 {"text": "Great thread from @steipete on agent loops"},
340 {"text": "@steipete nailed this"},
341 {"text": "more praise for @steipete"},
342 ]
343 inferred = pipeline._batch_subject_handles(raw)
344 items = _annotate(_mixed_batch())
345 kept = signals.prune_low_relevance(
346 items, first_party_handles=supplied | inferred
347 )
348 assert any(i.author == "steipete" for i in kept)
349
350
351 # --- ordering fix: prune X after resolution, not before ---------------------
352
353 def test_x_defers_its_relevance_floor_past_resolution():
354 """The root ordering bug: X was pruned before the run knew who the subject
355 was, so the floor could not exempt an author nobody had identified yet. No
356 amount of guessing at prune time substitutes for knowing."""
357 import inspect
358 from lib import pipeline
359 src = inspect.getsource(pipeline.run)
360 assert 'defer_relevance_prune=(source == "x")' in src
361
362
363 def test_deferred_prune_runs_after_resolution_and_before_fusion():
364 import inspect
365 from lib import pipeline
366 src = inspect.getsource(pipeline.run)
367 resolve_at = src.index("resolved_handles = explicit_first_party")
368 prune_at = src.index("Deferred X relevance floor")
369 fuse_at = src.index("candidates = weighted_rrf(")
370 assert resolve_at < prune_at < fuse_at, (
371 "the deferred floor must see resolved handles and still run before fusion"
372 )
373
374
375 def test_non_x_sources_still_prune_in_place():
376 """Only X defers; everything else keeps its existing behavior."""
377 import inspect
378 from lib import pipeline
379 src = inspect.getsource(pipeline._normalize_score_dedupe)
380 assert 'if source != "jobs" and not defer_relevance_prune:' in src
381
382
383 def test_deferred_prune_still_drops_off_topic_posts():
384 """Deferring must not mean skipping."""
385 items = _annotate(_mixed_batch() + [_x_item("9", "spam_acct", 0.01, {"likes": 0})])
386 kept = signals.prune_low_relevance(items, first_party_handles={"steipete"})
387 assert any(i.author == "steipete" for i in kept)
388 assert all(i.author != "spam_acct" for i in kept)
389
390
391 # --- unresolved subject policy: skip X floor when no real handle identified ---
392
393 def test_topic_handle_mentions_extracts_only_at_mentions():
394 """@mentions in the topic are real handles; regular words are not."""
395 from lib import pipeline
396 assert pipeline._topic_handle_mentions("Peter Steinberger @steipete") == {"steipete"}
397 assert pipeline._topic_handle_mentions("Peter Steinberger") == set()
398 assert pipeline._topic_handle_mentions("@GetEnergy_ launch") == {"getenergy_"}
399
400
401 def test_topic_handle_mentions_is_case_insensitive():
402 from lib import pipeline
403 assert pipeline._topic_handle_mentions("@SteiPete") == {"steipete"}
404
405
406 def test_entity_topic_no_handle_no_discovery_skips_x_floor():
407 """Policy: when the subject cannot be identified, skip the X floor entirely.
408
409 Entity-shaped topic, no --x-handle, auto-resolve/Phase 2 return nothing.
410 Retrieved X items include a zero-relevance post whose author is not a
411 topic token. That post survives prune because the floor is not applied.
412 A companion on-topic post is in the batch so the all-fail rescue cannot
413 hide an incorrectly applied floor.
414 """
415 from unittest.mock import patch
416 from lib import pipeline
417
418 topic = "Peter Steinberger"
419 assert pipeline._topic_handle_mentions(topic) == set()
420 topic_tokens = pipeline._topic_first_party_candidates(topic)
421 assert "peter" in topic_tokens and "steinberger" in topic_tokens
422 assert "rando_acct" not in topic_tokens
423
424 raw_items = [
425 {
426 "id": "1",
427 "text": "shipping a new agent loop tonight",
428 "url": "https://x.com/rando_acct/status/1",
429 "author_handle": "rando_acct",
430 "date": "2026-08-01",
431 "engagement": {"likes": 0, "reposts": 0, "replies": 0},
432 },
433 {
434 "id": "2",
435 "text": "Peter Steinberger just shipped another agent demo",
436 "url": "https://x.com/third_acct/status/2",
437 "author_handle": "third_acct",
438 "date": "2026-08-01",
439 "engagement": {"likes": 50, "reposts": 5, "replies": 2},
440 },
441 ]
442 plan = {
443 "intent": "person",
444 "freshness_mode": "balanced_recent",
445 "cluster_mode": "topic",
446 "subqueries": [{
447 "label": "primary",
448 "search_query": topic,
449 "ranking_query": topic,
450 "sources": ["x"],
451 }],
452 "source_weights": {"x": 1.0},
453 }
454
455 def fake_retrieve(**kwargs):
456 if kwargs.get("source") == "x":
457 return raw_items, {}
458 return [], {}
459
460 with patch("lib.pipeline._retrieve_stream", side_effect=lambda **kw: fake_retrieve(**kw)):
461 report = pipeline.run(
462 topic=topic,
463 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
464 depth="quick",
465 requested_sources=["x"],
466 mock=True,
467 external_plan=plan,
468 as_of_date="2026-08-14",
469 )
470
471 x_items = report.items_by_source.get("x") or []
472 authors = {item.author for item in x_items}
473 assert "rando_acct" in authors, (
474 "unresolved subject policy: skip X floor when no real handle identified; "
475 "the zero-relevance post whose author is not a topic token must survive"
476 )
477
478
479 def test_entity_topic_with_at_mention_applies_x_floor():
480 """When the topic DOES include @mentions, the X floor applies normally."""
481 from lib import pipeline
482
483 topic = "Peter Steinberger @steipete"
484 explicit_x_handles = pipeline._topic_handle_mentions(topic)
485 supplemental_handles = []
486
487 assert "steipete" in explicit_x_handles, (
488 "precondition: @mention is a real handle"
489 )
490
491 real_x_handles = explicit_x_handles | {
492 h.lstrip("@").strip().lower() for h in supplemental_handles if h and h.strip()
493 }
494 assert real_x_handles, "real handles were resolved"
495
496 topic_candidates = pipeline._topic_first_party_candidates(topic)
497 items = _annotate([
498 _x_item("1", "steipete", 0.0, {"likes": 100, "reposts": 10}),
499 _x_item("2", "rando_acct", 0.0, {"likes": 0, "reposts": 0}),
500 _x_item("3", "third_acct", 0.18, {"likes": 20, "reposts": 2}),
501 ])
502
503 kept = signals.prune_low_relevance(items, first_party_handles=topic_candidates | real_x_handles)
504
505 assert "steipete" in {i.author for i in kept}, (
506 "first-party posts survive the floor"
507 )
508 assert "rando_acct" not in {i.author for i in kept}, (
509 "when real handles exist, off-topic zero-relevance posts are pruned"
510 )
511
512
513 def test_explicit_x_handle_applies_x_floor():
514 """--x-handle triggers the floor even with entity-only topic."""
515 from lib import pipeline
516
517 topic = "Peter Steinberger"
518 x_handle = "steipete"
519 explicit_x_handles = {x_handle.lstrip("@").strip().lower()}
520
521 assert pipeline._topic_handle_mentions(topic) == set(), (
522 "precondition: topic has no @mentions"
523 )
524 assert explicit_x_handles == {"steipete"}, (
525 "but we have an explicit --x-handle"
526 )
527
528 topic_candidates = pipeline._topic_first_party_candidates(topic)
529 resolved_handles = topic_candidates | explicit_x_handles
530
531 items = _annotate([
532 _x_item("1", "steipete", 0.0, {"likes": 100, "reposts": 10}),
533 _x_item("2", "rando_acct", 0.0, {"likes": 0, "reposts": 0}),
534 ])
535
536 kept = signals.prune_low_relevance(items, first_party_handles=resolved_handles)
537
538 assert "steipete" in {i.author for i in kept}
539 assert "rando_acct" not in {i.author for i in kept}, (
540 "--x-handle triggers the floor, pruning off-topic posts"
541 )
542
543
544 # --- thin-source retry must not prune X before handle resolution ------------
545
546 def test_thin_retry_keeps_zero_relevance_subject_authored_x_post():
547 """Phase 1 defers the X floor; the simplified-query retry must too.
548
549 A default/deep run with fewer than three X items retries with a simpler
550 query. If that retry returns a subject-authored post that does not repeat
551 the subject's name, applying the relevance floor here (with no resolved
552 handles) discards it before it enters the bundle. The later
553 resolved-handle floor cannot recover a post that never arrived.
554 """
555 import threading
556 from unittest.mock import patch
557 from lib import pipeline
558
559 topic = "Peter Steinberger"
560 raw_items = [
561 {
562 "id": "1",
563 "text": "shipping a new agent loop tonight",
564 "url": "https://x.com/steipete/status/1",
565 "author_handle": "steipete",
566 "date": "2026-08-01",
567 "engagement": {"likes": 3466, "reposts": 128, "replies": 40},
568 },
569 {
570 "id": "2",
571 "text": "Peter Steinberger just shipped another agent demo",
572 "url": "https://x.com/third_acct/status/2",
573 "author_handle": "third_acct",
574 "date": "2026-08-01",
575 "engagement": {"likes": 50, "reposts": 5, "replies": 2},
576 },
577 ]
578 plan = schema.QueryPlan(
579 intent="person",
580 freshness_mode="balanced_recent",
581 cluster_mode="topic",
582 raw_topic=topic,
583 subqueries=[
584 schema.SubQuery(
585 label="primary",
586 search_query=topic,
587 ranking_query=topic,
588 sources=["x"],
589 )
590 ],
591 source_weights={"x": 1.0},
592 )
593 bundle = schema.RetrievalBundle()
594
595 with patch("lib.pipeline._retrieve_stream", return_value=(raw_items, {})):
596 pipeline._retry_thin_sources(
597 topic=topic,
598 bundle=bundle,
599 plan=plan,
600 config={},
601 depth="default",
602 date_range=("2026-07-15", "2026-08-14"),
603 runtime=schema.ProviderRuntime(
604 reasoning_provider="mock",
605 planner_model="mock",
606 rerank_model="mock",
607 ),
608 mock=False,
609 rate_limited_sources=set(),
610 rate_limit_lock=threading.Lock(),
611 settings=pipeline.DEPTH_SETTINGS["default"],
612 )
613
614 x_items = bundle.items_by_source.get("x") or []
615 authors = {item.author for item in x_items}
616 assert "steipete" in authors, (
617 "thin-retry X path must defer the relevance floor the way Phase 1 does; "
618 "a zero-relevance subject-authored post must survive into the bundle"
619 )
620 assert "third_acct" in authors, (
621 "precondition: the companion on-topic post cleared the floor, so the "
622 "all-fail rescue cannot hide an incorrectly applied prune"
623 )
624
624 lines PYTHON