From 05aa80306286d1866d1b6efe595f99074e509df1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:17:33 +0000 Subject: [PATCH 1/2] fix: allow the tracked .claude/ directory in the structure allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 498e1a8 committed .claude/ (the session-start hook + settings) without adding it to ALLOWED_TOP_DIRS, so `make validate` and `make test` have both been red on main ever since — validate_structure only considers git-tracked top-level names, and .claude/ is tracked. Unrelated to the inbox work on this branch; committed separately so it can be read, or reverted, on its own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ex9YARe5kDjsT3Ug7LKQ1r --- scripts/validate_structure.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validate_structure.py b/scripts/validate_structure.py index 31b938d..f6c6989 100644 --- a/scripts/validate_structure.py +++ b/scripts/validate_structure.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] ALLOWED_TOP_DIRS = { + ".claude", # Claude Code session config (hooks, settings) — tooling, not content ".git", ".github", "bibliography", From a4c5b640afdd4ed5ce55b2ac086636afdb4adfe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:22:46 +0000 Subject: [PATCH 2/2] feat: tell a quiet arXiv inbox apart from a broken one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board rendered an empty inbox as "nothing waiting — the nightly arXiv digest fills this" for two opposite states: arXiv genuinely announced no strong-lensing papers, or the digest ran and the filing failed so the papers were lost. On 2026-08-25 it was the second, and nothing on the page said so. arxiv_papers.yml already killed this ambiguity on the Slack side — empty days still post a heartbeat, so silence in #papers always means a broken run. The board had no equivalent. inbox_actions.py now owns a `last digest: ` line in arxiv-inbox.md, rewritten on every digest run whether or not papers arrive. One line does both jobs the prompt scoped: dated today over an empty inbox it is the quiet-day marker, making absence positive evidence; days old it is the "last filled" signal. Being replaced rather than accumulated, the sweep has nothing to age out — which is what the explicit per-day marker line would have cost. The staleness verdict is computed in the reader's browser, not at render time. knowledge_board.yml republishes on pushes to arxiv-inbox.md, which is exactly what stops happening when filing breaks: the page freezes at its last good render, so a warning baked in server-side would sit at "0 weekdays" forever and never fire in the one failure it exists for. The date is a fact and survives the freeze; the verdict is recomputed against the reader's clock. A page that was already stale when rendered warns without JS too. Threshold is 2 weekdays, in inbox_actions.py beside INBOX_WINDOW_DAYS. Not 1: the digest fires at a nominal 02:00 UTC and GitHub cron only ever jitters later, so a board rendered early can see yesterday's stamp with nothing wrong. Weekdays, not days, because the digest is Mon-Fri — a Monday board reading Friday's stamp is three days but zero missed runs. A missing stamp reads neutrally ("no run recorded yet"), never as a warning: 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. Seeds the stamp at 2026-08-25, the day the backfill dispatch filed the two papers now in the inbox. 23 new tests (92 total, was 69), covering the three empty states, the weekday arithmetic across a weekend, and the render/browser handshake. The shipped JS was separately cross-checked against the Python for 30 consecutive stamp dates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ex9YARe5kDjsT3Ug7LKQ1r --- arxiv-inbox.md | 7 +++ scripts/board.py | 109 +++++++++++++++++++++++++++++++++--- scripts/inbox_actions.py | 89 +++++++++++++++++++++++++++++ tests/test_board.py | 79 ++++++++++++++++++++++++++ tests/test_inbox_actions.py | 108 +++++++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 8 deletions(-) diff --git a/arxiv-inbox.md b/arxiv-inbox.md index c7370d2..51370e3 100644 --- a/arxiv-inbox.md +++ b/arxiv-inbox.md @@ -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: ` 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 diff --git a/scripts/board.py b/scripts/board.py index dcc8912..47dead4 100644 --- a/scripts/board.py +++ b/scripts/board.py @@ -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. @@ -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}, } @@ -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"], @@ -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) @@ -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_"] @@ -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 []): @@ -573,6 +616,7 @@ 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} """ @@ -580,7 +624,30 @@ def _bar(statuses: dict) -> str: # The shared copy handler is delegated, so a chip inside a 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;i0&&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); @@ -670,15 +737,41 @@ def _render_html(snapshot: dict) -> str: f"
  • " f"{_html.escape(p['title'])} " f"{p['days_left']}d left{''.join(acts)}
  • ") + # 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"{_html.escape(now_text)}" + 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"" + f"{_html.escape(now_text)}") + 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"
    " - f"arXiv inbox {len(inbox_items)} waiting " - f"· lapse after {inbox_actions.INBOX_WINDOW_DAYS}d" + f"arXiv inbox {meta}
    " f"
      {''.join(inbox_items)}
    ") else: - inbox_block = ("

    nothing waiting — the nightly arXiv " - "digest fills this.

    ") + note = _inbox_empty_note(fresh) + inbox_block = ("

    " + _fresh_span( + f"nothing waiting — {note}", + f"nothing waiting — {stale_tmpl}") + "

    ") trend = _read_trend(snapshot) total_done = sum(q.get("done", 0) for q in snapshot.get("queue") or []) diff --git a/scripts/inbox_actions.py b/scripts/inbox_actions.py index e05d1cb..6074ef4 100644 --- a/scripts/inbox_actions.py +++ b/scripts/inbox_actions.py @@ -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: +`` 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 @@ -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. @@ -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) @@ -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. @@ -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)) @@ -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) diff --git a/tests/test_board.py b/tests/test_board.py index 2d6b170..9ccf48f 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -258,3 +258,82 @@ def test_inbox_renders_before_the_reading_queue(tmp_path): snap = _snap_with_remote(tmp_path) for text in (board._render_md(snap), board._render_html(snap)): assert text.index("arXiv inbox") < text.index("Reading queue") + + +# --- the inbox's freshness line ----------------------------------------------- +# The point of the stamp is that an EMPTY inbox stops being ambiguous. These +# cover the three empty states (quiet / never run / suspect) plus the populated +# one, on both renderers. +def _inbox(tmp_path, stamp=None, papers=()): + # A fresh subdirectory per call: _tree() builds the tree with mkdir() and + # cannot be re-run over one it already made. + base = tmp_path / f"t{len(list(tmp_path.iterdir()))}" + base.mkdir() + root = _tree(base) + text = "# arXiv inbox\n\nintro prose\n\n---\n" + "".join( + f"{line}\n" for line in papers) + if stamp: + text = inbox_actions.set_last_digest(text, stamp) + (root / "arxiv-inbox.md").write_text(text) + snap = board.collect(root) + snap["owner"], snap["repo"] = "PyAutoLabs", "PyAutoMemory" + snap["generated"] = "2026-08-25T09:00:00+00:00" + return snap + + +def test_the_stamp_is_collected(tmp_path): + assert _inbox(tmp_path, "2026-08-24")["inbox_last_digest"] == "2026-08-24" + assert _inbox(tmp_path)["inbox_last_digest"] is None + + +def test_a_quiet_day_says_the_digest_ran(tmp_path): + """The state the old wording could not distinguish from a broken run.""" + md = board._render_md(_inbox(tmp_path, "2026-08-25")) + assert "the last digest ran 2026-08-25 and found nothing" in md + assert "⚠" not in md + + +def test_a_stale_inbox_says_the_filing_may_be_broken(tmp_path): + for render in (board._render_md, board._render_html): + out = render(_inbox(tmp_path, "2026-08-19")) + assert "no digest since 2026-08-19" in out + assert "the nightly filing may be broken" in out + + +def test_a_never_run_inbox_does_not_cry_wolf(tmp_path): + """spawn.py ships the template with an empty inbox and no stamp.""" + for render in (board._render_md, board._render_html): + out = render(_inbox(tmp_path)) + assert "no run recorded yet" in out + assert "may be broken" not in out + + +def test_a_populated_inbox_still_carries_the_date(tmp_path): + snap = _inbox(tmp_path, "2026-08-25", ["2026-08-25 — One — 2608.00001"]) + assert "last digest 2026-08-25" in board._render_md(snap) + assert "last digest 2026-08-25" in board._render_html(snap) + + +# --- the client-side contract ------------------------------------------------- +# knowledge_board.yml republishes on pushes to arxiv-inbox.md, which is what +# stops happening when filing breaks — so the published page freezes and a +# render-time warning could never fire. The date is rendered; the verdict is +# recomputed in the browser. These tests pin the handshake between the two. +def test_the_html_hands_the_browser_what_it_needs(tmp_path): + html = board._render_html(_inbox(tmp_path, "2026-08-25")) + assert "data-last-digest='2026-08-25'" in html + assert (f"data-stale-weekdays='{inbox_actions.INBOX_STALE_WEEKDAYS}'" + in html) + assert "{n}" in html, "the warning template must keep its placeholder" + assert "freshness()" in html + + +def test_a_page_rendered_while_stale_warns_without_js(tmp_path): + html = board._render_html(_inbox(tmp_path, "2026-08-19")) + assert "class='meta stale fresh'" in html + + +def test_an_unstamped_inbox_hands_the_browser_nothing_to_check(tmp_path): + # The attribute-with-value form: the script's own selector text mentions + # the bare attribute name and is always present. + assert "data-last-digest='" not in board._render_html(_inbox(tmp_path)) diff --git a/tests/test_inbox_actions.py b/tests/test_inbox_actions.py index 59e8f10..4ff73ee 100644 --- a/tests/test_inbox_actions.py +++ b/tests/test_inbox_actions.py @@ -318,3 +318,111 @@ def test_add_output_still_parses_as_a_queue_line(tmp_path): "2026-08-30") assert status == "marked" assert f"DONE 2026-08-30 — {queued}" in text + + +# --- the freshness stamp ------------------------------------------------------ +# An empty inbox is ambiguous on its own: arXiv was quiet, or the filing broke +# and the papers were lost. The stamp is what separates the two, so its +# contract is that it round-trips, replaces rather than accumulates, and never +# gets in the way of the papers around it. +def test_stamp_round_trips(): + text = ia.set_last_digest(INBOX, "2026-08-25") + assert ia.last_digest(text) == "2026-08-25" + + +def test_an_unstamped_inbox_reports_none(): + assert ia.last_digest(INBOX) is None + + +def test_stamp_lands_below_the_separator_and_above_the_papers(): + lines = ia.set_last_digest(INBOX, "2026-08-25").splitlines() + assert lines[lines.index("---") + 1] == "last digest: 2026-08-25" + assert lines.index("last digest: 2026-08-25") < lines.index( + "2026-08-24 — A model-independent substructure probe — 2608.21253") + + +def test_stamp_is_replaced_not_accumulated(): + """It is rewritten nightly; a second line would be a slow leak.""" + text = ia.set_last_digest(ia.set_last_digest(INBOX, "2026-08-24"), + "2026-08-25") + assert text.count("last digest:") == 1 + assert ia.last_digest(text) == "2026-08-25" + + +def test_stamp_is_not_read_back_as_a_paper(): + """Why the line is not date-first: it must never match INBOX_LINE_RE.""" + text = ia.set_last_digest(INBOX, "2026-08-25") + assert ia.parse_line("last digest: 2026-08-25") is None + assert [p["ref"] for p in ia.papers(text)] == ["2608.21253", "2608.11111"] + + +def test_the_sweep_never_ages_the_stamp_out(): + """Unlike a per-day marker line, this one has nothing to expire.""" + text = ia.set_last_digest(INBOX, "2026-08-01") + swept, dropped = ia.sweep(text, datetime.date(2026, 9, 30)) + assert ia.last_digest(swept) == "2026-08-01" + assert "last digest: 2026-08-01" not in dropped + + +def test_append_still_lands_below_a_stamped_empty_inbox(): + """The empty-inbox branch appends at EOF — the stamp must stay on top.""" + empty = ia.set_last_digest("# arXiv inbox\n\nprose\n\n---\n", "2026-08-25") + text, n = ia.append(empty, "", [_entry("Fresh", "2608.31337")], + "2026-08-25") + assert n == 1 + lines = text.splitlines() + assert lines.index("last digest: 2026-08-25") < lines.index( + "2026-08-25 — Fresh — 2608.31337") + + +def test_append_still_lands_below_a_stamped_populated_inbox(): + text, n = ia.append(ia.set_last_digest(INBOX, "2026-08-25"), "", + [_entry("Fresh", "2608.31337")], "2026-08-25") + assert n == 1 + lines = text.splitlines() + assert lines.index("last digest: 2026-08-25") < lines.index( + "2026-08-25 — Fresh — 2608.31337") + + +# --- weekday arithmetic ------------------------------------------------------- +# Calendar days overstate the digest's silence: it only runs Mon-Fri, so a +# Monday board reading Friday's stamp is three days but zero missed runs. +def test_a_weekend_costs_no_weekdays(): + friday, monday = "2026-08-21", datetime.date(2026, 8, 24) + assert (monday - datetime.date.fromisoformat(friday)).days == 3 + assert ia.weekdays_since(friday, monday) == 1 + + +def test_weekdays_since_counts_the_days_after_the_stamp(): + assert ia.weekdays_since("2026-08-24", datetime.date(2026, 8, 24)) == 0 + assert ia.weekdays_since("2026-08-24", datetime.date(2026, 8, 25)) == 1 + assert ia.weekdays_since("2026-08-24", datetime.date(2026, 8, 28)) == 4 + + +def test_a_malformed_stamp_is_not_arithmetic(): + assert ia.weekdays_since("not-a-date", datetime.date(2026, 8, 25)) == 0 + + +# --- the staleness verdict ---------------------------------------------------- +def test_one_missed_weekday_is_not_yet_stale(): + """Cron jitters later by up to ~3 h, so a single day is reachable clean.""" + assert ia.INBOX_STALE_WEEKDAYS == 2 + assert not ia.is_stale("2026-08-24", datetime.date(2026, 8, 25)) + + +def test_two_missed_weekdays_is_stale(): + assert ia.is_stale("2026-08-24", datetime.date(2026, 8, 26)) + + +def test_a_missing_stamp_is_not_stale(): + """spawn.py empties the inbox, so a fresh template has nothing to be late + for. Absence of evidence is not evidence of breakage.""" + assert not ia.is_stale(None, datetime.date(2026, 8, 26)) + + +def test_cli_stamp_reports_the_date(tmp_path, capsys): + inbox, queue = _write(tmp_path) + assert ia.main(["--inbox", str(inbox), "--queue", str(queue), + "--date", "2026-08-25", "stamp"]) == 0 + assert capsys.readouterr().out.strip() == "stamped:2026-08-25" + assert ia.last_digest(inbox.read_text()) == "2026-08-25"