Repository navigation
Conversation
The API is rate limited and paid, so searches need to manage that. The adapter is rate-limit aware.
There was a problem hiding this comment.
🟡 Changes recommended
Credit accounting, concurrent state handling, resumable logging, and rate-limit-aware tests contain correctness and operational issues.
9 open findings
Lock shard state updates and preserve the longest pause · New Count OpenAlex requests before parsing responses · New Keep OpenAlex opt-in to avoid unexpected credit spending · New Exclude cached pairs from repeat logging and classification · New Throttle and distinguish DataCite relation lookup failures · New Skip live benchmark assertions when OpenAlex is rate-limited · New Keep the broad live test free of OpenAlex budget usage · New Handle missing match logs as empty results · New Honor the OpenAlex circuit breaker before diagnostics · New
What changed in this PR
Adds OpenAlex citation discovery with resumable, credit-aware text and graph searches, review logging, Helm scheduling, documentation, and tests.
Changes:
- Implements OpenAlex searching, rate limiting, checkpointing, and relation classification.
- Integrates OpenAlex with package APIs and the Helm cron job.
- Adds offline/live tests, fixtures, benchmarking tools, and documentation.
| File | Description |
|---|---|
.Rbuildignore |
Excludes development artifacts. |
.gitignore |
Ignores generated vignette files. |
DESCRIPTION |
Adds vignette dependencies and metadata. |
NAMESPACE |
Exports the OpenAlex API. |
README.md |
Documents OpenAlex usage and costs. |
R/citation_search.R |
Registers OpenAlex as a source. |
R/citation_search_openalex.R |
Orchestrates searches, checkpoints, logs, and results. |
R/helpers.R |
Adds DOI normalization and chunking helpers. |
R/openalex_client.R |
Implements HTTP, budgeting, retries, and rate limiting. |
R/openalex_search.R |
Implements text and graph searches. |
R/relations.R |
Routes and classifies citation relations. |
R/scythe_set_key.R |
Supports OpenAlex credentials. |
helm/scripts/search.R |
Adds resumable scheduled searches. |
helm/templates/cron-job.yaml |
Configures OpenAlex secrets and persistence. |
helm/values.yaml |
Enables OpenAlex cron settings. |
inst/testdata/doi-variants.csv |
Adds DOI normalization fixtures. |
inst/testdata/openalex-negatives.csv |
Adds negative citation fixtures. |
inst/testdata/test-citations-mbj.csv |
Adds benchmark citation data. |
inst/testdata/test-citations.csv |
Adds known OpenAlex citations. |
man/citation_search.Rd |
Updates source documentation. |
man/citation_search_openalex.Rd |
Documents the new API. |
man/report_est_wait.Rd |
Removes obsolete generated documentation. |
man/scythe_set_key.Rd |
Documents OpenAlex credential sources. |
tests/testthat/helper-openalex.R |
Adds a fake OpenAlex service. |
tests/testthat/test-openalex.R |
Tests searching, limits, retries, and checkpoints. |
tests/testthat/test-scythe.R |
Extends aggregate source testing. |
tools/README.md |
Documents benchmarking workflow. |
tools/benchmark_openalex.R |
Adds recall and precision benchmarking. |
vignettes/openalex-arctic-data-center.Rmd |
Demonstrates staged ADC searches. |
Files not reviewed (3)
- man/citation_search.Rd: Generated file
- man/citation_search_openalex.Rd: Generated file
- man/scythe_set_key.Rd: Generated file
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Comment on lines
+75
to
+82
| state <- read_openalex_state(path) | ||
| fields <- list(...) | ||
| state[names(fields)] <- fields | ||
| state <- Filter(Negate(is.null), state) | ||
| dir.create(dirname(path), showWarnings = FALSE, recursive = TRUE) | ||
| tmp <- paste0(path, ".tmp", Sys.getpid()) | ||
| jsonlite::write_json(state, tmp, auto_unbox = TRUE, pretty = TRUE) | ||
| file.rename(tmp, path) |
Comment on lines
+245
to
+255
| if (!is.na(code) && code == 200) { | ||
| body <- tryCatch(jsonlite::fromJSON(res$body, simplifyVector = FALSE), | ||
| error = function(e) NULL) | ||
| if (is.null(body)) { | ||
| status[i] <- "failed" | ||
| errors[i] <- "unparseable response" | ||
| next | ||
| } | ||
| used <- suppressWarnings(as.numeric(res$headers[["x-ratelimit-credits-used"]] %||% NA)) | ||
| client$credits_used <- client$credits_used + if (is.na(used)) cost[k] else used | ||
| client$consecutive_429 <- 0 |
| paste(method, since %||% "all", paste(b, collapse = "|")) | ||
| }, "") | ||
| cached <- keys %in% names(checkpoint) | ||
| found <- c(found, unname(checkpoint[keys[cached]])) |
Comment on lines
+103
to
+104
| related <- lookup(side[1]) | ||
| hit <- unique(related$relation_type[!is.na(related$doi) & related$doi == side[2]]) |
| citations <- read.csv(system.file("testdata", "test-citations.csv", package = "scythe"), | ||
| stringsAsFactors = FALSE) | ||
| citations <- citations[citations$source == "openalex", ] | ||
| res <- citation_search_openalex(unique(citations$dataone_pid), log_dir = withr::local_tempdir()) |
| return(NULL) | ||
| })) | ||
| keyed_sources <- c(keyed_sources, "plos", "xdd") | ||
| keyed_sources <- c(keyed_sources, "plos", "xdd", "openalex") |
Comment on lines
+70
to
+71
| matches <- jsonlite::stream_in(file(file.path(opts$out, "logs", "openalex-matches.jsonl")), | ||
| verbose = FALSE) |
| gold_key <- paste(gold$article_doi, gold$dataset_doi) | ||
|
|
||
| # Recall: which gold citations were found, and why the others were missed. | ||
| client <- scythe:::openalex_client(max_credits = max_credits - status$credits_used) |
…t can be run manually.
Moved relation lookup to external function, not normally run.
Member
Author
|
Responses to the Copilot review, addressed in 8e04871 and 1a96352:
🤖 Generated with Claude Code |
Closed
… without API credits.
Included docs on how to run these by developers manually before release.
Member
Author
|
Closing this PR, as its functionality was fully merged into PR #47 and so it will be reviewed as a whole there. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Closes #44.
Adds OpenAlex as a citation source, through a new exported
citation_search_openalex()and as"openalex"incitation_search()and the helm cron job. OpenAlex doesn't fit the existing adapters. Rather than one keyword query per DOI, it has two search modes with very different costs, and it charges credits against a daily budget. So most of this PR deals with spending those credits deliberately and resuming work across days.Approach
Two search methods (
R/openalex_search.R)graph: resolves each batch of up to 100 DOIs to OpenAlex work IDs, then fetches the works that cite any of them (cites:filter) and uses each citing work'sreferenced_worksto tell which datasets it cites. This costs about 3 credits per 100 DOIs, but only finds datasets that OpenAlex indexes and that are linked from parsed reference lists.text: an exact full-text search (fulltext.search.exact) for the DOI string, which also finds DOIs in data availability statements, methods and acknowledgements. A search costs 10 credits, so it uses adaptive group testing: each batch is screened with one OR'd query that only counts matches. A batch with matches is split in half and searched again, down to single DOIs, and only the single-DOI searches fetch the works themselves. Most batches have no matches, so the cost grows with the number of datasets that are cited, roughly20 × log2(batch size)per cited DOI, not with the number searched.The methods find largely different citations. On a random sample of 200 Arctic Data Center (ADC) DOIs, 11 citations were found only by
text, 30 only bygraph, and 7 by both. Matches found by both methods are merged and labelledmatch_method = "both".Results versus review logs (
R/relations.R)The returned data.frame keeps the existing
article_id / article_title / dataset_id / sourceshape, so it combines with the other sources incitation_search(). It only includes citing works that have a DOI and aren't datasets. Everything else is appended as JSON Lines tolog_dir, for review rather than dropped:openalex-matches.jsonl: every article match, with its method and the work's metadata.openalex-data-relations.jsonl: data-to-data links. A citing work counts as data if its type isdataset, or if its DOI prefix belongs to a DataONE member repository (scythe.repository_prefixes), since OpenAlex often types repository records asarticle. These links are classified as version, derivation, composition or citation from DataCiterelatedIdentifiers, checked from both sides; links with no DataCite relation stayunknown.openalex-no-doi-citers.jsonl,openalex-unresolved.jsonl,openalex-failed-batches.jsonl.Spending credits safely (
R/openalex_client.R)estimate_openalex_cost()estimates the cost of a search, and the search stops before sending anything if the estimate exceedsmax_credits, which defaults to 500.max_credits = 0therefore gives a free estimate. Withpreflight = FALSEit runs untilmax_creditsis spent instead.max_credits, and stops early when OpenAlex's reported remaining budget is down to a reserve (scythe.openalex.reserve, 5% by default).max_wait, or an exhausted daily budget, stops all requests and returns partial results. The time the pause ends is written to a sharedstate_file, so other processes using the same key don't send requests before then. Server errors are retried with backoff, then the batch is logged as failed.openalex_statusattribute:complete,stopped,paused_until,estimate,credits_usedandrequests.Resuming across runs
Completed batches are appended to
openalex-checkpoint.jsonlincheckpoint_dir, keyed by method,sinceand the batch's DOIs. A rerun with the same directory skips them, so a large search can be run in daily installments untilcompleteisTRUE.Helm job (
helm/scripts/search.R, chart)--sinceand--lookback: after the first complete run, which is a full backfill, each run only searches works published since the last complete run minuslookbackdays. An interrupted run resumes with the date filter it started with.--shard i/n: splits the DOIs between jobs by a stable hash of each DOI.cronjob.openalex.approve(a number of credits, orunlimited). The cron job isconcurrencyPolicy: Forbid, so runs don't compete for the budget, and the OpenAlex secrets are optional.Supporting changes
normalize_doi()(R/helpers.R) handles resolver URLs,doi:,info:doi/andurn:doi:schemes, stacked prefixes, URL encoding, surrounding punctuation, and parentheses inside DOIs.check_identifiers()now uses it.scythe_set_key()acceptsopenalexandopenalex_mailto, and builds its error message from the list of valid sources.vignettes/openalex-arctic-data-center.Rmd) that walks through a staged search of ADC DOIs, andtools/benchmark_openalex.Rfor measuring precision and recall against a gold set. The vignette's searches only run withSCYTHE_OPENALEX_LIVE=true, so R CMD check and CI stay offline and free.Testing
tests/testthat/helper-openalex.Ris a fake OpenAlex API that answers thefulltext.search.exact,doiandcitesfilters, and can return queued 429s, server errors and rate-limit headers. The offline tests cover attributing text matches to single DOIs, graph joins and pagination, routing results to the logs, the cost check andmax_creditscap, rate limits shared through the state file, retries, checkpoint resume, andsince.inst/testdata.devtools::check()passes locally. The ADC vignette stages were also run by hand against the live API.Worth a close look
openalex_status$stoppedis"rate_limited".10.18739/A23H1N, which Springer's API finds, but the DOI isn't in OpenAlex's full-text index and the paper's reference list doesn't link it. OpenAlex complements the other sources rather than replacing them.normalize_doi()doesn't extract DOIs from catalog URLs such ashttps://arcticdata.io/catalog/#view/doi:10.18739/A29K97; they're logged as "not a DOI".options(scythe.classify_relations = FALSE).batch_sizeorsinceinvalidates checkpoints. Batch keys are built from the DOIs in each batch, so changing either one between runs means none of the earlier checkpoints are reused.🤖 Generated with Claude Code