427 lines
22 KiB
Python
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<br /><p>Particles become fixed\xe2\x80\xa6</p><br />[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"]
|