5050import arxiv_fetch # noqa: E402
5151
5252#: The reading categories. astro-ph.HE carries the black-hole and transient
53- #: work that .CO/.GA do not; .IM carries the instrumentation and methods
54- #: papers. `cat:` matches cross-lists, so a stats-heavy paper whose primary
55- #: category is stat.ME is still caught when it cross-lists to astro-ph.
56- CATEGORIES = ("astro-ph.CO" , "astro-ph.GA" , "astro-ph.HE" , "astro-ph.IM" )
53+ #: work that .CO/.GA do not; .IM the instrumentation and methods papers;
54+ #: gr-qc the black-hole theory, gravitational-wave and primordial-black-hole
55+ #: work that never reaches astro-ph at all; .SR the stellar populations that
56+ #: galaxy-evolution papers are built on; stat.ME/stat.ML the inference methods
57+ #: behind the Stats bucket. `cat:` matches cross-lists, so a paper whose
58+ #: primary category is elsewhere is still caught when it cross-lists in.
59+ #:
60+ #: The last four were added 2026-08-27, on measurement rather than taste: the
61+ #: first live run announced 61 papers in a band, against a design that assumed
62+ #: several hundred. At that volume the ranker below was filtering almost
63+ #: nothing (52 of 61 scored) and CANDIDATE_CAP never bound, so the four
64+ #: astro-ph categories were leaving the shortlist stage with no work to do.
65+ #: Widening spends that headroom on coverage instead of retiring the stage.
66+ CATEGORIES = ("astro-ph.CO" , "astro-ph.GA" , "astro-ph.HE" , "astro-ph.IM" ,
67+ "gr-qc" , "astro-ph.SR" , "stat.ME" , "stat.ML" )
5768QUERY = " OR " .join (f"cat:{ c } " for c in CATEGORIES )
5869
59- #: How many papers the final list holds. The human asked for ten a day.
60- PICK_COUNT = 10
70+ #: How many papers the final list holds — the human asked for ten a day. This
71+ #: is NOT the number the prompt is asked for; see PICK_COUNT.
72+ BATCH_SIZE = 10
73+
74+ #: How many the prompt returns, ranked most interesting first. Three more than
75+ #: land, because the append on the Memory side drops any paper already on the
76+ #: strong-lensing inbox or in the reading queue — and on the very first live
77+ #: run exactly that happened (2608.26039 was on both lists' radar), so a
78+ #: ten-pick day filed nine. The extra three are dedup slack: append takes them
79+ #: in order and stops at its own cap, so ordering is what makes this work, and
80+ #: a day with no overlap still files ten.
81+ #:
82+ #: The cap itself is deliberately NOT restated here. `interests_actions.py`
83+ #: owns it (its own BATCH_SIZE, the default of `append --limit`); duplicating
84+ #: the number across two repos is how they drift.
85+ OVERPICK = 3
86+ PICK_COUNT = BATCH_SIZE + OVERPICK
6187
6288#: How many the prompt gets to choose from. Enough that ten good ones are
6389#: reliably in there, small enough that one prompt can read every abstract
64- #: closely — the whole reason for the scoring stage.
90+ #: closely — the whole reason for the scoring stage. With the widened
91+ #: categories above this cap now BINDS on an ordinary day, which is the point:
92+ #: it went from decoration back to doing the job it was written for.
6593CANDIDATE_CAP = 60
6694
67- #: Paging: the API caps a page at ~200 and a 3-day Monday band is several
68- #: hundred papers. The cap is a runaway guard, not an expected limit; hitting
69- #: it is reported rather than swallowed.
95+ #: Paging: the API caps a page at ~200. Measured 61 papers/day on the original
96+ #: four categories; the widened set should run a few hundred, and a 3-day
97+ #: Monday band several hundred more. The cap is a runaway guard, not an
98+ #: expected limit; hitting it is reported rather than swallowed.
7099PAGE_SIZE = 200
71100MAX_PAGES = 12
72101
129158TITLE_WEIGHT = 3
130159ABSTRACT_WEIGHT = 1
131160
161+ #: The categories whose papers are astronomy by default, and what that is worth
162+ #: on top of the keyword score.
163+ #:
164+ #: This exists because of what widening CATEGORIES brings in. In stat.ME and
165+ #: stat.ML, "Bayesian", "posterior", "inference" and "hierarchical model" are
166+ #: the house vocabulary — every paper there scores on the Stats terms, so a
167+ #: Bayesian method for clinical trials would out-score a lens-modelling paper
168+ #: on keywords alone and crowd it off a 60-paper shortlist. The bonus is the
169+ #: tie-breaker that keeps an astronomy shortlist astronomical: a paper from a
170+ #: home category starts ahead, and a stats paper has to genuinely out-score it
171+ #: to take a slot. gr-qc counts as home — black-hole theory and GW work is the
172+ #: reason it was added.
173+ #:
174+ #: It is a thumb on the scale, not a filter: a strong stats paper still makes
175+ #: the list, and Claude still judges everything that survives.
176+ HOME_CATEGORIES = ("astro-ph" , "gr-qc" )
177+ HOME_BONUS = 4
178+
179+
180+ def is_home (primary_category : str | None ) -> bool :
181+ """Whether a paper's primary category is astronomy rather than borrowed."""
182+ cat = (primary_category or "" ).strip ()
183+ return any (cat == c or cat .startswith (c + "." ) for c in HOME_CATEGORIES )
184+
132185#: The strong-lensing net, borrowed whole from the other digest so the two
133186#: cannot drift apart on what "strong lensing" means. Used only to FLAG.
134187LENSING_TERMS = tuple (t .lower () for t in (arxiv_fetch ._ABS + arxiv_fetch ._TI ))
@@ -168,12 +221,18 @@ def rank(papers: list[dict], cap: int = CANDIDATE_CAP) -> tuple[list[dict], int]
168221 """
169222 scored = []
170223 for p in papers :
171- total , topic , per = score (p ["title" ], p ["abstract" ])
172- if not total :
224+ keywords , topic , per = score (p ["title" ], p ["abstract" ])
225+ if not keywords :
173226 continue
227+ # The bonus rides on top of a NON-ZERO keyword score, never instead of
228+ # one: being an astro-ph paper is a tie-breaker among papers that are
229+ # already on topic, not a way in for one that matched nothing.
230+ home = is_home (p .get ("primary_category" ))
174231 scored .append ({** p ,
175232 "topic" : topic ,
176- "score" : total ,
233+ "score" : keywords + (HOME_BONUS if home else 0 ),
234+ "keyword_score" : keywords ,
235+ "home" : home ,
177236 "topic_scores" : per ,
178237 "strong_lensing" : is_lensing (p ["title" ], p ["abstract" ])})
179238 scored .sort (key = lambda p : (- p ["score" ], p ["url" ]))
@@ -264,17 +323,49 @@ def check(label, ok):
264323 check ("a non-lensing paper is not flagged" ,
265324 not is_lensing ("A quiescent galaxy at z=5" , "JWST spectroscopy." ))
266325
326+ check ("astro-ph and gr-qc are home, stat.ML is borrowed" ,
327+ is_home ("astro-ph.GA" ) and is_home ("gr-qc" )
328+ and not is_home ("stat.ML" ) and not is_home (None ))
329+
267330 papers = [
268331 {"title" : f"Dark matter paper { i } " , "abstract" : "dark matter halo" ,
332+ "primary_category" : "astro-ph.CO" ,
269333 "url" : f"https://arxiv.org/abs/2608.{ i :05d} " } for i in range (20 )
270- ] + [{"title" : "Unrelated" , "abstract" : "nothing" , "url" : "x" }]
334+ ] + [{"title" : "Unrelated" , "abstract" : "nothing" ,
335+ "primary_category" : "astro-ph.GA" , "url" : "x" }]
271336 top , scored = rank (papers , cap = 5 )
272337 check (f"rank drops the unscored and caps ({ len (top )} of { scored } )" ,
273338 len (top ) == 5 and scored == 20 )
274339 check ("rank is deterministic" , rank (papers , cap = 5 )[0 ] == top )
275340 check ("every candidate carries a topic and a lensing flag" ,
276341 all (p ["topic" ] and "strong_lensing" in p for p in top ))
277342
343+ # The bonus is a tie-breaker among on-topic papers, never a way in.
344+ pair = [{"title" : "Bayesian hierarchical inference for trial design" ,
345+ "abstract" : "A posterior over treatment effects." ,
346+ "primary_category" : "stat.ME" , "url" : "https://arxiv.org/abs/1" },
347+ {"title" : "Bayesian inference for galaxy scaling relations" ,
348+ "abstract" : "A posterior over stellar mass." ,
349+ "primary_category" : "astro-ph.GA" , "url" : "https://arxiv.org/abs/2" }]
350+ ranked , _ = rank (pair , cap = 2 )
351+ check (f"an astro paper outranks an equal-scoring stats one "
352+ f"({ ranked [0 ]['primary_category' ]} first)" ,
353+ ranked [0 ]["primary_category" ] == "astro-ph.GA" )
354+ check ("the borrowed paper is kept, not filtered out" , len (ranked ) == 2 )
355+ check ("the keyword score is reported alongside the boosted one" ,
356+ ranked [0 ]["score" ] == ranked [0 ]["keyword_score" ] + HOME_BONUS
357+ and ranked [1 ]["score" ] == ranked [1 ]["keyword_score" ])
358+
359+ off_topic = [{"title" : "A new species of Antarctic lichen" ,
360+ "abstract" : "Nothing here is astronomy at all." ,
361+ "primary_category" : "astro-ph.GA" , "url" : "https://x/3" }]
362+ check ("the bonus cannot admit a paper that matched nothing" ,
363+ rank (off_topic , cap = 5 )[0 ] == [])
364+
365+ check (f"the prompt over-picks for dedup slack "
366+ f"(asks { PICK_COUNT } , { BATCH_SIZE } land)" ,
367+ PICK_COUNT == BATCH_SIZE + OVERPICK and OVERPICK > 0 )
368+
278369 print (f"selftest: { 'PASS' if not failures else f'{ failures } FAILURE(S)' } " ,
279370 file = sys .stderr )
280371 return 1 if failures else 0
@@ -308,6 +399,7 @@ def main() -> int:
308399 "until" : band_end .isoformat (),
309400 "categories" : list (CATEGORIES ),
310401 "pick" : PICK_COUNT ,
402+ "batch" : BATCH_SIZE ,
311403 "band_count" : len (band ),
312404 "scored_count" : scored ,
313405 "truncated" : truncated ,
@@ -321,6 +413,8 @@ def main() -> int:
321413 "published" : p ["published" ],
322414 "topic" : p ["topic" ],
323415 "score" : p ["score" ],
416+ "keyword_score" : p ["keyword_score" ],
417+ "home" : p ["home" ],
324418 "strong_lensing" : p ["strong_lensing" ]}
325419 for p in candidates ],
326420 }
0 commit comments