Skip to main content

research_agent/application/
references.rs

1use crate::adapters::openalex_source::ReferencesSource;
2use crate::adapters::semantic_scholar_source::SemanticScholarSource;
3use crate::domain::citation::Citation;
4use crate::domain::paper::Paper;
5use crate::error::Result;
6use crate::ports::index_store::IndexStore;
7
8/// Fetch, persist, and return the OpenAlex references of `paper`. Idempotent:
9/// referenced papers dedupe against library entries by OpenAlex id then DOI,
10/// and citation edges dedupe on their paper-id pair. Returns the resolved
11/// reference papers, how many edges were newly inserted, and how many papers
12/// were newly ingested.
13pub async fn sync_references(
14    store: &dyn IndexStore,
15    paper: &Paper,
16) -> Result<(Vec<Paper>, usize, usize)> {
17    let source = ReferencesSource::new();
18    let work_ids = source.reference_ids(paper).await?;
19    let hydrated = source.hydrate(&work_ids).await?;
20
21    let (citations, new_papers) = link_related(store, &paper.id, &hydrated, false)?;
22    let new_edges = store.insert_citations(&citations)?;
23    Ok((hydrated, new_edges, new_papers))
24}
25
26/// Fetch, persist, and return the OpenAlex works citing `paper` (the reverse
27/// direction of [`sync_references`]). Same dedupe/idempotency contract; the
28/// edges point from each citing paper to `paper`.
29pub async fn sync_cited_by(
30    store: &dyn IndexStore,
31    paper: &Paper,
32) -> Result<(Vec<Paper>, usize, usize)> {
33    let source = ReferencesSource::new();
34    let citers = source.citing_papers(paper).await?;
35
36    let (citations, new_papers) = link_related(store, &paper.id, &citers, true)?;
37    let new_edges = store.insert_citations(&citations)?;
38    Ok((citers, new_edges, new_papers))
39}
40
41/// Label already-stored citation edges of `paper` with Semantic Scholar's
42/// per-edge intents (`background`, `methodology`, `result`) and its
43/// `isInfluential` flag, written to `citations.context`. `reverse` picks the
44/// direction: forward labels the works `paper` cites, reverse labels the
45/// works citing it.
46///
47/// Only edges already in the graph are touched, so run this after
48/// [`sync_references`] / [`sync_cited_by`]. Returns `(labeled, unlabeled)`
49/// over the stored edges in that direction: how many got a non-empty label,
50/// and how many were left without one (S2 classified nothing, or the other
51/// paper could not be matched by DOI). S2 classifies only a fraction of
52/// edges upstream, so a partial result is the normal outcome, not a failure.
53pub async fn sync_citation_intents(
54    store: &dyn IndexStore,
55    paper: &Paper,
56    reverse: bool,
57) -> Result<(usize, usize)> {
58    let source = SemanticScholarSource::new();
59    let direction = if reverse { "citations" } else { "references" };
60    let intents = source.citation_intents(paper, direction).await?;
61
62    let stored = if reverse {
63        store.citations_citing_paper(&paper.id)?
64    } else {
65        store.citations_for_paper(&paper.id)?
66    };
67
68    let mut labeled = Vec::new();
69    for intent in &intents {
70        let label = intent.label();
71        if label.is_empty() {
72            continue;
73        }
74        // S2 identifies the other paper by DOI or its own id; the graph keys
75        // edges by local paper id, so resolve through the library. An edge S2
76        // knows about but the graph never stored is skipped.
77        let other = match &intent.doi {
78            Some(doi) => store.find_paper_by_doi(doi)?,
79            None => None,
80        };
81        let Some(other) = other else { continue };
82        let pair_exists = stored.iter().any(|e| {
83            if reverse {
84                e.citing_paper_id == other.id
85            } else {
86                e.cited_paper_id == other.id
87            }
88        });
89        if !pair_exists {
90            continue;
91        }
92        let mut citation = if reverse {
93            Citation::new(other.id, paper.id.clone())
94        } else {
95            Citation::new(paper.id.clone(), other.id)
96        };
97        citation.context = label;
98        labeled.push(citation);
99    }
100
101    let updated = store.set_citation_contexts(&labeled)?;
102    Ok((updated, stored.len().saturating_sub(updated)))
103}
104
105/// Resolve `related` papers against the library (OpenAlex id, then DOI),
106/// ingest unknown ones, and build citation edges between `paper` and each of
107/// them. Forward produces `(paper -> related)` edges; reverse produces
108/// `(related -> paper)`. Self-pairs add nothing to the graph and are skipped.
109fn link_related(
110    store: &dyn IndexStore,
111    paper_id: &str,
112    related: &[Paper],
113    reverse: bool,
114) -> Result<(Vec<Citation>, usize)> {
115    let mut citations = Vec::with_capacity(related.len());
116    let mut new_papers = 0usize;
117    for other in related {
118        let existing = match &other.openalex_id {
119            Some(id) => store.find_paper_by_openalex_id(id)?,
120            None => None,
121        };
122        let existing = match (&existing, &other.doi) {
123            (None, Some(doi)) => store.find_paper_by_doi(doi)?,
124            _ => existing,
125        };
126        let other_id = match existing {
127            Some(p) => p.id,
128            None => {
129                store.insert_paper(other)?;
130                new_papers += 1;
131                other.id.clone()
132            }
133        };
134        // Self-references exist in the wild (versioned preprints citing their
135        // own journal article); storing `(a, a)` adds nothing to the graph.
136        if other_id == paper_id {
137            continue;
138        }
139        citations.push(if reverse {
140            Citation::new(other_id, paper_id.to_string())
141        } else {
142            Citation::new(paper_id.to_string(), other_id)
143        });
144    }
145    Ok((citations, new_papers))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::adapters::sqlite_store::SqliteStore;
152    use crate::domain::paper::Paper;
153
154    #[tokio::test]
155    async fn unknown_identity_errors_before_any_write() {
156        let store = SqliteStore::open_in_memory().unwrap();
157        let paper = Paper::new("no ids".into());
158        let err = sync_references(&store, &paper).await.unwrap_err();
159        assert!(err.to_string().contains("no openalex_id or DOI"));
160        assert!(store.list_papers(None).unwrap().is_empty());
161    }
162
163    /// Dedupe path, exercised without network: pre-store a paper by OpenAlex
164    /// id, then verify the store helpers `sync_references` relies on behave.
165    #[test]
166    fn dedupes_referenced_paper_by_openalex_id() {
167        let store = SqliteStore::open_in_memory().unwrap();
168        let mut existing = Paper::new("already here".into());
169        existing.openalex_id = Some("W1".into());
170        store.insert_paper(&existing).unwrap();
171
172        assert!(store.find_paper_by_openalex_id("W1").unwrap().is_some());
173        assert!(store.find_paper_by_openalex_id("W2").unwrap().is_none());
174    }
175
176    /// Edge direction and self-pair skipping, without network. The
177    /// self-pair case resolves through the store: the related work carries the
178    /// anchor's OpenAlex id, so dedupe lands on the anchor itself.
179    #[test]
180    fn link_related_builds_directional_edges() {
181        let store = SqliteStore::open_in_memory().unwrap();
182        let mut paper = Paper::new("anchor".into());
183        paper.openalex_id = Some("Wanchor".into());
184        store.insert_paper(&paper).unwrap();
185
186        let mut forward = Paper::new("forward ref".into());
187        forward.openalex_id = Some("W1".into());
188        let mut self_pair = Paper::new("same work, fresh record".into());
189        self_pair.openalex_id = Some("Wanchor".into());
190        let related = vec![forward.clone(), self_pair];
191
192        let (citations, new_papers) = link_related(&store, &paper.id, &related, false).unwrap();
193        assert_eq!(new_papers, 1, "only the forward ref is new");
194        assert_eq!(citations.len(), 1, "self-pair skipped");
195        assert_eq!(citations[0].citing_paper_id, paper.id);
196        assert_eq!(citations[0].cited_paper_id, forward.id);
197
198        let mut backward = Paper::new("backward citer".into());
199        backward.openalex_id = Some("W2".into());
200        let (citations, _) = link_related(&store, &paper.id, &[backward.clone()], true).unwrap();
201        assert_eq!(citations.len(), 1);
202        assert_eq!(citations[0].citing_paper_id, backward.id);
203        assert_eq!(citations[0].cited_paper_id, paper.id);
204    }
205
206    /// Intents only relabel edges the graph already stores, and only when S2
207    /// gave a non-empty label. Exercised through the store, without network.
208    #[test]
209    fn set_citation_contexts_scoped_to_existing_edges() {
210        let store = SqliteStore::open_in_memory().unwrap();
211        let anchor = Paper::new("anchor".into());
212        let cited = Paper::new("cited".into());
213        store.insert_paper(&anchor).unwrap();
214        store.insert_paper(&cited).unwrap();
215        store
216            .insert_citations(&[Citation::new(anchor.id.clone(), cited.id.clone())])
217            .unwrap();
218
219        let mut labeled = Citation::new(anchor.id.clone(), cited.id.clone());
220        labeled.context = "methodology+influential".into();
221        // A pair with no stored edge must not create one.
222        let mut orphan = Citation::new(anchor.id.clone(), "ghost".into());
223        orphan.context = "background".into();
224
225        let updated = store.set_citation_contexts(&[labeled, orphan]).unwrap();
226        assert_eq!(updated, 1);
227        let edges = store.citations_for_paper(&anchor.id).unwrap();
228        assert_eq!(edges.len(), 1, "orphan pair did not insert an edge");
229        assert_eq!(edges[0].context, "methodology+influential");
230    }
231
232    /// Live round-trip for intent labeling: sync the graph first, then label.
233    /// Ignored by default (network + S2 rate limits).
234    #[tokio::test]
235    #[ignore]
236    async fn live_sync_citation_intents() {
237        let store = SqliteStore::open_in_memory().unwrap();
238        let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
239        paper.openalex_id = Some("W2741809807".into());
240        paper.doi = Some("10.1038/nature12373".into());
241        store.insert_paper(&paper).unwrap();
242
243        sync_references(&store, &paper).await.unwrap();
244        let (labeled, unlabeled) = sync_citation_intents(&store, &paper, false).await.unwrap();
245        // S2 classifies only a fraction of edges, so assert the invariant
246        // (counts partition the stored edges) rather than a coverage number.
247        let edges = store.citations_for_paper(&paper.id).unwrap();
248        assert_eq!(labeled + unlabeled, edges.len());
249        assert_eq!(
250            edges.iter().filter(|e| !e.context.is_empty()).count(),
251            labeled
252        );
253    }
254
255    /// Live round-trip against the real OpenAlex API. Ignored by default: it
256    /// needs network access. Run explicitly with `cargo test -- --ignored`.
257    #[tokio::test]
258    #[ignore]
259    async fn live_sync_references() {
260        let store = SqliteStore::open_in_memory().unwrap();
261        let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
262        paper.openalex_id = Some("W2741809807".into());
263        store.insert_paper(&paper).unwrap();
264
265        let (papers, new_edges, new_papers) = sync_references(&store, &paper).await.unwrap();
266        assert!(!papers.is_empty());
267        assert!(new_edges > 0);
268        assert!(new_papers > 0);
269        let edges = store.citations_for_paper(&paper.id).unwrap();
270        // Edges track resolved references; fewer when a reference resolves to
271        // the citing paper itself.
272        assert!(edges.len() <= papers.len());
273        // Every cited id resolves to an ingested paper.
274        for edge in &edges {
275            assert!(store.get_paper(&edge.cited_paper_id).unwrap().is_some());
276        }
277    }
278
279    /// Live round-trip for the reverse direction, same contract as
280    /// `live_sync_references`.
281    #[tokio::test]
282    #[ignore]
283    async fn live_sync_cited_by() {
284        let store = SqliteStore::open_in_memory().unwrap();
285        let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
286        paper.openalex_id = Some("W2741809807".into());
287        store.insert_paper(&paper).unwrap();
288
289        let (papers, new_edges, _new_papers) = sync_cited_by(&store, &paper).await.unwrap();
290        assert!(!papers.is_empty());
291        assert!(new_edges > 0);
292        let edges = store.citations_citing_paper(&paper.id).unwrap();
293        assert!(!edges.is_empty());
294        // Every edge points at the anchor paper from an ingested citer.
295        for edge in &edges {
296            assert_eq!(edge.cited_paper_id, paper.id);
297            assert!(store.get_paper(&edge.citing_paper_id).unwrap().is_some());
298        }
299    }
300}