Art 0.1.0

Built from art-dev 78fb10b.
This commit is contained in:
emfurst 2026-09-29 20:52:52 -04:00
commit 7b2bb49e3c
49 changed files with 5284 additions and 0 deletions

263
tests/test_bingham.py Normal file
View file

@ -0,0 +1,263 @@
"""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