Skip to main content

doiget_core/
discovery.rs

1//! External literature **discovery search** over OpenAlex `/works?search=`.
2//!
3//! This is the front half of the #281 research loop (`search → triage →
4//! expand → fetch → read → map`). Unlike [`FsStore::search`](crate::store::FsStore)
5//! (which re-finds papers already in the local store) and unlike the
6//! citation `graph` walker, this module turns a free-text *topic* into a
7//! ranked list of candidate papers — each carrying enough metadata
8//! (title / abstract / year / venue / citation count / OA status / DOI)
9//! for an agent to triage *before* any PDF is fetched.
10//!
11//! ## Capability tier (ADR-0031)
12//!
13//! Discovery search is **Tier 1 OA metadata, always-on**: there is no
14//! `DOIGET_ENABLE_OPENALEX` gate and no Cargo-feature gate. It ships in
15//! the default `oa-only` binary. The justification (ADR-0031 D1) is that
16//! a bounded OpenAlex query is the same network-surface risk class as the
17//! Crossref / Unpaywall calls Tier 1 already makes on every fetch:
18//! read-only OA metadata, never paywalled, never a PDF.
19//!
20//! This is deliberately **distinct** from `crate::sources::openalex`
21//! (the `#[cfg(feature = "metadata")]` enrichment / `referenced_works[]`
22//! source used by `graph`, which stays Tier 2 behind
23//! `DOIGET_ENABLE_OPENALEX`). The `Source` trait is `ref → FetchResult`;
24//! search is `query → list`, so it does not fit that trait and lives here
25//! as a free function reusing only the shared [`HttpClient`], rate
26//! limiter, and provenance log via [`FetchContext`].
27//!
28//! ## Author / venue / publisher filters (ADR-0031 D5)
29//!
30//! OpenAlex filters authors / sources (venues) / publishers by **entity
31//! ID**, not free text. So `paper_search` first resolves a supplied
32//! `--author` / `--venue` / `--publisher` *name* to its OpenAlex ID via a
33//! `?search=` lookup against `/authors`, `/sources`, `/publishers`, then
34//! filters `/works` by `authorships.author.id` /
35//! `primary_location.source.id` /
36//! `primary_location.source.publisher_lineage`. The top hit is NOT taken
37//! blindly: `select_entity` resolves only an unambiguous name (a single
38//! hit, an exact case-insensitive name match, or a top hit that clearly
39//! out-scores the runner-up); a name matching several entities with no
40//! clear winner is a typed [`FetchError::Ambiguous`] listing the
41//! candidates, and a name matching nothing is [`FetchError::NotFound`].
42//! The filter is never silently dropped.
43//!
44//! ## Metadata-only contract (ADR-0031 D3)
45//!
46//! Every call here uses [`HttpClient::fetch_bytes`] (a JSON body),
47//! **never** `fetch_pdf`, and never follows an OA URL. The abstract is
48//! reconstructed from OpenAlex's `abstract_inverted_index`.
49//!
50//! [`HttpClient`]: crate::http::HttpClient
51//! [`HttpClient::fetch_bytes`]: crate::http::HttpClient::fetch_bytes
52
53use serde::Serialize;
54use url::Url;
55
56use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
57use crate::source::{FetchContext, FetchError};
58
59/// Source key used for the per-source HTTP client + redirect allowlist.
60///
61/// Shares the `"openalex"` key with `crate::sources::openalex` so that
62/// `crate::http::discovery_allowlist` (always compiled) and
63/// `tier_2_allowlist` (always compiled, but only *called* by the CLI
64/// under `#[cfg(feature = "metadata")]` — #516) register the same
65/// `api.openalex.org` host under one key (an idempotent overwrite — see
66/// ADR-0031 D2).
67const SOURCE_KEY: &str = "openalex";
68
69/// OpenAlex `select=` field list. Bounds the response payload to exactly
70/// the top-level fields [`PaperHit`] needs; every entry here is a
71/// top-level Work field (nested selection is not used).
72const SELECT_FIELDS: &str = "id,doi,title,display_name,publication_year,\
73cited_by_count,fwci,cited_by_percentile_year,abstract_inverted_index,authorships,\
74primary_location,open_access,locations";
75
76/// OpenAlex caps `per-page` at 200; requests above that are rejected by
77/// the API. `build_search_url` clamps to this as defense-in-depth, but
78/// the CLI rejects an out-of-range `--limit` up front (so the user is not
79/// silently given fewer results than asked).
80pub const MAX_PER_PAGE: usize = 200;
81
82/// Default page size when the caller does not specify `--limit`.
83pub const DEFAULT_LIMIT: usize = 25;
84
85/// Ordering applied to the discovery result set.
86///
87/// **Relevance is the only sort** (issue #290). Verified against live
88/// OpenAlex: every non-relevance sort (`cited_by_count`, `fwci`,
89/// `publication_date`) over OpenAlex's loose full-text match floats
90/// high-scoring *off-topic* papers to the top — they override the one
91/// signal that enforces topicality. "Important / recent / high-quality" is
92/// therefore expressed as **filters** (`min_fwci` / `min_percentile` /
93/// `from_year`), which narrow the candidate set without discarding
94/// relevance ordering. (Non-relevance sorting is only safe over an
95/// already-topically-constrained set — not free-text `search`.)
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum SearchSort {
98    /// OpenAlex `relevance_score:desc` — best textual match to `query`
99    /// first. Only meaningful because a search term is always present.
100    #[default]
101    Relevance,
102}
103
104impl SearchSort {
105    /// The OpenAlex `sort=` parameter value for this ordering.
106    #[must_use]
107    pub fn as_openalex(self) -> &'static str {
108        match self {
109            SearchSort::Relevance => "relevance_score:desc",
110        }
111    }
112}
113
114/// A discovery-search request: the free-text query plus triage filters.
115///
116/// Construct directly (all fields are public); the CLI maps its flags
117/// onto this.
118#[derive(Debug, Clone)]
119pub struct PaperSearchQuery {
120    /// Free-text topic query (e.g. "tropical tensor networks for spin
121    /// glasses"). Must be non-empty; the caller is expected to reject
122    /// empty input.
123    pub query: String,
124    /// Maximum number of results to return, bounded to `1..=200` (OpenAlex
125    /// `per-page` ceiling). [`validate`](Self::validate) **rejects** an
126    /// out-of-range value; `paper_search` itself only clamps it as
127    /// defense-in-depth (see the function's caller-side-validation note).
128    pub limit: usize,
129    /// Inclusive lower bound on publication year (maps to OpenAlex
130    /// `from_publication_date:<year>-01-01`).
131    pub from_year: Option<i32>,
132    /// Inclusive upper bound on publication year (maps to OpenAlex
133    /// `to_publication_date:<year>-12-31`).
134    pub to_year: Option<i32>,
135    /// When `true`, restrict to open-access works (`is_oa:true`).
136    pub oa_only: bool,
137    /// Minimum citation count. Maps to OpenAlex `cited_by_count:>{n}`
138    /// ("more than n"); the off-by-one versus "at least n" is documented
139    /// on the CLI flag.
140    pub min_citations: Option<u64>,
141    /// Minimum field-and-year-normalized citation impact (FWCI). Maps to
142    /// OpenAlex `fwci:>{f}` — an impact floor that, unlike sorting by
143    /// citations, narrows the set without overriding relevance (#290).
144    pub min_fwci: Option<f64>,
145    /// Minimum within-cohort citation percentile (0–100). Maps to OpenAlex
146    /// `cited_by_percentile_year.min:{p}` — "top-X% among same-year works";
147    /// combined with `from_year` this is the "recent × already standing
148    /// out" set (#290).
149    pub min_percentile: Option<u8>,
150    /// Author name to filter by. Resolved to an OpenAlex author ID via
151    /// `/authors?search=` then applied as `authorships.author.id`.
152    pub author: Option<String>,
153    /// Venue / journal name to filter by. Resolved to an OpenAlex source
154    /// ID via `/sources?search=` then applied as
155    /// `primary_location.source.id`.
156    pub venue: Option<String>,
157    /// Publisher name to filter by. Resolved to an OpenAlex publisher ID
158    /// via `/publishers?search=` then applied as
159    /// `primary_location.source.publisher_lineage`.
160    pub publisher: Option<String>,
161    /// Result ordering.
162    pub sort: SearchSort,
163}
164
165impl PaperSearchQuery {
166    /// A bare query with [`DEFAULT_LIMIT`], no filters, relevance sort.
167    #[must_use]
168    pub fn new(query: impl Into<String>) -> Self {
169        Self {
170            query: query.into(),
171            limit: DEFAULT_LIMIT,
172            from_year: None,
173            to_year: None,
174            oa_only: false,
175            min_citations: None,
176            min_fwci: None,
177            min_percentile: None,
178            author: None,
179            venue: None,
180            publisher: None,
181            sort: SearchSort::Relevance,
182        }
183    }
184
185    /// Validate the request shape, returning a human-readable message on
186    /// the first problem. This is the single source of truth for the
187    /// boundary validation that both the CLI and the MCP tool apply
188    /// (`paper_search` itself stays permissive — see its docs); keeping it
189    /// here prevents the two surfaces from drifting.
190    ///
191    /// Checks: non-empty `query`, `limit` in `1..=`[`MAX_PER_PAGE`], a
192    /// non-inverted `from_year`/`to_year` range, a finite non-negative
193    /// `min_fwci`, and a `min_percentile` in `0..=100`.
194    ///
195    /// # Errors
196    ///
197    /// `Err(msg)` describing the first invalid field; `msg` is suitable for
198    /// surfacing directly to a user / agent.
199    pub fn validate(&self) -> Result<(), String> {
200        if self.query.trim().is_empty() {
201            return Err("search query is empty".to_string());
202        }
203        if !(1..=MAX_PER_PAGE).contains(&self.limit) {
204            return Err(format!(
205                "limit must be between 1 and {MAX_PER_PAGE} (got {})",
206                self.limit
207            ));
208        }
209        if let (Some(from), Some(to)) = (self.from_year, self.to_year) {
210            if from > to {
211                return Err(format!("from_year ({from}) is after to_year ({to})"));
212            }
213        }
214        // `min_fwci` becomes a literal `fwci:>{f}` filter clause; a negative
215        // or non-finite value would be a malformed OpenAlex request that the
216        // API rejects (or silently ignores). Reject it here, at the same
217        // boundary as the year range, rather than emit a bad filter (#290).
218        if let Some(f) = self.min_fwci {
219            if !f.is_finite() || f < 0.0 {
220                return Err(format!(
221                    "min_fwci must be a finite, non-negative number (got {f})"
222                ));
223            }
224        }
225        // The percentile is a 0–100 cohort rank; `u8` already excludes
226        // negatives, but 101–255 would emit a `cited_by_percentile_year.min`
227        // clause OpenAlex cannot satisfy (empty result, no error).
228        if let Some(p) = self.min_percentile {
229            if p > 100 {
230                return Err(format!(
231                    "min_percentile must be between 0 and 100 (got {p})"
232                ));
233            }
234        }
235        Ok(())
236    }
237}
238
239/// Discovery backend that produced a [`PaperHit`].
240///
241/// PR1 has a single source; `#[non_exhaustive]` reserves room for future
242/// Tier-1 discovery backends (e.g. Semantic Scholar) without a breaking
243/// change, while the wire form stays the lowercase source name (so the
244/// JSON shape is unchanged from the previous `&'static str` field).
245#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
246#[serde(rename_all = "lowercase")]
247#[non_exhaustive]
248pub enum DiscoverySource {
249    /// OpenAlex `/works?search=`. Serializes to `"openalex"`.
250    OpenAlex,
251}
252
253/// One candidate paper returned by discovery search.
254///
255/// All fields except `openalex_id` / `title` / `cited_by_count` /
256/// `source` are `Option` because OpenAlex omits them for some records
257/// (e.g. no DOI for a dataset, no abstract for an Elsevier-gated
258/// abstract). Absent fields serialize to JSON `null` (not skipped) so
259/// the wire shape is stable for agents.
260#[derive(Debug, Clone, Serialize, PartialEq)]
261pub struct PaperHit {
262    /// Bare DOI (lower-cased, `https://doi.org/` prefix stripped), or
263    /// `None` when the record has no DOI.
264    pub doi: Option<String>,
265    /// OpenAlex Work ID (`W…`, `https://openalex.org/` prefix stripped).
266    /// An empty string signals a malformed upstream record (the `id` field
267    /// was absent) that was kept rather than dropped so one bad record does
268    /// not sink the page — do NOT use `""` as a fetchable id.
269    pub openalex_id: String,
270    /// arXiv id, best-effort extracted from a `locations[].*url`
271    /// containing `arxiv.org/abs/<id>`; `None` if no arXiv location.
272    pub arxiv: Option<String>,
273    /// Work title.
274    pub title: String,
275    /// Author display names, in OpenAlex authorship order.
276    pub authors: Vec<String>,
277    /// Publication year, or `None` if absent.
278    pub year: Option<i32>,
279    /// Primary venue display name (journal / repository), or `None`.
280    pub venue: Option<String>,
281    /// Reconstructed abstract text, or `None` when OpenAlex has no
282    /// `abstract_inverted_index` for the record.
283    #[serde(rename = "abstract")]
284    pub abstract_: Option<String>,
285    /// OpenAlex `cited_by_count`.
286    pub cited_by_count: u64,
287    /// OpenAlex open-access status (`gold` / `green` / `hybrid` /
288    /// `bronze` / `closed`), or `None`.
289    pub oa_status: Option<String>,
290    /// OpenAlex `fwci` (Field-Weighted Citation Impact). A value > 1.0
291    /// means above-average impact for the paper's field and year. `None`
292    /// when OpenAlex has not yet computed a value for the work.
293    pub fwci: Option<f64>,
294    /// OpenAlex `cited_by_percentile_year.min` — the paper's minimum
295    /// percentile rank among same-year works in the same field (0–100).
296    /// `None` when OpenAlex has not computed the percentile. A value of
297    /// 90 means "top 10% of same-year works in its field" (#295).
298    pub cited_by_percentile_year_min: Option<u8>,
299    /// Discovery backend that produced this hit (PR1: always
300    /// [`DiscoverySource::OpenAlex`]). Serializes to `"openalex"`.
301    pub source: DiscoverySource,
302}
303
304/// The result of a discovery search: the hits plus the upstream total.
305#[derive(Debug, Clone, Serialize, PartialEq)]
306pub struct PaperSearchResults {
307    /// The candidate papers (length ≤ `query.limit`).
308    pub results: Vec<PaperHit>,
309    /// OpenAlex `meta.count` — the total number of matching works
310    /// upstream (usually far larger than `results.len()`), or `None` if
311    /// the response omitted it. Lets an agent see "showing 25 of 4012".
312    pub total_results: Option<u64>,
313}
314
315/// OpenAlex entity IDs resolved from the `--author` / `--venue` /
316/// `--publisher` name filters (each `None` when the filter is unset).
317#[derive(Debug, Default)]
318struct ResolvedIds {
319    /// Author ID (`A…`) for `authorships.author.id`.
320    author: Option<String>,
321    /// Source ID (`S…`) for `primary_location.source.id`.
322    source: Option<String>,
323    /// Publisher ID (`P…`) for `primary_location.source.publisher_lineage`.
324    publisher: Option<String>,
325}
326
327/// Run a discovery search against OpenAlex and return ranked candidates.
328///
329/// `base` is the OpenAlex API base URL (production
330/// `https://api.openalex.org`; tests inject a wiremock origin, mirroring
331/// the `DOIGET_OPENALEX_BASE` override the CLI honors). `contact_email`
332/// opts into the polite pool via `?mailto=` when non-empty.
333///
334/// When `query.author` / `query.venue` / `query.publisher` are set, this
335/// first issues one `?search=` lookup each against `/authors` /
336/// `/sources` / `/publishers` to resolve the name to an OpenAlex ID, then
337/// filters `/works` by that ID. Every call reuses `ctx.http` (allowlisted,
338/// HTTPS-only in production), `ctx.rate_limiter`, and `ctx.log` (one
339/// `Metadata`/`Fetch` provenance row per request). Never fetches a PDF
340/// (ADR-0031 D3).
341///
342/// ## Caller-side validation
343///
344/// This is permissive on the `query` shape — boundary validation is the
345/// caller's job (the CLI does it; an MCP tool should too). Specifically:
346/// `query.limit` is **clamped** to `1..=200` (not rejected), and an
347/// inverted year range (`from_year > to_year`) is passed through and
348/// yields an **empty** result set rather than an error. Direct callers
349/// that want a typed error for those should pre-validate.
350///
351/// # Errors
352///
353/// Returns [`FetchError::Http`] for transport / allowlist failures,
354/// [`FetchError::NotFound`] when an author/venue/publisher name resolves
355/// to nothing, [`FetchError::Ambiguous`] when such a name matches several
356/// entities with no clear winner (carries a candidate listing),
357/// [`FetchError::SourceSchema`] when a response is not a JSON object
358/// carrying a `results` array, and propagates a provenance-log append
359/// failure (fail-closed).
360pub async fn paper_search(
361    base: &Url,
362    contact_email: &str,
363    query: &PaperSearchQuery,
364    ctx: &FetchContext,
365) -> Result<PaperSearchResults, FetchError> {
366    // Resolve the name → ID filters first (one OpenAlex lookup each).
367    let ids = ResolvedIds {
368        author: resolve_optional(base, contact_email, "authors", &query.author, ctx).await?,
369        source: resolve_optional(base, contact_email, "sources", &query.venue, ctx).await?,
370        publisher: resolve_optional(base, contact_email, "publishers", &query.publisher, ctx)
371            .await?,
372    };
373
374    let url = build_search_url(base, contact_email, query, &ids)?;
375    let (value, _bytes) = openalex_get(&url, ctx).await?;
376
377    let results_array = value
378        .get("results")
379        .and_then(serde_json::Value::as_array)
380        .ok_or_else(|| missing_results_array("search", &value))?;
381
382    let results: Vec<PaperHit> = results_array.iter().map(work_to_hit).collect();
383    let total_results = value
384        .get("meta")
385        .and_then(|m| m.get("count"))
386        .and_then(serde_json::Value::as_u64);
387
388    Ok(PaperSearchResults {
389        results,
390        total_results,
391    })
392}
393
394/// Issue one OpenAlex GET: rate-limit, fetch the JSON body, parse it, and
395/// append the `Metadata`/`Fetch` provenance row. Returns the parsed value
396/// plus the byte length (the caller needs neither beyond the value, but
397/// the length keeps the provenance accounting in one place).
398async fn openalex_get(
399    url: &Url,
400    ctx: &FetchContext,
401) -> Result<(serde_json::Value, usize), FetchError> {
402    // Step 1: rate limiter (politeness — same channel every source uses).
403    let _permit = ctx.rate_limiter.acquire(SOURCE_KEY).await;
404
405    // Step 2: HTTP fetch (JSON; `select=`/`per-page=` keep it small).
406    let (body, _final_url) = ctx.http.fetch_bytes(SOURCE_KEY, url.clone()).await?;
407
408    // Step 3: parse.
409    let value: serde_json::Value =
410        serde_json::from_slice(&body).map_err(|e| FetchError::SourceSchema {
411            hint: format!("openalex returned non-JSON: {e}"),
412        })?;
413
414    // Step 4: provenance. Tier-1 metadata read; no single ref (it is a
415    // query), so `ref_` / `canonical_digest` are null per
416    // docs/PROVENANCE_LOG.md.
417    ctx.log.append(RowInput {
418        event: LogEvent::Fetch,
419        result: LogResult::Ok,
420        capability: Capability::Metadata,
421        ref_: None,
422        source: Some(SOURCE_KEY),
423        error_code: None,
424        size_bytes: Some(body.len() as u64),
425        license: None,
426        store_path: None,
427        canonical_digest: None,
428    })?;
429
430    Ok((value, body.len()))
431}
432
433/// Resolve an optional name filter to an OpenAlex entity ID, or `None`
434/// when the name is unset / blank.
435async fn resolve_optional(
436    base: &Url,
437    contact_email: &str,
438    entity_path: &str,
439    name: &Option<String>,
440    ctx: &FetchContext,
441) -> Result<Option<String>, FetchError> {
442    match name {
443        Some(n) if !n.trim().is_empty() => Ok(Some(
444            resolve_entity_id(base, contact_email, entity_path, n, ctx).await?,
445        )),
446        _ => Ok(None),
447    }
448}
449
450/// Resolve a name to a single OpenAlex entity ID for `entity_path`
451/// (`authors` / `sources` / `publishers`) via `?search=`.
452///
453/// OpenAlex `?search=` is partial / fuzzy and relevance-ranked, so a
454/// vague name still matches. To avoid silently filtering by the wrong
455/// entity, this fetches the top few candidates and applies
456/// [`select_entity`]: an unambiguous name (single hit, an exact-name
457/// match, or a clearly-dominant top hit) resolves; an ambiguous one is a
458/// typed [`FetchError::Ambiguous`] that lists the candidates so the
459/// caller can narrow the name. A name that matches nothing is
460/// [`FetchError::NotFound`]. The filter is never silently dropped.
461async fn resolve_entity_id(
462    base: &Url,
463    contact_email: &str,
464    entity_path: &str,
465    name: &str,
466    ctx: &FetchContext,
467) -> Result<String, FetchError> {
468    let mut url = base
469        .join(&format!("/{entity_path}"))
470        .map_err(|e| FetchError::SourceSchema {
471            hint: format!("openalex {entity_path} URL construction failed: {e}"),
472        })?;
473    {
474        let mut qp = url.query_pairs_mut();
475        qp.append_pair("search", name);
476        // Top few candidates so an ambiguous name can be reported with
477        // alternatives instead of silently resolving to the first hit.
478        // No `select=` so OpenAlex returns `relevance_score` (only present
479        // on search responses) alongside `display_name` / `works_count`.
480        qp.append_pair("per-page", "5");
481        if !contact_email.is_empty() {
482            qp.append_pair("mailto", contact_email);
483        }
484    }
485
486    let (value, _len) = openalex_get(&url, ctx).await?;
487    // A valid JSON object with no `results` array is a schema failure
488    // (e.g. an OpenAlex error envelope: rate limit / bad filter), NOT an
489    // empty match set — mirror the `/works` path. Collapsing it to an
490    // empty Vec here would surface a misleading "no <entity> matched"
491    // NotFound and silently drop the user's filter.
492    let results_arr = value
493        .get("results")
494        .and_then(serde_json::Value::as_array)
495        .ok_or_else(|| missing_results_array(&format!("/{entity_path}"), &value))?;
496    let mut candidates: Vec<Candidate> = results_arr
497        .iter()
498        .filter_map(Candidate::from_value)
499        .collect();
500    // OpenAlex returns search hits relevance-sorted, but make the
501    // dominance check order-independent.
502    candidates.sort_by(|a, b| {
503        b.relevance
504            .partial_cmp(&a.relevance)
505            .unwrap_or(std::cmp::Ordering::Equal)
506    });
507
508    select_entity(entity_path, name, &candidates)
509}
510
511/// One OpenAlex entity-search candidate (author / source / publisher).
512struct Candidate {
513    /// Bare OpenAlex ID (`A…` / `S…` / `P…`).
514    id: String,
515    /// Entity display name (used for the exact-match check + listings).
516    display_name: String,
517    /// Number of works attributed to the entity (shown in the ambiguity
518    /// listing so the caller can spot the prolific / canonical match).
519    works_count: u64,
520    /// OpenAlex `relevance_score` for the search query (0.0 if absent).
521    relevance: f64,
522}
523
524impl Candidate {
525    fn from_value(v: &serde_json::Value) -> Option<Self> {
526        let id = v
527            .get("id")
528            .and_then(serde_json::Value::as_str)
529            .map(strip_openalex_prefix)?;
530        Some(Self {
531            id,
532            display_name: v
533                .get("display_name")
534                .and_then(serde_json::Value::as_str)
535                .unwrap_or("")
536                .to_string(),
537            works_count: v
538                .get("works_count")
539                .and_then(serde_json::Value::as_u64)
540                .unwrap_or(0),
541            relevance: v
542                .get("relevance_score")
543                .and_then(serde_json::Value::as_f64)
544                .unwrap_or(0.0),
545        })
546    }
547}
548
549/// Relevance-dominance ratio: with no exact-name match, the top hit must
550/// out-score the runner-up by at least this factor to be auto-selected;
551/// otherwise the name is treated as ambiguous.
552const DOMINANCE_RATIO: f64 = 2.0;
553
554/// Pick a single entity from relevance-sorted search `candidates`, or
555/// report ambiguity.
556///
557/// Resolution order: empty → [`FetchError::NotFound`]; single candidate →
558/// it; exactly one case-insensitive exact display-name match → it; else
559/// the top hit when it out-scores the runner-up by [`DOMINANCE_RATIO`];
560/// otherwise [`FetchError::Ambiguous`] listing the candidates.
561fn select_entity(
562    entity_path: &str,
563    name: &str,
564    candidates: &[Candidate],
565) -> Result<String, FetchError> {
566    let label = entity_label(entity_path);
567    if candidates.is_empty() {
568        return Err(FetchError::NotFound {
569            hint: format!("no OpenAlex {label} matched '{name}'"),
570        });
571    }
572    if candidates.len() == 1 {
573        return Ok(candidates[0].id.clone());
574    }
575
576    let exact: Vec<&Candidate> = candidates
577        .iter()
578        .filter(|c| c.display_name.trim().eq_ignore_ascii_case(name.trim()))
579        .collect();
580    if exact.len() == 1 {
581        return Ok(exact[0].id.clone());
582    }
583
584    if exact.is_empty() {
585        let top = &candidates[0];
586        let second = &candidates[1];
587        // Both scores must be present (> 0.0): a runner-up with an absent
588        // `relevance_score` (defaulted to 0.0) would otherwise make
589        // `top >= RATIO * 0.0` trivially true and silently auto-select the
590        // top hit, defeating the ambiguity guard. When the runner-up has
591        // no score we cannot judge dominance — treat the name as ambiguous.
592        if top.relevance > 0.0
593            && second.relevance > 0.0
594            && top.relevance >= DOMINANCE_RATIO * second.relevance
595        {
596            return Ok(top.id.clone());
597        }
598    }
599
600    Err(FetchError::Ambiguous {
601        hint: format_ambiguous(label, name, candidates),
602    })
603}
604
605/// Singular human label for an OpenAlex entity path.
606fn entity_label(entity_path: &str) -> &str {
607    match entity_path {
608        "authors" => "author",
609        "sources" => "venue",
610        "publishers" => "publisher",
611        other => other,
612    }
613}
614
615/// Render the ambiguity error: the query plus the candidate listing
616/// (display name, id, works count) so the caller can narrow the name.
617fn format_ambiguous(label: &str, name: &str, candidates: &[Candidate]) -> String {
618    let mut s = format!(
619        "ambiguous {label} '{name}' — {} candidates; narrow the name \
620         (add a first name / fuller title) and retry:",
621        candidates.len()
622    );
623    for c in candidates.iter().take(5) {
624        s.push_str(&format!(
625            "\n  {} ({}, {} works)",
626            c.display_name, c.id, c.works_count
627        ));
628    }
629    s
630}
631
632/// Build the `/works?search=&filter=&sort=&select=&per-page=&mailto=` URL.
633fn build_search_url(
634    base: &Url,
635    contact_email: &str,
636    query: &PaperSearchQuery,
637    ids: &ResolvedIds,
638) -> Result<Url, FetchError> {
639    let mut url = base.join("/works").map_err(|e| FetchError::SourceSchema {
640        hint: format!("openalex search URL construction failed: {e}"),
641    })?;
642
643    let per_page = query.limit.clamp(1, MAX_PER_PAGE);
644
645    // Compose the comma-joined `filter=` value. OpenAlex treats commas as
646    // an AND of clauses within a single `filter` parameter.
647    let mut filters: Vec<String> = Vec::new();
648    // Match on title + abstract only, as a FILTER rather than the loose
649    // `search=` parameter (#290): `search=` includes full-text, which lets
650    // off-topic full-text hits in; `title_and_abstract.search` is the
651    // precision form. A comma in the query would split the comma-joined
652    // filter list, so commas are normalised to spaces (they carry no search
653    // meaning here).
654    filters.push(format!(
655        "title_and_abstract.search:{}",
656        query.query.replace(',', " ")
657    ));
658    if let Some(from) = query.from_year {
659        filters.push(format!("from_publication_date:{from}-01-01"));
660    }
661    if let Some(to) = query.to_year {
662        filters.push(format!("to_publication_date:{to}-12-31"));
663    }
664    if query.oa_only {
665        filters.push("is_oa:true".to_string());
666    }
667    if let Some(min) = query.min_citations {
668        // `cited_by_count:>{n}` matches works cited strictly more than
669        // `n` times. The off-by-one versus "at least n" is documented on
670        // the CLI flag.
671        filters.push(format!("cited_by_count:>{min}"));
672    }
673    if let Some(f) = query.min_fwci {
674        // Field-and-year-normalized impact floor (#290): narrows the set
675        // without overriding relevance, unlike a `sort=fwci`.
676        filters.push(format!("fwci:>{f}"));
677    }
678    if let Some(p) = query.min_percentile {
679        // Top-X% within the same-year cohort (#290).
680        filters.push(format!("cited_by_percentile_year.min:{p}"));
681    }
682    if let Some(author_id) = &ids.author {
683        filters.push(format!("authorships.author.id:{author_id}"));
684    }
685    if let Some(source_id) = &ids.source {
686        filters.push(format!("primary_location.source.id:{source_id}"));
687    }
688    if let Some(publisher_id) = &ids.publisher {
689        filters.push(format!(
690            "primary_location.source.publisher_lineage:{publisher_id}"
691        ));
692    }
693
694    {
695        let mut qp = url.query_pairs_mut();
696        // The query is now a `title_and_abstract.search` FILTER clause
697        // (above), not the `search=` parameter (#290).
698        qp.append_pair("per-page", &per_page.to_string());
699        qp.append_pair("sort", query.sort.as_openalex());
700        qp.append_pair("select", SELECT_FIELDS);
701        // `filters` always carries at least the title_and_abstract.search
702        // clause, so it is never empty here.
703        qp.append_pair("filter", &filters.join(","));
704        if !contact_email.is_empty() {
705            qp.append_pair("mailto", contact_email);
706        }
707    }
708
709    Ok(url)
710}
711
712/// Map one OpenAlex Work JSON object to a [`PaperHit`].
713///
714/// Tolerant of missing fields: anything absent becomes `None` / empty
715/// rather than failing the whole search (one malformed record should not
716/// sink the page).
717fn work_to_hit(work: &serde_json::Value) -> PaperHit {
718    let openalex_id = work
719        .get("id")
720        .and_then(serde_json::Value::as_str)
721        .map(strip_openalex_prefix)
722        .unwrap_or_default();
723
724    let doi = work
725        .get("doi")
726        .and_then(serde_json::Value::as_str)
727        .map(strip_doi_prefix);
728
729    let title = work
730        .get("title")
731        .and_then(serde_json::Value::as_str)
732        .or_else(|| work.get("display_name").and_then(serde_json::Value::as_str))
733        .unwrap_or("")
734        .to_string();
735
736    let authors = work
737        .get("authorships")
738        .and_then(serde_json::Value::as_array)
739        .map(|arr| {
740            arr.iter()
741                .filter_map(|a| {
742                    a.get("author")
743                        .and_then(|au| au.get("display_name"))
744                        .and_then(serde_json::Value::as_str)
745                        .map(str::to_string)
746                })
747                .collect()
748        })
749        .unwrap_or_default();
750
751    let year = work
752        .get("publication_year")
753        .and_then(serde_json::Value::as_i64)
754        .and_then(|y| i32::try_from(y).ok());
755
756    let venue = work
757        .get("primary_location")
758        .and_then(|loc| loc.get("source"))
759        .and_then(|src| src.get("display_name"))
760        .and_then(serde_json::Value::as_str)
761        .map(str::to_string);
762
763    let abstract_ = work
764        .get("abstract_inverted_index")
765        .and_then(reconstruct_abstract);
766
767    let cited_by_count = work
768        .get("cited_by_count")
769        .and_then(serde_json::Value::as_u64)
770        .unwrap_or(0);
771
772    let oa_status = work
773        .get("open_access")
774        .and_then(|oa| oa.get("oa_status"))
775        .and_then(serde_json::Value::as_str)
776        .map(str::to_string);
777
778    let arxiv = work
779        .get("locations")
780        .and_then(serde_json::Value::as_array)
781        .and_then(|locs| locs.iter().find_map(extract_arxiv_from_location));
782
783    let fwci = work.get("fwci").and_then(serde_json::Value::as_f64);
784
785    let cited_by_percentile_year_min = work
786        .get("cited_by_percentile_year")
787        .and_then(|p| p.get("min"))
788        .and_then(serde_json::Value::as_u64)
789        .and_then(|v| u8::try_from(v).ok());
790
791    PaperHit {
792        doi,
793        openalex_id,
794        arxiv,
795        title,
796        authors,
797        year,
798        venue,
799        abstract_,
800        cited_by_count,
801        oa_status,
802        fwci,
803        cited_by_percentile_year_min,
804        source: DiscoverySource::OpenAlex,
805    }
806}
807
808/// Reconstruct plain abstract text from OpenAlex's
809/// `abstract_inverted_index` (`{ word: [positions...] }`). Returns `None`
810/// for a null / empty / non-object value.
811fn reconstruct_abstract(inv: &serde_json::Value) -> Option<String> {
812    let map = inv.as_object()?;
813    if map.is_empty() {
814        return None;
815    }
816    let mut positioned: Vec<(u64, &str)> = Vec::new();
817    for (word, positions) in map {
818        if let Some(arr) = positions.as_array() {
819            for p in arr {
820                if let Some(pos) = p.as_u64() {
821                    positioned.push((pos, word.as_str()));
822                }
823            }
824        }
825    }
826    if positioned.is_empty() {
827        return None;
828    }
829    positioned.sort_by_key(|(pos, _)| *pos);
830    let words: Vec<&str> = positioned.into_iter().map(|(_, w)| w).collect();
831    Some(words.join(" "))
832}
833
834/// Best-effort arXiv id extraction from a single OpenAlex location's
835/// `landing_page_url` / `pdf_url`. Looks for `arxiv.org/abs/<id>` and returns
836/// the validated `<id>`. Old-style (pre-2007) ids embed a `/`
837/// (`archive/number`, e.g. `cond-mat/0701105`), so the capture must NOT stop
838/// at `/`; a trailing `vN` version is kept. The capture is validated via
839/// [`ArxivId::parse`], so a malformed URL yields `None` rather than a
840/// truncated / garbage id. (#371)
841fn extract_arxiv_from_location(loc: &serde_json::Value) -> Option<String> {
842    for key in ["landing_page_url", "pdf_url"] {
843        if let Some(u) = loc.get(key).and_then(serde_json::Value::as_str) {
844            if let Some(idx) = u.find("arxiv.org/abs/") {
845                let after = &u[idx + "arxiv.org/abs/".len()..];
846                // Stop at a query / fragment / whitespace — but NOT at '/',
847                // which separates `archive` from `number` in old-style ids.
848                let raw: String = after
849                    .chars()
850                    .take_while(|c| !matches!(c, '?' | '#' | ' ' | '\t' | '\n' | '\r'))
851                    .collect();
852                if let Ok(id) = crate::ArxivId::parse(raw.trim_end_matches('/')) {
853                    return Some(id.as_str().to_string());
854                }
855            }
856        }
857    }
858    None
859}
860
861/// Strip the `https://openalex.org/` prefix from an entity id, yielding
862/// the bare `W…` / `A…` / `S…` / `P…` form.
863fn strip_openalex_prefix(id: &str) -> String {
864    id.rsplit('/').next().unwrap_or(id).to_string()
865}
866
867/// Strip the `https://doi.org/` (or `http://…`) prefix from a DOI URL and
868/// lower-case it (DOIs are case-insensitive; lower-case is the canonical
869/// store form).
870fn strip_doi_prefix(doi_url: &str) -> String {
871    let lower = doi_url.to_ascii_lowercase();
872    lower
873        .strip_prefix("https://doi.org/")
874        .or_else(|| lower.strip_prefix("http://doi.org/"))
875        .unwrap_or(&lower)
876        .to_string()
877}
878
879/// Truncate a response body to a short prefix for error hints, so a
880/// multi-KB malformed payload does not flood a single log line.
881///
882/// Truncation is by `char` (not byte) so a multi-byte UTF-8 character
883/// straddling the cap — common in OpenAlex error payloads, which embed
884/// `…`/curly quotes — never panics on a non-char-boundary byte slice.
885fn truncate_for_hint(body: &[u8]) -> String {
886    const MAX: usize = 200;
887    let s = String::from_utf8_lossy(body);
888    if s.chars().count() <= MAX {
889        s.into_owned()
890    } else {
891        let head: String = s.chars().take(MAX).collect();
892        format!("{head}…")
893    }
894}
895
896/// Build the `SourceSchema` error for an OpenAlex response that is valid
897/// JSON but carries no `results` array (e.g. an error envelope). Shared by
898/// the `/works` search path and the entity-resolution path so the
899/// "do NOT collapse to an empty Vec" contract lives in one place.
900/// `context` names the endpoint for the hint (`"search"` or `"/authors"`).
901fn missing_results_array(context: &str, value: &serde_json::Value) -> FetchError {
902    FetchError::SourceSchema {
903        hint: format!(
904            "openalex {context} response missing `results` array — likely an \
905             error payload (got: {})",
906            truncate_for_hint(value.to_string().as_bytes())
907        ),
908    }
909}
910
911// ---------------------------------------------------------------------------
912// DOI ↔ arXiv linking (#281 item 5)
913// ---------------------------------------------------------------------------
914
915/// The cross-identifier "identity cluster" for a single work: its DOI, its
916/// arXiv preprint id (when one exists), the OpenAlex Work id, and the
917/// title.
918///
919/// This is the primitive behind #281 item 5 (arXiv ↔ published-DOI
920/// linking & dedup): given a published DOI, an agent can discover whether a
921/// free arXiv preprint of the **same work** exists (to read its full text,
922/// or to avoid fetching the preprint and the journal version twice).
923#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
924pub struct PaperLinks {
925    /// Bare DOI (lower-cased), or `None` if OpenAlex has none for the work.
926    pub doi: Option<String>,
927    /// arXiv id of the preprint of this work, or `None` when no arXiv
928    /// location is recorded. A trailing version (`v2`) is kept.
929    pub arxiv: Option<String>,
930    /// OpenAlex Work id (`W…`).
931    pub openalex_id: String,
932    /// Work title.
933    pub title: String,
934}
935
936/// Resolve the [`PaperLinks`] identity cluster for a **DOI** via OpenAlex
937/// (`/works?filter=doi:<doi>`), in particular whether the work has an arXiv
938/// preprint.
939///
940/// `base` / `contact_email` / `ctx` are used exactly as in
941/// [`paper_search`] (Tier-1 OA metadata, always-on; a single bounded
942/// `/works` query; `HttpClient::fetch_bytes`, never a PDF). The arXiv id
943/// is extracted from the work's `locations[]` / `primary_location` /
944/// `best_oa_location` URLs (`arxiv.org/abs/<id>`), reusing the same logic
945/// as discovery search.
946///
947/// # Errors
948///
949/// [`FetchError::NotFound`] when no OpenAlex work matches the DOI,
950/// [`FetchError::SourceSchema`] when the response is not a JSON object with
951/// a `results` array — or when the matched work carries no `id`,
952/// [`FetchError::Http`] for transport failures, and propagates a
953/// provenance-log append failure (fail-closed).
954pub async fn resolve_links_for_doi(
955    base: &Url,
956    contact_email: &str,
957    doi: &str,
958    ctx: &FetchContext,
959) -> Result<PaperLinks, FetchError> {
960    let url = build_doi_lookup_url(base, contact_email, doi)?;
961    let (value, _bytes) = openalex_get(&url, ctx).await?;
962
963    let results = value
964        .get("results")
965        .and_then(serde_json::Value::as_array)
966        .ok_or_else(|| missing_results_array("doi-lookup", &value))?;
967
968    let work = results.first().ok_or_else(|| FetchError::NotFound {
969        hint: format!("no OpenAlex work matched doi '{doi}'"),
970    })?;
971
972    let links = work_to_links(work);
973    // A matched work always carries an `id`; an empty one means the record
974    // was malformed. Surface it as a schema error rather than returning a
975    // cluster with a blank `openalex_id` (review #287).
976    if links.openalex_id.is_empty() {
977        return Err(FetchError::SourceSchema {
978            hint: format!("openalex work for doi '{doi}' has no id"),
979        });
980    }
981    Ok(links)
982}
983
984/// Build the `/works?filter=doi:<doi>&select=&per-page=1&mailto=` URL for
985/// the single-work DOI lookup. The `filter` value is URL-encoded by
986/// `query_pairs_mut`, so a DOI's `/` and `:` are carried safely (unlike a
987/// `/works/doi:<doi>` path form, where the suffix `/` would split the
988/// path).
989fn build_doi_lookup_url(base: &Url, contact_email: &str, doi: &str) -> Result<Url, FetchError> {
990    let mut url = base.join("/works").map_err(|e| FetchError::SourceSchema {
991        hint: format!("openalex doi-lookup URL construction failed: {e}"),
992    })?;
993    {
994        let mut qp = url.query_pairs_mut();
995        qp.append_pair("filter", &format!("doi:{doi}"));
996        qp.append_pair("per-page", "1");
997        qp.append_pair(
998            "select",
999            "id,doi,title,display_name,locations,primary_location,best_oa_location",
1000        );
1001        if !contact_email.is_empty() {
1002            qp.append_pair("mailto", contact_email);
1003        }
1004    }
1005    Ok(url)
1006}
1007
1008/// Map one OpenAlex Work JSON object to a [`PaperLinks`]. Scans
1009/// `locations[]`, then `primary_location` / `best_oa_location`, for an
1010/// arXiv URL.
1011fn work_to_links(work: &serde_json::Value) -> PaperLinks {
1012    let openalex_id = work
1013        .get("id")
1014        .and_then(serde_json::Value::as_str)
1015        .map(strip_openalex_prefix)
1016        .unwrap_or_default();
1017
1018    let doi = work
1019        .get("doi")
1020        .and_then(serde_json::Value::as_str)
1021        .map(strip_doi_prefix);
1022
1023    let title = work
1024        .get("title")
1025        .and_then(serde_json::Value::as_str)
1026        .or_else(|| work.get("display_name").and_then(serde_json::Value::as_str))
1027        .unwrap_or("")
1028        .to_string();
1029
1030    let arxiv = work
1031        .get("locations")
1032        .and_then(serde_json::Value::as_array)
1033        .and_then(|locs| locs.iter().find_map(extract_arxiv_from_location))
1034        .or_else(|| {
1035            work.get("primary_location")
1036                .and_then(extract_arxiv_from_location)
1037        })
1038        .or_else(|| {
1039            work.get("best_oa_location")
1040                .and_then(extract_arxiv_from_location)
1041        });
1042
1043    PaperLinks {
1044        doi,
1045        arxiv,
1046        openalex_id,
1047        title,
1048    }
1049}
1050
1051// ---------------------------------------------------------------------------
1052// Frontier view (#295)
1053// ---------------------------------------------------------------------------
1054
1055/// Parameters for `frontier_view`: the gap-spotting view that surfaces
1056/// candidate papers structurally connected to a seed but not yet noticed.
1057///
1058/// Phase 1 (this PR): finds papers that **cite the seed** sorted by
1059/// age-normalized impact (`fwci` desc). Phase 2 will add bibliographic-
1060/// coupling candidates (papers sharing references with the seed).
1061#[derive(Debug, Clone)]
1062pub struct FrontierQuery {
1063    /// The anchor paper whose citing neighbourhood forms the candidate set.
1064    pub seed_doi: crate::Doi,
1065    /// Maximum results returned (clamped to `1..=MAX_PER_PAGE`).
1066    /// Defaults to [`DEFAULT_LIMIT`].
1067    pub limit: usize,
1068    /// When set, only include works published on or after this year.
1069    pub min_year: Option<i32>,
1070}
1071
1072impl FrontierQuery {
1073    /// Construct a new query for the given seed DOI with default limits.
1074    pub fn new(seed_doi: crate::Doi) -> Self {
1075        Self {
1076            seed_doi,
1077            limit: DEFAULT_LIMIT,
1078            min_year: None,
1079        }
1080    }
1081}
1082
1083/// Results of a `frontier_view` query.
1084#[derive(Debug, Clone, Serialize)]
1085pub struct FrontierResults {
1086    /// Ranked candidate papers (length ≤ `query.limit`).
1087    ///
1088    /// Sorted by `fwci` descending (nulls last), then by `year` descending,
1089    /// then by `cited_by_count` descending for stable ordering.
1090    pub hits: Vec<PaperHit>,
1091    /// OpenAlex Work ID of the seed paper (`W…` prefix stripped).
1092    pub seed_openalex_id: String,
1093    /// Title of the seed paper, when available from OpenAlex.
1094    pub seed_title: Option<String>,
1095    /// Total number of citing works in OpenAlex (usually larger than
1096    /// `hits.len()`). `None` if the API response omitted the count.
1097    pub total_citing: Option<u64>,
1098}
1099
1100/// Surface the frontier neighbourhood of `seed_doi`: papers that cite the
1101/// seed, ranked by age-normalized impact, ready for the agent to triage.
1102///
1103/// ## Algorithm (Phase 1)
1104///
1105/// 1. Resolve the seed DOI to an OpenAlex Work ID via a direct DOI lookup
1106///    (`/works/https://doi.org/<doi>`).
1107/// 2. Query `/works?filter=cites:<seed_id>` to fetch citing papers.
1108///    Unlike free-text search, `filter=cites:` constrains the result set to
1109///    papers that actually build on the seed, so `sort=fwci:desc` is safe
1110///    here (no off-topic papers; the topicality concern from #290 does not
1111///    apply). Phase 2 will add bibliographic-coupling candidates.
1112/// 3. Sort locally by `fwci` desc (nulls last) → `year` desc → `cited_by_count` desc.
1113/// 4. Apply `min_year` filter if provided.
1114///
1115/// Returns `FrontierResults` containing the ranked hits. The caller is
1116/// responsible for excluding papers already in the local store (the core
1117/// function is store-agnostic).
1118pub async fn frontier_view(
1119    query: &FrontierQuery,
1120    base: &Url,
1121    contact_email: &str,
1122    ctx: &FetchContext,
1123) -> Result<FrontierResults, FetchError> {
1124    let limit = query.limit.clamp(1, MAX_PER_PAGE);
1125
1126    // Step 1: resolve seed DOI to OpenAlex Work ID via filter=doi: (the
1127    // query-param form handles the DOI's `/` and `:` without manual
1128    // encoding — same pattern as `build_doi_lookup_url`).
1129    let seed_url = {
1130        let mut u = base.join("/works").map_err(|e| FetchError::SourceSchema {
1131            hint: format!("frontier seed URL construction failed: {e}"),
1132        })?;
1133        {
1134            let mut qp = u.query_pairs_mut();
1135            qp.append_pair("filter", &format!("doi:{}", query.seed_doi.as_str()));
1136            qp.append_pair("per-page", "1");
1137            qp.append_pair("select", "id,title,display_name");
1138            if !contact_email.is_empty() {
1139                qp.append_pair("mailto", contact_email);
1140            }
1141        }
1142        u
1143    };
1144    let (seed_resp, _) = openalex_get(&seed_url, ctx).await?;
1145    let seed_results = seed_resp
1146        .get("results")
1147        .and_then(serde_json::Value::as_array)
1148        .ok_or_else(|| missing_results_array("frontier/seed", &seed_resp))?;
1149    let seed_work = seed_results.first().ok_or_else(|| FetchError::NotFound {
1150        hint: format!(
1151            "no OpenAlex work matched seed doi '{}'",
1152            query.seed_doi.as_str()
1153        ),
1154    })?;
1155    let seed_openalex_id = seed_work
1156        .get("id")
1157        .and_then(serde_json::Value::as_str)
1158        .map(strip_openalex_prefix)
1159        .ok_or_else(|| FetchError::SourceSchema {
1160            hint: format!(
1161                "seed OpenAlex record for '{}' has no id",
1162                query.seed_doi.as_str()
1163            ),
1164        })?
1165        .to_string();
1166    let seed_title = seed_work
1167        .get("title")
1168        .and_then(serde_json::Value::as_str)
1169        .or_else(|| {
1170            seed_work
1171                .get("display_name")
1172                .and_then(serde_json::Value::as_str)
1173        })
1174        .map(str::to_string);
1175
1176    // Step 2: fetch citing papers with fwci sort.
1177    let mut citing_url = base.clone();
1178    citing_url.set_path("/works");
1179    {
1180        let mut pairs = citing_url.query_pairs_mut();
1181        pairs.append_pair("filter", &format!("cites:{seed_openalex_id}"));
1182        pairs.append_pair("select", SELECT_FIELDS);
1183        pairs.append_pair("sort", "fwci:desc");
1184        pairs.append_pair("per-page", &limit.to_string());
1185        if !contact_email.is_empty() {
1186            pairs.append_pair("mailto", contact_email);
1187        }
1188    }
1189    let (citing_resp, _) = openalex_get(&citing_url, ctx).await?;
1190    let total_citing = citing_resp
1191        .get("meta")
1192        .and_then(|m| m.get("count"))
1193        .and_then(serde_json::Value::as_u64);
1194    let results_arr = citing_resp
1195        .get("results")
1196        .and_then(serde_json::Value::as_array)
1197        .ok_or_else(|| missing_results_array("frontier/cites", &citing_resp))?;
1198
1199    // Step 3: parse, apply min_year filter, and sort.
1200    let mut hits: Vec<PaperHit> = results_arr
1201        .iter()
1202        .map(work_to_hit)
1203        .filter(|h| {
1204            query
1205                .min_year
1206                .is_none_or(|y| h.year.is_some_and(|hy| hy >= y))
1207        })
1208        .collect();
1209
1210    hits.sort_by(|a, b| {
1211        let fwci_ord = match (a.fwci, b.fwci) {
1212            (Some(fa), Some(fb)) => fb.partial_cmp(&fa).unwrap_or(std::cmp::Ordering::Equal),
1213            (Some(_), None) => std::cmp::Ordering::Less,
1214            (None, Some(_)) => std::cmp::Ordering::Greater,
1215            (None, None) => std::cmp::Ordering::Equal,
1216        };
1217        fwci_ord
1218            .then_with(|| b.year.cmp(&a.year))
1219            .then_with(|| b.cited_by_count.cmp(&a.cited_by_count))
1220    });
1221
1222    Ok(FrontierResults {
1223        hits,
1224        seed_openalex_id,
1225        seed_title,
1226        total_citing,
1227    })
1228}
1229
1230// ---------------------------------------------------------------------------
1231// Tests
1232// ---------------------------------------------------------------------------
1233
1234/// Advice to attach to a paper search that matched nothing (#534).
1235///
1236/// OpenAlex free-text matching degrades sharply as a query lengthens: past
1237/// roughly eight terms it returns nothing at all rather than a partial match.
1238/// A human reading `0 results` shortens the query and tries again. An agent
1239/// reading `ok: true` with an empty array reads it as a fact about the world
1240/// and stops -- in the session that produced #534, eleven consecutive searches
1241/// returned zero for papers a three-to-five term query then found immediately,
1242/// and a known study was written off as unavailable.
1243///
1244/// Returns `None` for short queries: a zero-result two-term search really may
1245/// mean the work is not indexed, and a hint on every empty result would train
1246/// readers to skip it.
1247///
1248/// Lives here rather than on either front end because both surfaces call
1249/// [`paper_search`] and both can return the empty envelope. The first fix
1250/// landed only on the MCP tool, so `doiget search --mode json` went on
1251/// emitting the exact envelope #534 was filed about.
1252#[must_use]
1253pub fn zero_result_hint(query: &str) -> Option<String> {
1254    // Where OpenAlex free-text matching starts failing outright. Not a hard
1255    // boundary -- a bound observed from the queries in #534, which is why the
1256    // wording says "roughly".
1257    const DEGRADES_PAST: usize = 8;
1258
1259    let terms = query.split_whitespace().count();
1260    if terms <= DEGRADES_PAST {
1261        return None;
1262    }
1263    Some(format!(
1264        "This query has {terms} terms. OpenAlex free-text matching degrades sharply past roughly {DEGRADES_PAST} and returns nothing rather than a partial match, so zero results here is more likely to be about the query than about the literature. Retry with 3-5 distinctive terms - an author surname, a coined phrase, the distinguishing noun - before concluding the work is not indexed."
1265    ))
1266}
1267
1268#[cfg(test)]
1269#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1270mod tests {
1271    /// #534: a long query matching nothing is far more likely to be a
1272    /// query-length problem than an absent literature, and the agent reading
1273    /// the envelope is the one who cannot tell the difference.
1274    #[test]
1275    fn a_long_zero_result_query_is_told_why_it_may_be_zero() {
1276        let q = "lithium refractoriness after discontinuation kindling sensitization course of illness Post";
1277        let hint = zero_result_hint(q).expect("10 terms is past the threshold");
1278        assert!(hint.contains("10 terms"), "names the count: {hint}");
1279        assert!(
1280            hint.contains("3-5"),
1281            "says what to do instead, not only what went wrong: {hint}"
1282        );
1283    }
1284
1285    /// A short query returning nothing may genuinely mean nothing is indexed.
1286    /// Hinting on every empty result would teach readers to skip the hint.
1287    #[test]
1288    fn a_short_zero_result_query_is_left_alone() {
1289        assert!(zero_result_hint("depersonalization derealization").is_none());
1290        assert!(zero_result_hint("").is_none());
1291    }
1292
1293    /// The threshold counts terms, not characters: one very long term is still
1294    /// one term, and "shorten it" is not the advice to give.
1295    #[test]
1296    fn the_threshold_counts_terms_not_length() {
1297        assert!(zero_result_hint(&"a".repeat(400)).is_none());
1298        assert!(zero_result_hint("a b c d e f g h i").is_some());
1299    }
1300
1301    use super::*;
1302
1303    use std::sync::Arc;
1304
1305    use camino::Utf8PathBuf;
1306    use tempfile::TempDir;
1307    use wiremock::matchers::{method, path, query_param};
1308    use wiremock::{Mock, MockServer, ResponseTemplate};
1309
1310    use crate::http::HttpClient;
1311    use crate::provenance::ProvenanceLog;
1312    use crate::rate_limiter::RateLimiter;
1313    use crate::RateLimits;
1314
1315    /// Hand-crafted (not a snapshot) OpenAlex `/works` search response.
1316    /// Synthetic to avoid third-party redistribution concerns; exercises
1317    /// every `PaperHit` field including abstract reconstruction, arXiv
1318    /// extraction, and the all-absent record.
1319    const SAMPLE_SEARCH: &str = r#"{
1320        "meta": { "count": 4012, "per_page": 25 },
1321        "results": [
1322            {
1323                "id": "https://openalex.org/W123",
1324                "doi": "https://doi.org/10.1234/Example",
1325                "title": "Tropical Tensor Networks",
1326                "display_name": "Tropical Tensor Networks",
1327                "publication_year": 2021,
1328                "cited_by_count": 42,
1329                "abstract_inverted_index": { "Tropical": [0], "tensor": [1], "networks": [2] },
1330                "authorships": [
1331                    { "author": { "display_name": "Ada Lovelace" } },
1332                    { "author": { "display_name": "Alan Turing" } }
1333                ],
1334                "primary_location": { "source": { "display_name": "Phys. Rev. B" } },
1335                "open_access": { "oa_status": "green", "is_oa": true },
1336                "locations": [
1337                    { "landing_page_url": "https://arxiv.org/abs/2101.12345v2" }
1338                ]
1339            },
1340            {
1341                "id": "https://openalex.org/W456",
1342                "doi": null,
1343                "title": "Second Paper",
1344                "publication_year": 2019,
1345                "cited_by_count": 7,
1346                "abstract_inverted_index": null,
1347                "authorships": [],
1348                "open_access": { "oa_status": "closed" }
1349            }
1350        ]
1351    }"#;
1352
1353    fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
1354        let td = TempDir::new().expect("tempdir");
1355        let log_dir =
1356            Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
1357        let log_path = log_dir.join("test.jsonl");
1358
1359        let http = Arc::new(HttpClient::new_for_tests_allow_http(
1360            "openalex",
1361            wiremock_host,
1362        ));
1363        let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
1364        let session_id = "01J0000000000000000000TEST".to_string();
1365        let log = Arc::new(
1366            ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
1367        );
1368        let ctx = FetchContext {
1369            http,
1370            rate_limiter,
1371            log,
1372            session_id,
1373            cache_root: None,
1374        };
1375        (td, ctx)
1376    }
1377
1378    #[tokio::test]
1379    async fn search_maps_works_to_hits() {
1380        let server = MockServer::start().await;
1381        Mock::given(method("GET"))
1382            .and(path("/works"))
1383            // #290: the query is a `title_and_abstract.search` filter clause.
1384            .and(query_param(
1385                "filter",
1386                "title_and_abstract.search:tropical tensor networks",
1387            ))
1388            .and(query_param("mailto", "doiget@localhost"))
1389            .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_SEARCH))
1390            .mount(&server)
1391            .await;
1392
1393        let (_td, ctx) = build_test_context(&server.uri());
1394        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1395        let q = PaperSearchQuery::new("tropical tensor networks");
1396
1397        let out = paper_search(&base, "doiget@localhost", &q, &ctx)
1398            .await
1399            .expect("search ok");
1400
1401        assert_eq!(out.total_results, Some(4012));
1402        assert_eq!(out.results.len(), 2);
1403
1404        let first = &out.results[0];
1405        assert_eq!(first.openalex_id, "W123");
1406        assert_eq!(first.doi.as_deref(), Some("10.1234/example")); // lower-cased
1407        assert_eq!(first.title, "Tropical Tensor Networks");
1408        assert_eq!(first.year, Some(2021));
1409        assert_eq!(first.cited_by_count, 42);
1410        assert_eq!(first.abstract_.as_deref(), Some("Tropical tensor networks"));
1411        assert_eq!(first.authors, vec!["Ada Lovelace", "Alan Turing"]);
1412        assert_eq!(first.venue.as_deref(), Some("Phys. Rev. B"));
1413        assert_eq!(first.oa_status.as_deref(), Some("green"));
1414        assert_eq!(first.arxiv.as_deref(), Some("2101.12345v2"));
1415        assert_eq!(first.source, DiscoverySource::OpenAlex);
1416
1417        let second = &out.results[1];
1418        assert_eq!(second.openalex_id, "W456");
1419        assert_eq!(second.doi, None);
1420        assert_eq!(second.abstract_, None);
1421        assert_eq!(second.venue, None);
1422        assert!(second.authors.is_empty());
1423        assert_eq!(second.oa_status.as_deref(), Some("closed"));
1424        assert_eq!(second.arxiv, None);
1425    }
1426
1427    #[tokio::test]
1428    async fn search_filters_and_sort_land_on_the_url() {
1429        let server = MockServer::start().await;
1430        // Assert the composed filter + sort params reach the wire.
1431        Mock::given(method("GET"))
1432            .and(path("/works"))
1433            // #290: relevance is the only sort; the query is the leading
1434            // `title_and_abstract.search` filter clause.
1435            .and(query_param("sort", "relevance_score:desc"))
1436            .and(query_param(
1437                "filter",
1438                "title_and_abstract.search:spin glass,from_publication_date:2020-01-01,is_oa:true,cited_by_count:>10",
1439            ))
1440            .and(query_param("per-page", "5"))
1441            .respond_with(
1442                ResponseTemplate::new(200)
1443                    .set_body_string(r#"{ "meta": { "count": 0 }, "results": [] }"#),
1444            )
1445            .mount(&server)
1446            .await;
1447
1448        let (_td, ctx) = build_test_context(&server.uri());
1449        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1450        let q = PaperSearchQuery {
1451            query: "spin glass".to_string(),
1452            limit: 5,
1453            from_year: Some(2020),
1454            to_year: None,
1455            oa_only: true,
1456            min_citations: Some(10),
1457            min_fwci: None,
1458            min_percentile: None,
1459            author: None,
1460            venue: None,
1461            publisher: None,
1462            sort: SearchSort::Relevance,
1463        };
1464
1465        let out = paper_search(&base, "doiget@localhost", &q, &ctx)
1466            .await
1467            .expect("search ok");
1468        assert_eq!(out.total_results, Some(0));
1469        assert!(out.results.is_empty());
1470    }
1471
1472    #[tokio::test]
1473    async fn search_error_payload_is_source_schema() {
1474        let server = MockServer::start().await;
1475        Mock::given(method("GET"))
1476            .and(path("/works"))
1477            .respond_with(
1478                ResponseTemplate::new(200)
1479                    .set_body_string(r#"{"error":"Invalid query parameters"}"#),
1480            )
1481            .mount(&server)
1482            .await;
1483
1484        let (_td, ctx) = build_test_context(&server.uri());
1485        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1486        let q = PaperSearchQuery::new("anything");
1487
1488        let err = paper_search(&base, "", &q, &ctx)
1489            .await
1490            .expect_err("missing `results` must surface as SourceSchema");
1491        assert!(matches!(err, FetchError::SourceSchema { .. }));
1492    }
1493
1494    #[test]
1495    fn name_filters_compose_into_resolved_ids() {
1496        let base = Url::parse("https://api.openalex.org").expect("base parses");
1497        let q = PaperSearchQuery::new("topic");
1498        let ids = ResolvedIds {
1499            author: Some("A1".to_string()),
1500            source: Some("S2".to_string()),
1501            publisher: Some("P3".to_string()),
1502        };
1503        let url = build_search_url(&base, "", &q, &ids).expect("url builds");
1504        let filter = url
1505            .query_pairs()
1506            .find(|(k, _)| k == "filter")
1507            .map(|(_, v)| v.into_owned())
1508            .expect("filter param present");
1509        assert!(filter.contains("authorships.author.id:A1"), "got {filter}");
1510        assert!(
1511            filter.contains("primary_location.source.id:S2"),
1512            "got {filter}"
1513        );
1514        assert!(
1515            filter.contains("primary_location.source.publisher_lineage:P3"),
1516            "got {filter}"
1517        );
1518        // An empty contact email must omit the `mailto` parameter entirely
1519        // (never send a placeholder).
1520        assert!(
1521            param(&url, "mailto").is_none(),
1522            "empty contact email must omit mailto"
1523        );
1524    }
1525
1526    #[tokio::test]
1527    async fn venue_name_resolves_to_source_id_then_filters_works() {
1528        let server = MockServer::start().await;
1529        // First leg: /sources?search=... → top hit S99.
1530        Mock::given(method("GET"))
1531            .and(path("/sources"))
1532            .and(query_param("search", "Physical Review B"))
1533            .respond_with(ResponseTemplate::new(200).set_body_string(
1534                r#"{ "results": [ { "id": "https://openalex.org/S99", "display_name": "Physical Review B" } ] }"#,
1535            ))
1536            .mount(&server)
1537            .await;
1538        // Second leg: /works filtered by the resolved source id.
1539        Mock::given(method("GET"))
1540            .and(path("/works"))
1541            .and(query_param(
1542                "filter",
1543                "title_and_abstract.search:spin glass,primary_location.source.id:S99",
1544            ))
1545            .respond_with(ResponseTemplate::new(200).set_body_string(
1546                r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W1", "title": "In PRB" } ] }"#,
1547            ))
1548            .mount(&server)
1549            .await;
1550
1551        let (_td, ctx) = build_test_context(&server.uri());
1552        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1553        let mut q = PaperSearchQuery::new("spin glass");
1554        q.venue = Some("Physical Review B".to_string());
1555
1556        let out = paper_search(&base, "", &q, &ctx)
1557            .await
1558            .expect("venue-filtered search ok");
1559        assert_eq!(out.total_results, Some(1));
1560        assert_eq!(out.results.len(), 1);
1561        assert_eq!(out.results[0].openalex_id, "W1");
1562    }
1563
1564    #[tokio::test]
1565    async fn unresolvable_venue_name_is_not_found() {
1566        let server = MockServer::start().await;
1567        Mock::given(method("GET"))
1568            .and(path("/sources"))
1569            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{ "results": [] }"#))
1570            .mount(&server)
1571            .await;
1572
1573        let (_td, ctx) = build_test_context(&server.uri());
1574        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1575        let mut q = PaperSearchQuery::new("spin glass");
1576        q.venue = Some("No Such Journal".to_string());
1577
1578        let err = paper_search(&base, "", &q, &ctx)
1579            .await
1580            .expect_err("an unresolvable venue name must error, not silently drop the filter");
1581        assert!(matches!(err, FetchError::NotFound { .. }), "got {err:?}");
1582    }
1583
1584    #[tokio::test]
1585    async fn entity_error_envelope_is_source_schema_not_not_found() {
1586        let server = MockServer::start().await;
1587        // A valid JSON object with NO `results` key (an OpenAlex error
1588        // envelope, e.g. a rate limit) must surface as SourceSchema, NOT a
1589        // misleading "no author matched" NotFound that drops the filter.
1590        Mock::given(method("GET"))
1591            .and(path("/authors"))
1592            .respond_with(
1593                ResponseTemplate::new(200).set_body_string(r#"{"error":"rate limit exceeded"}"#),
1594            )
1595            .mount(&server)
1596            .await;
1597
1598        let (_td, ctx) = build_test_context(&server.uri());
1599        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1600        let mut q = PaperSearchQuery::new("x");
1601        q.author = Some("Parisi".to_string());
1602
1603        let err = paper_search(&base, "", &q, &ctx)
1604            .await
1605            .expect_err("an entity error envelope must be SourceSchema, not NotFound");
1606        assert!(
1607            matches!(err, FetchError::SourceSchema { .. }),
1608            "got {err:?}"
1609        );
1610    }
1611
1612    #[tokio::test]
1613    async fn exact_name_match_resolves_amid_namesakes() {
1614        let server = MockServer::start().await;
1615        // Three sources match the search; only one is an exact name match.
1616        Mock::given(method("GET"))
1617            .and(path("/sources"))
1618            .respond_with(ResponseTemplate::new(200).set_body_string(
1619                r#"{ "results": [
1620                    { "id": "https://openalex.org/S1", "display_name": "Physical Review B", "works_count": 50000, "relevance_score": 80.0 },
1621                    { "id": "https://openalex.org/S2", "display_name": "Physical Review B: Condensed Matter", "works_count": 1000, "relevance_score": 78.0 },
1622                    { "id": "https://openalex.org/S3", "display_name": "Reviews of Physics", "works_count": 200, "relevance_score": 70.0 }
1623                ] }"#,
1624            ))
1625            .mount(&server)
1626            .await;
1627        Mock::given(method("GET"))
1628            .and(path("/works"))
1629            .and(query_param(
1630                "filter",
1631                "title_and_abstract.search:spin glass,primary_location.source.id:S1",
1632            ))
1633            .respond_with(ResponseTemplate::new(200).set_body_string(
1634                r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W1", "title": "x" } ] }"#,
1635            ))
1636            .mount(&server)
1637            .await;
1638
1639        let (_td, ctx) = build_test_context(&server.uri());
1640        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1641        let mut q = PaperSearchQuery::new("spin glass");
1642        q.venue = Some("Physical Review B".to_string());
1643
1644        let out = paper_search(&base, "", &q, &ctx)
1645            .await
1646            .expect("exact venue name must resolve to S1 amid namesakes");
1647        assert_eq!(out.results[0].openalex_id, "W1");
1648    }
1649
1650    #[tokio::test]
1651    async fn dominant_top_hit_resolves_for_vague_name() {
1652        let server = MockServer::start().await;
1653        // No exact match for "parisi", but the top hit dominates (>=2x).
1654        Mock::given(method("GET"))
1655            .and(path("/authors"))
1656            .respond_with(ResponseTemplate::new(200).set_body_string(
1657                r#"{ "results": [
1658                    { "id": "https://openalex.org/A1", "display_name": "Giorgio Parisi", "works_count": 400, "relevance_score": 100.0 },
1659                    { "id": "https://openalex.org/A2", "display_name": "M. Parisi", "works_count": 10, "relevance_score": 20.0 }
1660                ] }"#,
1661            ))
1662            .mount(&server)
1663            .await;
1664        Mock::given(method("GET"))
1665            .and(path("/works"))
1666            .and(query_param(
1667                "filter",
1668                "title_and_abstract.search:replica symmetry breaking,authorships.author.id:A1",
1669            ))
1670            .respond_with(ResponseTemplate::new(200).set_body_string(
1671                r#"{ "meta": { "count": 1 }, "results": [ { "id": "https://openalex.org/W9", "title": "y" } ] }"#,
1672            ))
1673            .mount(&server)
1674            .await;
1675
1676        let (_td, ctx) = build_test_context(&server.uri());
1677        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1678        let mut q = PaperSearchQuery::new("replica symmetry breaking");
1679        q.author = Some("parisi".to_string());
1680
1681        let out = paper_search(&base, "", &q, &ctx)
1682            .await
1683            .expect("a dominant top hit must resolve a vague name");
1684        assert_eq!(out.results[0].openalex_id, "W9");
1685    }
1686
1687    #[tokio::test]
1688    async fn ambiguous_name_errors_with_candidate_listing() {
1689        let server = MockServer::start().await;
1690        // Two close, non-exact matches → ambiguous; no /works call.
1691        Mock::given(method("GET"))
1692            .and(path("/authors"))
1693            .respond_with(ResponseTemplate::new(200).set_body_string(
1694                r#"{ "results": [
1695                    { "id": "https://openalex.org/A1", "display_name": "John Smith", "works_count": 300, "relevance_score": 50.0 },
1696                    { "id": "https://openalex.org/A2", "display_name": "Jane Smith", "works_count": 280, "relevance_score": 45.0 }
1697                ] }"#,
1698            ))
1699            .mount(&server)
1700            .await;
1701
1702        let (_td, ctx) = build_test_context(&server.uri());
1703        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1704        let mut q = PaperSearchQuery::new("electrons");
1705        q.author = Some("Smith".to_string());
1706
1707        let err = paper_search(&base, "", &q, &ctx)
1708            .await
1709            .expect_err("a close, non-exact multi-match must be reported as ambiguous");
1710        match err {
1711            FetchError::Ambiguous { hint } => {
1712                assert!(hint.contains("John Smith"), "hint lists candidates: {hint}");
1713                assert!(hint.contains("Jane Smith"), "hint lists candidates: {hint}");
1714            }
1715            other => panic!("expected Ambiguous, got {other:?}"),
1716        }
1717    }
1718
1719    #[test]
1720    fn abstract_reconstruction_orders_by_position() {
1721        let inv = serde_json::json!({
1722            "world": [1],
1723            "hello": [0],
1724            "again": [3],
1725            "hello2": [2]
1726        });
1727        // positions: 0=hello, 1=world, 2=hello2, 3=again
1728        assert_eq!(
1729            reconstruct_abstract(&inv).as_deref(),
1730            Some("hello world hello2 again")
1731        );
1732        assert_eq!(reconstruct_abstract(&serde_json::Value::Null), None);
1733        assert_eq!(reconstruct_abstract(&serde_json::json!({})), None);
1734    }
1735
1736    #[test]
1737    fn doi_and_openalex_prefixes_are_stripped() {
1738        assert_eq!(
1739            strip_doi_prefix("https://doi.org/10.1234/ABC"),
1740            "10.1234/abc"
1741        );
1742        assert_eq!(strip_openalex_prefix("https://openalex.org/W999"), "W999");
1743    }
1744
1745    // ---- resolve_links_for_doi (#281 item 5) -----------------------------
1746
1747    #[tokio::test]
1748    async fn doi_lookup_extracts_arxiv_preprint() {
1749        let server = MockServer::start().await;
1750        Mock::given(method("GET"))
1751            .and(path("/works"))
1752            .and(query_param("filter", "doi:10.1103/physrevb.1"))
1753            .respond_with(ResponseTemplate::new(200).set_body_string(
1754                r#"{ "meta": { "count": 1 }, "results": [ {
1755                    "id": "https://openalex.org/W55",
1756                    "doi": "https://doi.org/10.1103/PhysRevB.1",
1757                    "title": "Published Version",
1758                    "locations": [
1759                        { "landing_page_url": "https://journals.aps.org/prb/abstract/x" },
1760                        { "pdf_url": "https://arxiv.org/abs/2101.54321v2" }
1761                    ]
1762                } ] }"#,
1763            ))
1764            .mount(&server)
1765            .await;
1766
1767        let (_td, ctx) = build_test_context(&server.uri());
1768        let base = Url::parse(&server.uri()).expect("wiremock URI parses");
1769        let links = resolve_links_for_doi(&base, "", "10.1103/physrevb.1", &ctx)
1770            .await
1771            .expect("doi lookup ok");
1772        assert_eq!(links.openalex_id, "W55");
1773        assert_eq!(links.doi.as_deref(), Some("10.1103/physrevb.1")); // lower-cased
1774        assert_eq!(links.arxiv.as_deref(), Some("2101.54321v2"));
1775        assert_eq!(links.title, "Published Version");
1776    }
1777
1778    #[tokio::test]
1779    async fn doi_lookup_without_arxiv_location_is_none() {
1780        let server = MockServer::start().await;
1781        Mock::given(method("GET"))
1782            .and(path("/works"))
1783            .respond_with(ResponseTemplate::new(200).set_body_string(
1784                r#"{ "meta": { "count": 1 }, "results": [ {
1785                    "id": "https://openalex.org/W7",
1786                    "doi": "https://doi.org/10.1234/closed",
1787                    "title": "No Preprint",
1788                    "locations": [ { "landing_page_url": "https://example.com/x" } ]
1789                } ] }"#,
1790            ))
1791            .mount(&server)
1792            .await;
1793
1794        let (_td, ctx) = build_test_context(&server.uri());
1795        let base = Url::parse(&server.uri()).expect("uri");
1796        let links = resolve_links_for_doi(&base, "", "10.1234/closed", &ctx)
1797            .await
1798            .expect("ok");
1799        assert_eq!(links.arxiv, None);
1800        assert_eq!(links.openalex_id, "W7");
1801    }
1802
1803    #[tokio::test]
1804    async fn doi_lookup_unknown_doi_is_not_found() {
1805        let server = MockServer::start().await;
1806        Mock::given(method("GET"))
1807            .and(path("/works"))
1808            .respond_with(
1809                ResponseTemplate::new(200)
1810                    .set_body_string(r#"{ "meta": { "count": 0 }, "results": [] }"#),
1811            )
1812            .mount(&server)
1813            .await;
1814
1815        let (_td, ctx) = build_test_context(&server.uri());
1816        let base = Url::parse(&server.uri()).expect("uri");
1817        let err = resolve_links_for_doi(&base, "", "10.0000/nope", &ctx)
1818            .await
1819            .expect_err("an unmatched doi must be NotFound");
1820        assert!(matches!(err, FetchError::NotFound { .. }), "got {err:?}");
1821    }
1822
1823    #[test]
1824    fn doi_lookup_url_preserves_input_doi_case() {
1825        // The correctness of `link` rests on "Doi::parse does not lower-case,
1826        // OpenAlex is case-insensitive": a real `doiget link 10.1103/PhysRevB.1`
1827        // must send the DOI verbatim in the filter (review #287). Pin it.
1828        let base = Url::parse("https://api.openalex.org").expect("base");
1829        let u = build_doi_lookup_url(&base, "", "10.1103/PhysRevB.1").expect("url");
1830        assert_eq!(
1831            param(&u, "filter").as_deref(),
1832            Some("doi:10.1103/PhysRevB.1"),
1833            "the input DOI case must be carried through verbatim"
1834        );
1835    }
1836
1837    #[test]
1838    fn doi_lookup_url_carries_filter_and_select() {
1839        let base = Url::parse("https://api.openalex.org").expect("base");
1840        let u = build_doi_lookup_url(&base, "", "10.1/x").expect("url");
1841        assert_eq!(
1842            param(&u, "filter").as_deref(),
1843            Some("doi:10.1/x"),
1844            "doi filter must be url-encoded into the query"
1845        );
1846        assert!(param(&u, "select")
1847            .unwrap_or_default()
1848            .contains("locations"));
1849        assert_eq!(param(&u, "per-page").as_deref(), Some("1"));
1850    }
1851
1852    // ---- build_search_url branch coverage --------------------------------
1853
1854    fn param(u: &Url, key: &str) -> Option<String> {
1855        u.query_pairs()
1856            .find(|(k, _)| k == key)
1857            .map(|(_, v)| v.into_owned())
1858    }
1859
1860    #[test]
1861    fn per_page_clamps_to_floor_and_ceiling() {
1862        let base = Url::parse("https://api.openalex.org").expect("base");
1863        let mut q = PaperSearchQuery::new("x");
1864        q.limit = 0;
1865        let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1866        assert_eq!(param(&u, "per-page").as_deref(), Some("1"), "limit 0 -> 1");
1867        q.limit = 201;
1868        let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1869        assert_eq!(
1870            param(&u, "per-page").as_deref(),
1871            Some("200"),
1872            "limit 201 -> 200"
1873        );
1874    }
1875
1876    #[test]
1877    fn to_year_filter_and_relevance_only_sort_land_on_url() {
1878        let base = Url::parse("https://api.openalex.org").expect("base");
1879        let mut q = PaperSearchQuery::new("x");
1880        q.to_year = Some(2023);
1881        let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1882        // Relevance is the only sort (#290).
1883        assert_eq!(param(&u, "sort").as_deref(), Some("relevance_score:desc"));
1884        assert!(
1885            param(&u, "filter")
1886                .unwrap_or_default()
1887                .contains("to_publication_date:2023-12-31"),
1888            "to_year must map to to_publication_date:<y>-12-31"
1889        );
1890    }
1891
1892    #[test]
1893    fn query_is_a_title_and_abstract_filter_not_search_param(/* #290 */) {
1894        let base = Url::parse("https://api.openalex.org").expect("base");
1895        let mut q = PaperSearchQuery::new("classical shadows");
1896        q.min_fwci = Some(5.0);
1897        q.min_percentile = Some(90);
1898        let u = build_search_url(&base, "", &q, &ResolvedIds::default()).expect("url");
1899        // The query is a filter clause now; no top-level `search=` param.
1900        assert_eq!(param(&u, "search"), None, "no loose `search=` param");
1901        let filter = param(&u, "filter").unwrap_or_default();
1902        assert!(
1903            filter.contains("title_and_abstract.search:classical shadows"),
1904            "query must be a title_and_abstract.search filter: {filter}"
1905        );
1906        assert!(filter.contains("fwci:>5"), "min_fwci filter: {filter}");
1907        assert!(
1908            filter.contains("cited_by_percentile_year.min:90"),
1909            "min_percentile filter: {filter}"
1910        );
1911    }
1912
1913    // ---- select_entity disambiguation boundaries -------------------------
1914
1915    fn cand(id: &str, name: &str, works: u64, rel: f64) -> Candidate {
1916        Candidate {
1917            id: id.to_string(),
1918            display_name: name.to_string(),
1919            works_count: works,
1920            relevance: rel,
1921        }
1922    }
1923
1924    #[test]
1925    fn dominance_at_exactly_2x_resolves_top() {
1926        let c = vec![cand("A1", "x", 1, 2.0), cand("A2", "y", 1, 1.0)];
1927        assert_eq!(select_entity("authors", "q", &c).expect("resolves"), "A1");
1928    }
1929
1930    #[test]
1931    fn dominance_just_below_2x_is_ambiguous() {
1932        let c = vec![cand("A1", "x", 1, 1.9), cand("A2", "y", 1, 1.0)];
1933        assert!(matches!(
1934            select_entity("authors", "q", &c),
1935            Err(FetchError::Ambiguous { .. })
1936        ));
1937    }
1938
1939    #[test]
1940    fn zero_relevance_runner_up_is_ambiguous_not_auto_top() {
1941        // Runner-up with an absent (0.0) relevance must NOT let the top
1942        // win by default — the dominance guard requires second > 0.0.
1943        let c = vec![cand("A1", "x", 1, 5.0), cand("A2", "y", 1, 0.0)];
1944        assert!(matches!(
1945            select_entity("authors", "q", &c),
1946            Err(FetchError::Ambiguous { .. })
1947        ));
1948    }
1949
1950    #[test]
1951    fn multiple_exact_name_matches_are_ambiguous() {
1952        // Two entities share the exact display name -> ambiguous, even
1953        // though the first would otherwise dominate on relevance.
1954        let c = vec![cand("S1", "Dup", 9, 5.0), cand("S2", "Dup", 1, 1.0)];
1955        assert!(matches!(
1956            select_entity("sources", "Dup", &c),
1957            Err(FetchError::Ambiguous { .. })
1958        ));
1959    }
1960
1961    // ---- extract_arxiv_from_location edge cases --------------------------
1962
1963    #[test]
1964    fn arxiv_extracted_from_pdf_url_when_landing_absent() {
1965        let loc = serde_json::json!({ "pdf_url": "https://arxiv.org/abs/2302.00001v3" });
1966        assert_eq!(
1967            extract_arxiv_from_location(&loc).as_deref(),
1968            Some("2302.00001v3")
1969        );
1970    }
1971
1972    #[test]
1973    fn arxiv_id_stops_at_query_string() {
1974        let loc =
1975            serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/2101.12345?utm=x" });
1976        assert_eq!(
1977            extract_arxiv_from_location(&loc).as_deref(),
1978            Some("2101.12345")
1979        );
1980    }
1981
1982    #[test]
1983    fn arxiv_extracted_old_style_id_not_truncated() {
1984        // Pre-2007 ids embed a '/'; the capture must keep it (#371).
1985        let loc =
1986            serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/cond-mat/0701105" });
1987        assert_eq!(
1988            extract_arxiv_from_location(&loc).as_deref(),
1989            Some("cond-mat/0701105")
1990        );
1991        // Old-style with subclass + version + a trailing query string.
1992        let loc2 = serde_json::json!({
1993            "pdf_url": "http://arxiv.org/abs/astro-ph.CO/0703123v2?foo=bar"
1994        });
1995        assert_eq!(
1996            extract_arxiv_from_location(&loc2).as_deref(),
1997            Some("astro-ph.CO/0703123v2")
1998        );
1999    }
2000
2001    #[test]
2002    fn arxiv_extraction_rejects_garbage() {
2003        // A non-arXiv capture under abs/ fails ArxivId::parse -> None, rather
2004        // than a truncated / garbage id (#371).
2005        let loc = serde_json::json!({ "landing_page_url": "https://arxiv.org/abs/not an id!" });
2006        assert_eq!(extract_arxiv_from_location(&loc), None);
2007    }
2008
2009    #[test]
2010    fn truncate_for_hint_is_char_boundary_safe() {
2011        // 300 multi-byte chars: must not panic on a byte-slice boundary.
2012        let body = "あ".repeat(300);
2013        let out = truncate_for_hint(body.as_bytes());
2014        assert!(out.ends_with('…'));
2015        assert_eq!(out.chars().filter(|&c| c == 'あ').count(), 200);
2016    }
2017
2018    #[test]
2019    fn ambiguous_has_its_own_wire_code() {
2020        // Distinct from NOT_FOUND so agents can branch (ADR-0031 D5).
2021        let e = FetchError::Ambiguous { hint: "x".into() };
2022        assert_eq!(crate::ErrorCode::from(&e), crate::ErrorCode::Ambiguous);
2023        assert_eq!(crate::ErrorCode::Ambiguous.as_wire(), "AMBIGUOUS");
2024    }
2025
2026    #[test]
2027    fn validate_rejects_bad_shape_and_accepts_good() {
2028        let mut q = PaperSearchQuery::new("topic");
2029        assert!(q.validate().is_ok());
2030
2031        q.query = "  ".to_string();
2032        assert!(q.validate().unwrap_err().contains("empty"));
2033
2034        let mut q = PaperSearchQuery::new("topic");
2035        q.limit = 0;
2036        assert!(q.validate().unwrap_err().contains("limit"));
2037        q.limit = MAX_PER_PAGE + 1;
2038        assert!(q.validate().unwrap_err().contains("limit"));
2039
2040        let mut q = PaperSearchQuery::new("topic");
2041        q.from_year = Some(2025);
2042        q.to_year = Some(2010);
2043        assert!(q.validate().unwrap_err().contains("after"));
2044        // Equal bounds are valid (inclusive range).
2045        q.to_year = Some(2025);
2046        assert!(q.validate().is_ok());
2047    }
2048
2049    #[test]
2050    fn validate_rejects_out_of_range_impact_filters() {
2051        // #290 / review #318: a negative or non-finite `min_fwci`, or a
2052        // percentile above 100, would compose a malformed OpenAlex filter
2053        // clause. `validate()` must reject them at the boundary instead.
2054        let mut q = PaperSearchQuery::new("topic");
2055        q.min_fwci = Some(-1.0);
2056        assert!(q.validate().unwrap_err().contains("min_fwci"));
2057        q.min_fwci = Some(f64::NAN);
2058        assert!(q.validate().unwrap_err().contains("min_fwci"));
2059        q.min_fwci = Some(f64::INFINITY);
2060        assert!(q.validate().unwrap_err().contains("min_fwci"));
2061        // A valid floor passes.
2062        q.min_fwci = Some(2.5);
2063        assert!(q.validate().is_ok());
2064
2065        let mut q = PaperSearchQuery::new("topic");
2066        q.min_percentile = Some(101);
2067        assert!(q.validate().unwrap_err().contains("min_percentile"));
2068        // Boundary value 100 is valid (top 0%, i.e. the single best cohort
2069        // rank); 0 is valid (no floor).
2070        q.min_percentile = Some(100);
2071        assert!(q.validate().is_ok());
2072        q.min_percentile = Some(0);
2073        assert!(q.validate().is_ok());
2074    }
2075}