art/tests/test_bingham.py
Eric Furst d7e264bcf2 Art 0.2.0
Built from art-dev 2da005a.
2026-10-25 07:54:09 -04:00

427 lines
22 KiB
Python

"""Tests for the deterministic parts of bingham (no network, no models)."""
from pathlib import Path
from unittest import mock
import pytest
from art import store
from art.feeds import fetch
from art.profile import profiles as prof
FIXTURES = Path(__file__).parent / "fixtures"
def test_arxiv_rss_parse():
data = (FIXTURES / "arxiv_rss_cond-mat.soft_2024-05-02.xml").read_bytes()
with mock.patch.object(fetch, "http_get", return_value=data):
items = fetch.fetch_arxiv_rss("cond-mat.soft")
assert len(items) == 12
first = items[0]
assert first["id"] == "2405.00487" and first["version"] == "v1"
assert first["doi"] == "10.48550/arXiv.2405.00487"
assert first["authors"] == ["Henry A. Lockwood", "Suzanne M. Fielding"]
assert first["date"] == "2024-05-02"
assert first["abstract"].startswith("Recoverable strain")
assert "Announce Type" not in first["abstract"]
assert {it["announce_type"] for it in items} == {"new", "cross", "replace", "replace-cross"}
# LaTeX is kept as written
assert any("$S(q)$" in it["abstract"] for it in items)
@pytest.fixture
def conn(tmp_path, monkeypatch):
monkeypatch.setattr(store.env, "path", lambda key: tmp_path / "art.sqlite")
return store.connect()
def _item(**kw):
base = {"id": "10.1122/x", "doi": "10.1122/x", "title": "T", "authors": [],
"date": "2026-09-22", "abstract": "", "source": "J. Rheol.", "categories": []}
return {**base, **kw}
def test_upsert_fills_only_empty_fields(conn):
assert store.upsert_items(conn, [_item(abstract="From the feed.")], "daily") == 1
assert store.upsert_items(conn, [_item(abstract="From Crossref.", authors=["A. B"])], "daily") == 0
row = store.item_dict(conn.execute("SELECT * FROM items").fetchone())
assert row["abstract"] == "From the feed."
assert row["authors"] == ["A. B"]
def test_cross_list_then_new_is_stored_once_as_new(conn):
store.upsert_items(conn, [_item(id="2609.1", announce_type="cross")], "daily")
store.upsert_items(conn, [_item(id="2609.1", announce_type="new")], "daily")
rows = conn.execute("SELECT announce_type FROM items").fetchall()
assert [r[0] for r in rows] == ["new"]
def test_tracked_author_match_is_full_name():
tracked = [prof.TrackedAuthor(name="Priya Raman", match=["Priya Raman"]),
prof.TrackedAuthor(name="Samuel T. Okafor", match=["Samuel T. Okafor"])]
assert prof.tracked_matches(["samuel t. okafor"], tracked) == ["Samuel T. Okafor"]
assert prof.tracked_matches(["P. Raman", "Anil Raman"], tracked) == []
def test_profiles_file_loads_and_versions_are_stable():
data = prof.load()
assert len(data.profiles) >= 1
assert data.profiles[0].version == prof.load().profiles[0].version
def test_legacy_entry_is_screened_once_filled(conn):
from art.screen import screen
store.upsert_items(conn, [_item(abstract="")], "legacy")
assert screen.load_items(conn) == []
store.upsert_items(conn, [_item(abstract="From Crossref.", authors=["A. B"])], "backfill")
assert [it["id"] for it in screen.load_items(conn)] == ["10.1122/x"]
def _arxiv(id_, day, cats=("cond-mat.soft",)):
return fetch._arxiv_item(id_, "v1", f"Paper {id_}", ["A. B"], day, "Abstract.",
list(cats), "new")
def test_weekdays_between():
from datetime import date
from art.feeds.collect import _weekdays_between
fri, mon, thu = date(2026, 9, 25), date(2026, 9, 28), date(2026, 10, 1)
assert _weekdays_between(fri, mon) == []
assert _weekdays_between(mon, thu) == [date(2026, 9, 29), date(2026, 9, 30)]
def test_daily_fetch_catches_up_skipped_days(conn, monkeypatch):
from art.feeds import collect
monkeypatch.setattr(collect.cache, "load_raw", lambda name: ([], None))
monkeypatch.setattr(collect.cache, "save_raw", lambda name, items: None)
cfg = {"categories": ["cond-mat.soft"], "include_cross_lists": True,
"request_delay_s": 0, "max_catchup_days": 14}
monkeypatch.setattr(collect.env, "config", lambda: {"arxiv": cfg})
oai_calls = []
def fake_oai(oai_set, since, **kw):
oai_calls.append((oai_set, since))
return [_arxiv("2609.2", "2026-09-29"), _arxiv("2609.3", "2026-09-30", ("physics.flu-dyn", "cond-mat.soft"))]
monkeypatch.setattr(collect.fetch, "fetch_arxiv_oai", fake_oai)
# Monday: first daily fetch records coverage and does not catch up.
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", lambda cat: [_arxiv("2609.1", "2026-09-28")])
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
assert oai_calls == []
# Thursday: Tuesday and Wednesday were skipped.
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", lambda cat: [_arxiv("2610.1", "2026-10-01")])
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
assert oai_calls == [("physics:cond-mat", "2026-09-29")]
rows = {r["id"]: (r["origin"], r["announce_type"]) for r in conn.execute("SELECT * FROM items")}
assert rows["2609.2"] == ("daily", "new")
assert rows["2609.3"] == ("daily", "cross")
assert store.last_fetch(conn, "arxiv:catchup") is not None
covered = conn.execute("SELECT MAX(detail) FROM fetches WHERE source = 'arxiv:covered'").fetchone()[0]
assert covered == "2026-10-01"
# Thursday again: nothing missing.
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
assert len(oai_calls) == 1
def test_failed_rss_feed_does_not_advance_coverage(conn, monkeypatch):
from art.feeds import collect
monkeypatch.setattr(collect.cache, "load_raw", lambda name: ([], None))
monkeypatch.setattr(collect.cache, "save_raw", lambda name, items: None)
cfg = {"categories": ["cond-mat.soft", "cs.CL"], "include_cross_lists": True,
"request_delay_s": 0, "max_catchup_days": 14}
monkeypatch.setattr(collect.env, "config", lambda: {"arxiv": cfg})
store.log_fetch(conn, "arxiv:covered", 0, 0, True, "2026-09-28")
def rss(cat):
if cat == "cs.CL":
raise RuntimeError("HTTP 503")
return [_arxiv("2609.9", "2026-09-29")]
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", rss)
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
covered = conn.execute("SELECT MAX(detail) FROM fetches WHERE source = 'arxiv:covered'").fetchone()[0]
assert covered == "2026-09-28"
def test_cluster_average_linkage():
import numpy as np
from art.cluster import cluster
S = np.array([[1.0, 0.9, 0.85, 0.1],
[0.9, 1.0, 0.82, 0.2],
[0.85, 0.82, 1.0, 0.1],
[0.1, 0.2, 0.1, 1.0]])
assert sorted(cluster(S, 0.8)) == [[0, 1, 2], [3]]
assert sorted(cluster(S, 0.95)) == [[0], [1], [2], [3]]
def test_user_config_is_merged_over_base(tmp_path, monkeypatch):
from art import env
(tmp_path / "config.yaml").write_text(
"contact: {email: a@b.org}\narxiv: {categories: [cs.CL]}\nscreen: {thresholds: {x: 0.5}}\n")
monkeypatch.setattr(env, "USER_DIR", tmp_path)
env.config.cache_clear()
try:
cfg = env.config()
assert env.contact_email() == "a@b.org"
assert cfg["arxiv"]["categories"] == ["cs.CL"] # lists replace
assert cfg["arxiv"]["request_delay_s"] == 3 # other keys kept
assert cfg["screen"]["thresholds"] == {"default": 0.2, "x": 0.5} # dicts merge
finally:
env.config.cache_clear()
def test_data_file_prefers_user_then_data_then_example(tmp_path, monkeypatch):
from art import env
monkeypatch.setattr(env, "USER_DIR", tmp_path)
assert env.data_file("profiles.yaml").parent.name == "examples"
assert env.data_file("feeds.yaml").parent.name == "data"
(tmp_path / "profiles.yaml").write_text("profiles: []\n")
assert env.data_file("profiles.yaml") == tmp_path / "profiles.yaml"
def test_cluster_stops_at_n_clusters():
import numpy as np
from art.cluster import cluster, medoids
S = np.array([[1.0, 0.9, 0.3, 0.2],
[0.9, 1.0, 0.25, 0.2],
[0.3, 0.25, 1.0, 0.7],
[0.2, 0.2, 0.7, 1.0]])
assert cluster(S, n_clusters=2) == [[0, 1], [2, 3]]
assert medoids(S, [0, 1, 2], k=1) == [0]
# -- profile toolbox ----------------------------------------------------------
def test_ref_from_text():
from art.toolbox.seeds import ref_from_text
assert ref_from_text("https://doi.org/10.1122/8.0001213") == "doi:10.1122/8.0001213"
assert ref_from_text("10.1039/D3SM00479A.") == "doi:10.1039/d3sm00479a"
assert ref_from_text("arXiv:2603.04657v2") == "arxiv:2603.04657"
assert ref_from_text("https://arxiv.org/abs/2609.21066") == "arxiv:2609.21066"
assert ref_from_text("10.48550/arXiv.2609.21066") == "arxiv:2609.21066"
assert ref_from_text("2609.21066") == "arxiv:2609.21066"
assert ref_from_text("no identifier here") is None
def test_orcid_works_use_self_identifiers_only():
import json
from art.toolbox.seeds import parse_orcid_works
works = json.loads((FIXTURES / "orcid_works_0000-0002-1825-0097.json").read_text())
seeds = parse_orcid_works(works)
assert len(seeds) == 6 and all(s["ref"].startswith("doi:10.") for s in seeds)
assert not any("0264-3561" in s["ref"] for s in seeds) # ISSNs are part-of, not self
assert any(s["title"] == "The Memory Bus Considered Harmful" and s["year"] == 2012 for s in seeds)
def test_id_list_bibtex_and_csl_readers():
from art.toolbox.seeds import from_bibtex, from_csl_json, from_id_list
ids = from_id_list("# my papers\n10.1122/8.0001213\n\narXiv:2609.21066 # rolling friction\n10.1122/8.0001213\n")
assert [s["ref"] for s in ids] == ["doi:10.1122/8.0001213", "arxiv:2609.21066"]
bib = from_bibtex("""@article{a,
title = {Rheology of {Non-Brownian} Suspensions},
author = {Denn, Morton M. and Morris, J. F.},
year = {2014}, doi = {10.1146/annurev-chembioeng-060713-040221},
abstract = {A review.}
}
@misc{b, title = "A preprint", eprint = {2609.21066}, archivePrefix = {arXiv}, author = {Q. Doe}}
@book{c, title = {No identifier}}
""")
assert [s["ref"] for s in bib] == ["doi:10.1146/annurev-chembioeng-060713-040221", "arxiv:2609.21066"]
assert bib[0]["title"] == "Rheology of Non-Brownian Suspensions"
assert bib[0]["authors"] == ["Morton M. Denn", "J. F. Morris"] and bib[0]["year"] == 2014
csl = from_csl_json('[{"DOI": "10.1039/D3SM00479A", "title": "Sticky friction", "abstract": "We show.",'
' "author": [{"given": "Hojin", "family": "Kim"}], "issued": {"date-parts": [[2023]]}},'
' {"URL": "http://arxiv.org/abs/2603.04657", "title": "Stan"}, {"title": "none"}]')
assert [s["ref"] for s in csl] == ["doi:10.1039/d3sm00479a", "arxiv:2603.04657"]
assert csl[0]["authors"] == ["Hojin Kim"] and csl[0]["year"] == 2023
def test_abbreviations_and_coauthors():
from art.toolbox.draft import abbreviations, coauthor_suggestions
text = "We use large-amplitude oscillatory shear (LAOS) and small amplitude oscillatory shear (SAOS)."
assert abbreviations(text) == {"LAOS": "large-amplitude oscillatory shear",
"SAOS": "small amplitude oscillatory shear"}
seeds = [{"authors": ["Ada Owner", "Bo Co"]}, {"authors": ["Ada Owner", "Bo Co", "Cy Once"]},
{"authors": ["Ada Owner"]}]
assert coauthor_suggestions(seeds, None) == [("Bo Co", 2)] # owner dropped automatically
assert coauthor_suggestions(seeds, "Ada Owner") == [("Bo Co", 2)]
def test_draft_yaml_loads_as_profiles():
from art.toolbox.draft import profiles_yaml
d = {"n_used": 4, "sources": ["ids"], "llm": None, "tracked": [("Bo Co", 2)],
"profiles": [{"id": "dense-suspensions", "name": "Dense suspensions", "n_papers": 4,
"description": "Friction and lubrication in dense suspensions: " * 5,
"keywords": ["DST", "shear thickening", "friction"], "drafted_by": "placeholder",
"exemplars": [("doi:10.1039/d3sm00479a", "Sticky friction", 2023),
("arxiv:2609.21066", "Rolling friction", None)]}]}
data = prof.Profiles.model_validate(__import__("yaml").safe_load(profiles_yaml(d)))
assert data.profiles[0].exemplars[1].arxiv == "2609.21066"
assert data.tracked_authors == [] # suggestions stay commented out
def test_daily_digest_window_does_not_repeat_previous_papers(conn, monkeypatch):
"""A paper fetched during one daily run must not reappear in the next day's digest."""
import json
from art import digest
monkeypatch.setattr(digest.embed, "model_name", lambda: "e")
monkeypatch.setattr(digest.rerank, "model_name", lambda: "r")
def run(command, started, finished, args=None):
conn.execute("INSERT INTO runs (command, args, started_at, finished_at, host) VALUES (?, ?, ?, ?, 'h')",
(command, json.dumps(args or {}), started, finished))
return conn.execute("SELECT MAX(id) FROM runs").fetchone()[0]
def fetched(item_id, at):
store.upsert_items(conn, [_item(id=item_id, doi=item_id, fetched_at=at)], "daily")
# Monday's daily run starts at 10:00, fetches paper A at 10:00:05, finishes at 10:02.
run("daily", "2026-09-28T10:00:00+00:00", "2026-09-28T10:02:00+00:00")
fetched("mon", "2026-09-28T10:00:05+00:00")
# Tuesday's daily run starts at 10:00 and fetches paper B.
tue = run("daily", "2026-09-29T10:00:00+00:00", None)
fetched("tue", "2026-09-29T10:00:05+00:00")
assert set(digest.select(conn, None, tue).items) == {"tue"}
def test_since_digest_does_not_move_the_daily_window(conn, monkeypatch):
"""A --since digest (a backfill view) must not hide papers from the next daily digest."""
import json
from art import digest
monkeypatch.setattr(digest.embed, "model_name", lambda: "e")
monkeypatch.setattr(digest.rerank, "model_name", lambda: "r")
conn.execute("INSERT INTO runs (command, args, started_at, finished_at, host) VALUES "
"('daily', '{}', '2026-09-28T10:00:00+00:00', '2026-09-28T10:02:00+00:00', 'h')")
store.upsert_items(conn, [_item(id="noon", doi="noon", fetched_at="2026-09-28T12:00:00+00:00")], "daily")
conn.execute("INSERT INTO runs (command, args, started_at, finished_at, host) VALUES "
"('digest', '{\"since\": \"2026-09-01\"}', '2026-09-28T15:00:00+00:00', '2026-09-28T15:00:30+00:00', 'h')")
conn.execute("INSERT INTO runs (command, args, started_at, host) VALUES "
"('daily', '{}', '2026-09-29T10:00:00+00:00', 'h')")
tue = conn.execute("SELECT MAX(id) FROM runs").fetchone()[0]
assert "noon" in digest.select(conn, None, tue).items
def test_crossref_feed_windows(conn, monkeypatch):
"""First fetch: the last week as daily (or backfill_from); later: since the last fetch less the overlap."""
from datetime import datetime, timedelta, timezone
from art.feeds import collect
calls = []
monkeypatch.setattr(collect.fetch, "fetch_crossref",
lambda issn, since, source: calls.append((issn, since, source)) or
[_item(id=f"10.1021/{len(calls)}", doi=f"10.1021/{len(calls)}", source=source)])
stats = {"fetched": 0, "new": 0}
feed = {"name": "Langmuir", "source": "Langmuir", "crossref_issn": "0743-7463"}
collect._fetch_crossref_feed(conn, feed, None, stats)
week_ago = (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat()
assert calls[-1] == ("0743-7463", week_ago, "Langmuir")
assert conn.execute("SELECT origin FROM items").fetchone()[0] == "daily"
collect._fetch_crossref_feed(conn, feed, None, stats) # next run: from the last fetch
assert calls[-1][1] == week_ago
collect._fetch_crossref_feed(conn, {**feed, "name": "Soft Matter", "crossref_issn": "1744-683X",
"backfill_from": "2026-01-01"}, None, stats)
assert calls[-1][:2] == ("1744-683X", "2026-01-01")
def test_rss_doi_from_link(monkeypatch):
rss = b"""<?xml version="1.0"?><rss version="2.0"><channel><title>t</title>
<item><title>Yield stress foods</title><link>https://link.springer.com/article/10.1007/s00397-026-01599-y</link>
<description>Rigorous rheological characterization.</description></item></channel></rss>"""
monkeypatch.setattr(fetch, "http_get", lambda *a, **k: rss)
it = fetch.fetch_rss("Rheologica Acta", "https://example.org/rss", "Rheol. Acta")[0]
assert it["id"] == it["doi"] == "10.1007/s00397-026-01599-y"
def test_rss_aps_abstract_authors_and_date(monkeypatch):
rss = b"""<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:prism="http://prismstandard.org/namespaces/basic/2.0/">
<item rdf:about="http://link.aps.org/doi/10.1103/5pj6-t7zg"><title>Random walk</title>
<link>http://link.aps.org/doi/10.1103/5pj6-t7zg</link>
<description>Author(s): Ann Lee, Bo Wu, and Cy Diaz&lt;br /&gt;&lt;p&gt;Particles become fixed\xe2\x80\xa6&lt;/p&gt;&lt;br /&gt;[Phys. Rev. E 114, 044119] Published Tue Oct 13, 2026</description>
<dc:creator>Ann Lee, Bo Wu, and Cy Diaz</dc:creator><prism:doi>10.1103/5pj6-t7zg</prism:doi>
<dc:date>2026-10-13T10:00:00+00:00</dc:date></item></rdf:RDF>"""
monkeypatch.setattr(fetch, "http_get", lambda *a, **k: rss)
it = fetch.fetch_rss("Physical Review E", "https://example.org/rss", "Phys. Rev. E")[0]
assert it["doi"] == "10.1103/5pj6-t7zg"
assert it["abstract"] == "Particles become fixed\u2026"
assert it["authors"] == ["Ann Lee", "Bo Wu", "Cy Diaz"]
assert it["date"] == "2026-10-13"
def test_crossref_date_falls_back_to_created():
w = {"DOI": "10.1039/D6SM00542J", "title": ["T"], "published-online": {"date-parts": [[2026]]},
"published": {"date-parts": [[2026]]}, "created": {"date-parts": [[2026, 9, 29]]}}
assert fetch._crossref_item(w, "Soft Matter")["date"] == "2026-09-29"
w["published-online"] = {"date-parts": [[2026, 9, 25]]}
assert fetch._crossref_item(w, "Soft Matter")["date"] == "2026-09-25"
def test_link_keyed_items_are_rekeyed_to_their_doi(tmp_path, monkeypatch):
monkeypatch.setattr(store.env, "path", lambda key: tmp_path / "art.sqlite")
conn = store.connect()
link = "https://link.springer.com/article/10.1007/s00397-026-01580-9"
store.upsert_items(conn, [_item(id=link, doi=None, link=link, source="Rheol. Acta")], "daily")
conn.execute("INSERT INTO screens VALUES (?, 'p', 'v', 'e', 'r', 0.5, 'description', 1.0, 'cosine', 0.6, 0.2, 1, 1)", (link,))
conn.commit()
conn = store.connect() # the re-key runs on open
assert [r[0] for r in conn.execute("SELECT id FROM items")] == ["10.1007/s00397-026-01580-9"]
assert conn.execute("SELECT item_id FROM screens").fetchone()[0] == "10.1007/s00397-026-01580-9"
# the same paper arriving from the feed is now recognised, not new
assert store.upsert_items(conn, [_item(id="10.1007/s00397-026-01580-9", doi="10.1007/s00397-026-01580-9",
source="Rheol. Acta")], "daily") == 0
def test_tracked_author_paper_without_rerank_is_listed(conn, monkeypatch):
"""A paper screened before its author was tracked has no re-rank score; the digest must still list it."""
from art import digest
monkeypatch.setattr(digest.embed, "model_name", lambda: "e")
monkeypatch.setattr(digest.rerank, "model_name", lambda: "r")
p = prof.Profile(id="p", name="P", description="d")
data = prof.Profiles(tracked_authors=[prof.TrackedAuthor(name="Ada Owner", match=["Ada Owner"])], profiles=[p])
monkeypatch.setattr(digest.prof, "load", lambda path=None: data)
store.upsert_items(conn, [_item(id="x1", doi="x1", authors=["Ada Owner"], date="2026-09-20")], "backfill")
conn.execute("INSERT INTO screens VALUES ('x1', 'p', ?, 'e', 'r', 0.7, 'description', 3.0, NULL, NULL, 0.2, 0, 1)",
(p.version,))
sel = digest.select(conn, "2026-09-01")
assert [e.item["id"] for e in sel.tracked_only] == ["x1"]
assert "not re-ranked" in "\n".join(digest._entry(conn, sel.tracked_only[0], {"p": p}))
def test_label_bands_and_sampling():
import random
from art.label import band, sample
assert [band(x) for x in (None, 0.05, 0.15, 0.3, 0.9)] == ["none", "<0.1", "0.1-0.2", "0.2-0.4", ">=0.4"]
pop = {f"n{i}": {"band": "none"} for i in range(50)} | {f"h{i}": {"band": ">=0.4"} for i in range(3)}
picked = sample(pop, labeled={"h0"}, n=6, rng=random.Random(0))
assert len(picked) == 6 and "h0" not in picked
assert sum(i.startswith("h") for i in picked) == 2 # both unlabeled high-band papers, the rest from "none"
def test_weighted_recall():
from art.evaluate import _rates
# one relevant paper caught (weight 1), one relevant paper never re-ranked standing for 9 papers
rows = [(1.0, True, 0.8), (9.0, True, None), (1.0, False, 0.5), (30.0, False, None)]
recall, precision = _rates(rows, 0.2)
assert abs(recall - 0.1) < 1e-9 and abs(precision - 0.5) < 1e-9
def test_llm_call_records_ollama_version(tmp_path, monkeypatch):
from types import SimpleNamespace
from art import llm
monkeypatch.setattr(store.env, "path", lambda key: tmp_path / "art.sqlite")
conn = store.connect()
response = SimpleNamespace(response="A summary.", prompt_eval_count=100, eval_count=10,
done_reason="stop", thinking=None)
http = mock.Mock(**{"get.return_value.json.return_value": {"version": "0.40.11"}})
fake = mock.Mock(generate=mock.Mock(return_value=response), _client=http)
monkeypatch.setattr(llm, "client", lambda: fake)
monkeypatch.setattr(llm, "ensure_model", lambda: ("art-researcher:abc", "sha256:def"))
llm.server_version.cache_clear()
result = llm.generate(conn, "label", {"title": "T", "abstract": "A"})
llm.server_version.cache_clear()
assert result.outcome == "ok"
row = conn.execute("SELECT ollama_version, num_ctx FROM llm_calls").fetchone()
assert row["ollama_version"] == "0.40.11" and row["num_ctx"] == llm.cfg()["num_ctx"]