Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions arxiv-inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ Format:
not lost: git history holds it. This is deliberate — the never-delete rule in
`reading-queue.md` protects *reading history*, and an un-acted suggestion is
not history.
- One `last digest: <YYYY-MM-DD>` line, directly below the `---`, records the
last run of the nightly digest — **papers or none**. It is what lets an empty
inbox say *which* kind of empty it is: dated today it is a genuinely quiet
day, four days stale it means the filing is broken and papers were lost. The
same job the `#papers` empty-day heartbeat does on the Slack side. It is
replaced, never accumulated, so the sweep has nothing to age out.
- Written by PyAutoMind's `arxiv_papers.yml`. Hand edits are safe but will race
a nightly run; prefer the Dashboard buttons.

---
last digest: 2026-08-25
2026-08-25 — Strong Lensing Cosmology with Population-level Calibrated Neural Ratio Estimation — 2608.23534
2026-08-25 — Mapping the Information Geometry of an Unresolved Dark Matter Population using a Differentiable Strong Lensing Simulator — 2608.18224
109 changes: 101 additions & 8 deletions scripts/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
The inbox's format, window and transitions live in ``scripts/inbox_actions.py``;
this module reads them rather than re-deriving them.

The inbox also carries a **freshness line**: the date of the last digest run,
so an empty inbox says *which* kind of empty it is — arXiv was quiet, or the
filing broke. The date is rendered into the page, but the "this looks broken"
warning is computed in the reader's browser (see ``_EXTRA_JS``), because the
page freezes at its last good render in exactly the failure the warning is for.

**Contents-level only.** The board shows titles and counts, never claim text
or summaries — the knowledge itself stays in the wiki pages.

Expand Down Expand Up @@ -136,6 +142,7 @@ def collect(root: Path | None = None) -> dict:
"bib_entries": 0,
"queue": [],
"inbox": [],
"inbox_last_digest": None,
"links": {"total": 0, "unique": 0, "wanted": 0},
}

Expand Down Expand Up @@ -200,7 +207,9 @@ def _flush():
inbox = root / inbox_actions.INBOX_FILE
if inbox.exists():
today = datetime.datetime.now(datetime.timezone.utc).date()
for entry in inbox_actions.papers(inbox.read_text(errors="replace")):
inbox_text = inbox.read_text(errors="replace")
snapshot["inbox_last_digest"] = inbox_actions.last_digest(inbox_text)
for entry in inbox_actions.papers(inbox_text):
snapshot["inbox"].append({
"title": entry["title"],
"ref": entry["ref"],
Expand Down Expand Up @@ -441,6 +450,36 @@ def _stub_prompt(snapshot: dict, wiki: str) -> str:
f"in wiki/CLAUDE.md, set status: drafted, and run make validate.")


def _inbox_freshness(snapshot: dict) -> dict:
"""The inbox's freshness facts, shared by both renderers.

``stale`` is what the *markdown* fallback can say. The HTML page must not
trust it for the warning: see ``_EXTRA_JS``.
"""
stamp = snapshot.get("inbox_last_digest")
try:
today = datetime.datetime.fromisoformat(
str(snapshot.get("generated"))).date()
except (TypeError, ValueError):
today = datetime.datetime.now(datetime.timezone.utc).date()
return {
"stamp": stamp,
"weekdays": inbox_actions.weekdays_since(stamp, today) if stamp else 0,
"stale": inbox_actions.is_stale(stamp, today),
}


def _inbox_empty_note(fresh: dict) -> str:
"""Why the inbox is empty — quiet, suspect, or never run. Plain text."""
if fresh["stale"]:
return (f"no digest since {fresh['stamp']} "
f"({fresh['weekdays']} weekdays) — the nightly filing may be "
f"broken")
if fresh["stamp"]:
return f"the last digest ran {fresh['stamp']} and found nothing"
return "the nightly arXiv digest fills this — no run recorded yet"


# --- renderers ------------------------------------------------------------------
def _render_md(snapshot: dict) -> str:
t = _totals(snapshot)
Expand All @@ -460,9 +499,12 @@ def _render_md(snapshot: dict) -> str:
f"{w.get('todo', 0)} |")
lines += ["", "## arXiv inbox", ""]
inbox = snapshot.get("inbox") or []
fresh = _inbox_freshness(snapshot)
if inbox:
lines += [f"_{len(inbox)} waiting · un-acted suggestions lapse after "
f"{inbox_actions.INBOX_WINDOW_DAYS} days._", ""]
digest = (f"last digest {fresh['stamp']} · " if fresh["stamp"] else "")
warn = ("⚠ " if fresh["stale"] else "")
lines += [f"_{len(inbox)} waiting · {warn}{digest}un-acted suggestions "
f"lapse after {inbox_actions.INBOX_WINDOW_DAYS} days._", ""]
for p in inbox:
label = p["title"].replace("[", "\\[").replace("]", "\\]")
bits = [f"- [{label}]({paper_url(p)}) — _{p['days_left']}d left_"]
Expand All @@ -475,7 +517,8 @@ def _render_md(snapshot: dict) -> str:
lines.append(" · ".join(bits))
lines.append("")
else:
lines += ["- _(nothing waiting — the nightly digest fills this)_", ""]
warn = ("⚠ " if fresh["stale"] else "")
lines += [f"- _(nothing waiting — {warn}{_inbox_empty_note(fresh)})_", ""]
lines += ["## Reading queue", ""]
trend = _read_trend(snapshot)
if sum(q.get("done", 0) for q in snapshot.get("queue") or []):
Expand Down Expand Up @@ -573,14 +616,38 @@ def _bar(statuses: dict) -> str:
margin-left:.45rem}
.spark .sb{width:5px;background:var(--accent);border-radius:1px;
display:inline-block}
.fresh.stale{color:var(--warn);font-weight:600}
table.recent td.name{white-space:nowrap}
footer{margin-top:2rem;color:var(--muted);font-size:.82em}
"""

# The shared copy handler is delegated, so a chip inside a <summary> would
# also toggle its section. Swallow that one default; the filter is this
# board's own behaviour and stays here.
#
# freshness() is the inbox's staleness warning, and it runs HERE rather than in
# the renderer on purpose. knowledge_board.yml re-publishes on pushes to
# arxiv-inbox.md — which is precisely what stops happening when the nightly
# filing breaks. The page then freezes at its last good render, so a warning
# baked in at render time would sit at "0 weekdays" forever and never fire in
# the one failure it exists for. The rendered date is a fact and survives the
# freeze; the verdict is recomputed against the reader's own clock.
_EXTRA_JS = """
function freshness(){
var els=document.querySelectorAll('.fresh[data-last-digest]');
for(var i=0;i<els.length;i++){var el=els[i];
var p=el.getAttribute('data-last-digest').split('-');
var d=new Date(Date.UTC(+p[0],+p[1]-1,+p[2]));
var now=new Date();
var today=Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate());
var n=0,guard=0;
while(d.getTime()<today&&guard++<400){
d.setUTCDate(d.getUTCDate()+1);
var w=d.getUTCDay(); if(w>0&&w<6){n++;}}
if(n>=+el.getAttribute('data-stale-weekdays')){
el.textContent=el.getAttribute('data-stale-text').replace('{n}',n);
el.classList.add('stale');}}}
freshness();
document.addEventListener("click",function(e){
var b=e.target.closest("button.copy");
if(b&&b.closest("summary")){e.preventDefault();}},true);
Expand Down Expand Up @@ -670,15 +737,41 @@ def _render_html(snapshot: dict) -> str:
f"<li><a href=\"{_html.escape(paper_url(p), quote=True)}\">"
f"{_html.escape(p['title'])}</a> "
f"<span class='meta'>{p['days_left']}d left</span>{''.join(acts)}</li>")
# The date goes into the page; the "this looks broken" verdict is left to
# the reader's browser (_EXTRA_JS). A stale render is exactly the case the
# warning exists for, and a stale render cannot warn about itself.
fresh = _inbox_freshness(snapshot)
stale_tmpl = (f"⚠ no digest since {fresh['stamp']} ({{n}} weekdays) — the "
f"nightly filing may be broken") if fresh["stamp"] else ""

def _fresh_span(now_text: str, stale_text: str, cls: str = "meta") -> str:
if not fresh["stamp"]:
return f"<span class='{cls}'>{_html.escape(now_text)}</span>"
if fresh["stale"]:
# Already stale at render time: show the warning now, so a reader
# with JS off still sees it. The script recomputes `n` regardless.
now_text = stale_text.replace("{n}", str(fresh["weekdays"]))
cls += " stale"
return (f"<span class='{cls} fresh' data-last-digest='{fresh['stamp']}' "
f"data-stale-weekdays='{inbox_actions.INBOX_STALE_WEEKDAYS}' "
f"data-stale-text=\"{_html.escape(stale_text, quote=True)}\">"
f"{_html.escape(now_text)}</span>")

if inbox_items:
digest = f" · last digest {fresh['stamp']}" if fresh["stamp"] else ""
meta = _fresh_span(
f"{len(inbox_items)} waiting{digest} · lapse after "
f"{inbox_actions.INBOX_WINDOW_DAYS}d",
f"{len(inbox_items)} waiting · {stale_tmpl}")
inbox_block = (
f"<details class='qsec inbox' open><summary><span class='name'>"
f"arXiv inbox</span> <span class='meta'>{len(inbox_items)} waiting "
f"· lapse after {inbox_actions.INBOX_WINDOW_DAYS}d</span></summary>"
f"arXiv inbox</span> {meta}</summary>"
f"<ul class='papers'>{''.join(inbox_items)}</ul></details>")
else:
inbox_block = ("<p class='meta'>nothing waiting — the nightly arXiv "
"digest fills this.</p>")
note = _inbox_empty_note(fresh)
inbox_block = ("<p>" + _fresh_span(
f"nothing waiting — {note}",
f"nothing waiting — {stale_tmpl}") + "</p>")

trend = _read_trend(snapshot)
total_done = sum(q.get("done", 0) for q in snapshot.get("queue") or [])
Expand Down
89 changes: 89 additions & 0 deletions scripts/inbox_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@
paper already in the inbox (or already in the reading queue) is a no-op, and
acting on a line that is already gone is a success, not an error.

The module also owns the inbox's **freshness stamp** — one ``last digest:
<date>`` line the nightly digest rewrites on every run, papers or none. An empty
inbox is ambiguous without it (arXiv was quiet, or the filing broke and the
papers were lost); with it, a stamp dated today makes the quiet day *positive
evidence*, exactly as the ``#papers`` empty-day heartbeat does on the Slack side.
It is replaced rather than accumulated, so the sweep never has to age it out.

Each subcommand prints exactly one status token on stdout.

Usage:
python scripts/inbox_actions.py append --papers-file survivors.json
python scripts/inbox_actions.py stamp
python scripts/inbox_actions.py sweep
python scripts/inbox_actions.py add --body-file /tmp/issue_body.txt
python scripts/inbox_actions.py dismiss --body-file /tmp/issue_body.txt
Expand All @@ -42,6 +50,13 @@
#: nothing, so there is no second literal to drift.
INBOX_WINDOW_DAYS = 7

#: How long the board waits before calling a silent digest broken, in weekdays
#: (the digest runs Mon-Fri). Two, not one: the nominal fire is 02:00 UTC and
#: GitHub cron only ever jitters *later* (0-3 h under load), so a board rendered
#: early can legitimately see yesterday's stamp with nothing wrong. Two weekdays
#: of silence is a pattern rather than a slip.
INBOX_STALE_WEEKDAYS = 2

#: Where ➕ sends a paper. The digest is strong-lensing-only, so every inbox
#: paper routes to one reading-queue section; a multi-section inbox is a later
#: problem, and this constant is where it starts.
Expand All @@ -56,6 +71,11 @@
#: Same ref grammar as board.py's QUEUE_REF_RE — kept in step deliberately.
REF_RE = re.compile(
r"^(.*\S)\s+—\s+((?:arXiv:)?\d{4}\.\d{4,5}(?:v\d+)?|https?://\S+)$")
#: The freshness stamp. Deliberately NOT date-first, so it can never collide
#: with INBOX_LINE_RE and be read back as a paper.
STAMP_RE = re.compile(r"^last digest:\s*(\d{4}-\d{2}-\d{2})\s*$")
#: The prose header ends here; the stamp and the papers live below it.
SEPARATOR = "---"
SECTION_RE = re.compile(r"^##\s+(.+?)\s*$")
BODY_LINE_RE = re.compile(r"^line:\s*(.+?)\s*$", re.MULTILINE)
BODY_FILE_RE = re.compile(r"^file:\s*(.+?)\s*$", re.MULTILINE)
Expand Down Expand Up @@ -112,7 +132,66 @@ def days_left(added: str, today: datetime.date) -> int:
return max(0, INBOX_WINDOW_DAYS - (today - stamp).days)


def last_digest(text: str) -> str | None:
"""The date of the last recorded digest run, or None if never stamped."""
for raw in text.splitlines():
m = STAMP_RE.match(raw.strip())
if m:
return m.group(1)
return None


def weekdays_since(stamp: str, today: datetime.date) -> int:
"""Weekdays elapsed since ``stamp``, counting neither endpoint's weekend.

The digest only runs Mon-Fri, so calendar days overstate its silence: a
Monday board reading Friday's stamp is three days but zero *missed runs*.
Counts the days strictly after ``stamp`` up to and including ``today``.
"""
try:
start = datetime.date.fromisoformat(stamp)
except ValueError:
return 0
n, day = 0, start
while day < today:
day += datetime.timedelta(days=1)
if day.weekday() < 5:
n += 1
return n


def is_stale(stamp: str | None, today: datetime.date) -> bool:
"""Whether a digest has been silent long enough to suspect it is broken.

A missing stamp is NOT stale: ``spawn.py`` empties ``arxiv-inbox.md`` for
the template repos, so a freshly spawned Memory has never run a digest and
has nothing to be late for. Absence of evidence, not evidence of breakage.
"""
if stamp is None:
return False
return weekdays_since(stamp, today) >= INBOX_STALE_WEEKDAYS


# --- transitions --------------------------------------------------------------
def set_last_digest(inbox_text: str, date: str) -> str:
"""Record a digest run, replacing any stamp already there.

Inserted directly below the ``---`` separator when absent, so it sits above
the papers: ``append`` places new lines after the last paper (or at EOF when
there are none), and never above the stamp under either branch.
"""
lines = inbox_text.rstrip("\n").splitlines()
line = f"last digest: {date}"
for i, raw in enumerate(lines):
if STAMP_RE.match(raw.strip()):
lines[i] = line
return "\n".join(lines) + "\n"
at = next((i + 1 for i, r in enumerate(lines) if r.strip() == SEPARATOR),
len(lines))
lines[at:at] = [line]
return "\n".join(lines) + "\n"


def append(inbox_text: str, queue_text: str, entries: list[dict],
date: str) -> tuple[str, int]:
"""Append papers not already in the inbox or the reading queue.
Expand Down Expand Up @@ -244,6 +323,13 @@ def _cmd_append(ns) -> tuple[str, int]:
return (f"appended:{n}", 0)


def _cmd_stamp(ns) -> tuple[str, int]:
date = _today(ns).isoformat()
inbox = Path(ns.inbox)
inbox.write_text(set_last_digest(inbox.read_text(errors="replace"), date))
return (f"stamped:{date}", 0)


def _cmd_sweep(ns) -> tuple[str, int]:
inbox = Path(ns.inbox)
text, dropped = sweep(inbox.read_text(errors="replace"), _today(ns))
Expand Down Expand Up @@ -298,6 +384,9 @@ def main(argv: list[str] | None = None) -> int:
help="JSON: [{title, ref}] or {\"papers\": [...]}")
p.set_defaults(fn=_cmd_append)

p = sub.add_parser("stamp", help="record that the digest ran today")
p.set_defaults(fn=_cmd_stamp)

p = sub.add_parser("sweep", help="drop lines past the window")
p.set_defaults(fn=_cmd_sweep)

Expand Down
Loading
Loading