Skip to content

Scoring formulas

A Discovery candidate is a paper proposed by one of the retrieval families. One ranker turns it into a number, on every surface that shows a paper score. This page documents that arithmetic: the families, their weights, and the atoms each family is built from.

Read Discovery pipeline first for the architecture this sits inside: how lanes retrieve, how their ranks are fused (two-level RRF), which features are admissible as reward versus exposure, why the ranker is a prior-centred linear model rather than a bigger one, how exploration keeps the feedback loop honest.

One ranker, ten families

Every paper score in ALMa — Discovery, Feed, Online Search — is produced by alma.application.discovery.ranker, in two steps that are always taken together:

  1. Measure. alma.discovery.scoring.measure_candidate observes everything observable about a candidate and returns a breakdown. It deliberately returns no score: it decides nothing about importance.
  2. Rank. ranker.rank_candidate (single) or ranker.apply_repaired_prior (bulk) turns that measurement into a number.

Keeping them apart is the point. Until 2026-07-28 the scorer also combined its own measurements into a composite — nine weights, a consensus bonus, a citation-fabric bonus and a 30-point dismissal penalty — and the ranker then discarded that number and replaced it. The composite was dead on Discovery and live on Feed and Online Search, so the same paper scored differently depending on which page you opened, and the UI drew the discarded decomposition beside the surviving score. The combination stage is gone.

The score

For candidate $c$, over the families $F$ that were measured for it:

$$ \text{score}© = 100 \cdot \mathrm{clip}{[0,1]}!\left( \sum] \right), \qquad \hat{w}} \hat{w}_f \cdot v_f© - 0.45 \cdot \mathbb{1}[\text{retractedf = \frac{w_f}{\sum $$} w_g

Two properties follow, and both are load-bearing:

  • Weights are FIXED — never rescaled per paper. A score is a ranking key; its only job is comparing papers, so a denominator that changes per paper destroys exactly that. A family that could not be measured is imputed at its corpus prior mean (FamilySpec.prior_mean), so not knowing something neither helps nor hurts, and two papers scoring 69 mean the same thing.

v0.22.0 briefly renormalised over the measured families instead. That broke comparability, and with a bias: the families that go missing are the ones papers score badly on (corpus means: citation 0.28, lexical 0.27, semantic 0.51, against feedback 0.96 and topic 0.74), so dropping a weak family and handing its weight to the strong ones was a free upgrade — a paper rose by having less evidence. Prod showed Feed rows (three families missing) averaging 68.1 against Discovery's 62.0 with all ten. Zero-filling is the opposite error: it ranks by hydration completeness, which is the trap that got usefulness_boost deleted. * Closure. The published explanation's family points, adjustments and clipping term sum to the final score exactly. The UI renders that sum, so the bars and the number can never be different quantities again. Guarded by tests/test_score_explanation_closure.py.

The families

ranker.FAMILY_SPECS is the single source of truth: it declares each family's atoms, their sub-weights and their combinator, and both the value and its explanation are derived from it. There is no second table to keep in sync, and the UI cannot describe a formula the scorer is not running.

Family Default weight Built from
semantic 0.444 max(library centroid, closest exemplar) − 0.5 · similarity to passed-on papers
topic 0 topic_score
retrieval 0.14 0.75 · max(RRF over the four channels) + 0.25 · (channels that agreed / 4)
author 0 author_affinity
lexical 0.066 0.45 word + 0.35 char n-gram + 0.20 key term − 0.5 · overlap with passed-on papers
recency 0 recency_boost
citation 0.12 0.50 citation_quality + 0.10 log-ratio(fwci) + 0.20 noisy-OR(coupling, co-citation) + 0.20 max(PPR library, PPR loved)
feedback 0.09 feedback_adj
preference 0.09 preference_affinity
venue 0.05 journal_affinity

The defaults are fitted to outcomes, not chosen (2026-09-18)

The defaults live in ONE table, alma.discovery.defaults.DEFAULT_SIGNAL_WEIGHTS (plus TEXT_SIMILARITY_SEMANTIC_SHARE). The ranker's FAMILY_SPECS, the settings defaults, the API model and migration 41 all read it; nothing else spells a weight.

They were fitted by application/discovery/outcome_eval.py on a temporal holdout: the profile is built from Library papers saved before the median save date, and the test asks whether the score ranks the papers saved AFTER it above (a) papers the user rejected and (b) a random sample of the corpus. The fit is a non-negative logistic regression over the calibrated family readings, 5-fold cross-validated, with both bars weighted equally. feedback and preference are leak-prone offline (they are built from the very outcomes being predicted) and retrieval cannot be measured offline, so those three keep their previous budget and the fit distributes the rest.

AUC, kept papers vs … previous defaults fitted defaults
random corpus — install the fit used (280 kept / 50 rejected) 0.604 0.786
rejected papers — same install 0.742 0.920
random corpus — second install, never seen by the fit (78 / 40) 0.637 0.788
rejected papers — second install 0.680 0.825

What the measurement said, and why three families default to zero:

  • semantic alone beat the previous full score. Every family that diluted it cost ranking quality.
  • author anti-predicted. Most of a monitored corpus is BY followed authors (measured: 82% of corpus papers vs 50% of Library papers), so authorship separates nothing the user has not already chosen by following.
  • topic added nothing over semantic, and recency pointed the wrong way against rejected papers (rejections are recent too: they come from the Feed).

Zero is a default, not a removal: the sliders remain, and the evaluation re-runs on any install (evaluate_ranker_outcomes(conn); its what_if.shipped_defaults scenario scores this table against the stored weights). Migration 41 moves an install to the fitted table only when its nine stored weights equal the previous defaults exactly; any hand-tuned value is left alone.

The measurement is a stored view, shown where the weights are set

scoring:outcome_eval (outcome_eval.EVAL_VIEW_KEY) stores the evaluation. It scores about a thousand papers, so no request computes it:

  • Read — GET /discovery/outcome-evaluation → load_outcome_summary, a row read. Settings → Discovery prints it as one sentence above the sliders.
  • Refresh — request_outcome_eval_refresh is the one entry point, called by a weights save or reset, by the periodic scoring tick (after the calibration is settled: the evaluation reads the ranker through it) and by the "Measure again" link.
  • Freshness — the fingerprint is the Library and verdict counts (bucketed), the nine stored weights, and the calibration row's fingerprint. It is taken BEFORE the build, so a run that started under weights which then changed is stored as not current and the sentence says it describes an earlier state.
  • Honesty at small n — under 40 dated Library papers the state is not_ready with the reason; a bar under 15 papers a side, or whose interval contains 0.5, reads "inconclusive", never as a number.

An unrated save counts as a positive in the profile (split_preference_pubs), the same rule Discovery's lens profile uses. Measured on an identical split, the change was neutral to slightly positive.

Calibration is derived per library, never hardcoded (2026-09-06)

A cosine, an overlap fraction, a family value: none has a useful absolute scale. Measured over one corpus, similarity to the Library ran 0.785..0.956 between the 1st and 99th percentiles — and a library in another field sits somewhere else entirely. So the ranker reads every calibrated input as its percentile in this install's own corpus, and imputes an unmeasured family at what a random paper from this corpus scores. Both come from one stored artifact, scoring:calibration (application/discovery/calibration.py):

  • How it is built. A seeded random sample of corpus papers is scored through the real measurement path with the real preference profile — the code a lens refresh runs — in two passes: the first, uncalibrated, gives each input's raw distribution (the similarity-to-centroid one over every corpus vector, so the top tail keeps its order); the second, under the derived tables, gives each family's mean as scoring will read it. Never measured on a Discovery deck, which retrieval already selected for these very quantities.
  • How it is used. Loaded once per scoring pass and passed explicitly to measure_candidate and the ranker (ScoringCalibration), like the Signal Lab context. Every ranking snapshot records the tables it was scored with, so a replay is exact after a rebuild; the explanation carries the generation.
  • When it refreshes. Its fingerprint moves when the corpus or the Library changed significantly — the Library by 5 papers, the embedding set by 250 vectors or a new day of writes, feedback by 10 events, the model, the build version — not on every save. A lens refresh checks it on the way in (ensure_calibration, first build synchronous), and a periodic job (scoring_calibration_refresh, every schedule.scoring_calibration_interval_hours, default 12) catches drift between refreshes.
  • Until measured. A fresh install with no Library or vectors scores uncalibrated: inputs read raw, families impute at their declared fallback priors, and the explanation says calibration: null. It never borrows a curve tuned for someone else's corpus.

scripts/measure_ranking_priors.py prints what the build derives for a given DB, for review. The prior_mean on each FamilySpec is only that fallback.

Leverage, not weight, is what moves a ranking. A family's influence is its weight times how much its value actually varies across a deck. Before any of this, the two largest sliders in Settings bought almost no discrimination — semantic had the second-largest weight and the least influence of all ten, because raw cosines against one library sit in a band 0.018 wide.

Which seeds are the exemplars (2026-09-06)

The exemplar atom is a candidate's best cosine to a small set of seed papers (12 positive, 8 negative). Those seeds are chosen to cover the seed set — greedy facility location in similarity.select_representatives: each pick maximises how similar every seed is to its nearest chosen exemplar, so a lens with several themes gets exemplars for all of them in proportion to their mass, and an isolated outlier comes last. Before this they were simply the first twelve in list order (the most recently added), which could leave a whole branch of a lens without an exemplar. One rule, used by lens scoring and by the calibration build.

How grouped inputs combine (2026-09-06)

Several families measure one question more than one way. Those atoms share a group that pays its weight once, and FamilySpec.combine declares how the group settles it:

  • max when the members are the same evidence measured several ways — three views of one embedding comparison, two PageRank walks over nested seed sets (measured correlation 0.92). Paying each would multiply one fact by however many ways we happened to measure it.
  • noisy_or (1 − Π(1 − v)) when they are different evidence for the same question. Sharing references with your library and being cited alongside it are separate relations (measured correlation 0.59, each deciding the group on a large share of papers), so a paper supported by both should outrank a paper supported by one — sub-additively, and still bounded by the group's weight.

The explanation publishes a counted flag per atom, so the card shows which measured inputs actually reached the score instead of re-deriving a rule that now differs per group.

semantic_similarity_support_raw left the semantic group. It is defined as min(centroid, exemplar) — the minimum of the two atoms it was competing against — so inside a group paying the maximum it could not win on any corpus, ever. Measured: it won 0 of 138 papers, and removing it changed no score by any amount. It is still recorded as a diagnostic; the two views agreeing is worth seeing, it just is not a third opinion.

fwci is read on a log ratio, not divided by 3. It is a ratio to the field average with a very long tail (measured over 6,108 papers: median 0.16, 95th percentile 19.6, maximum 8,825). Dividing by 3 pinned 22.5% of the corpus at the ceiling and squeezed the median to 0.05. The curve is now 0.5 + 0.5 · log10(fwci) / 2, so the field average sits at 0.5, two decades either side reach the ends, 1% saturate and the median lands at 0.30.

Weights come from discovery_settings.weights.* (Settings → Discovery). One slider, weights.text_similarity, drives two families — semantic takes 87% of it and lexical 13% (TEXT_SIMILARITY_SEMANTIC_SHARE, fitted with the weights) — which is why the defaults above are 0.444 / 0.066 for a slider set to 0.51. Weights are normalised to sum to 1 before scoring.

Three combinators, declared per atom:

  • sum — adds weight × value.
  • max — competes inside a group; the group pays its weight once, to the winner. This is how correlated views of the same evidence avoid being double-paid: three similarity views of one embedding, two graph views of one citation neighbourhood, four retrieval channels ranking the same paper.
  • penalty — subtracts, and never makes a family "available" on its own.

Negative preference enters through the penalty atoms (semantic_similarity_negative_raw, lexical_similarity_negative_penalty) — inside the families, bounded by them. The old free-standing 30-point dismissal cluster penalty is retired.

Multi-source agreement enters as the retrieval_family_count atom. The old free-standing consensus bonus is retired for papers. (The author suggestion rail keeps its own consensus bonus and dismissal penalty — a separate, independent implementation documented in the second half of this page.)

Recommendation mode

recommendation_mode (balanced / explore / exploit) multiplies family weights before normalisation, so the modes are zero-sum:

Mode Multipliers
explore recency ×1.5; citation, author, venue ×0.5
exploit author, venue, preference ×1.5; recency ×0.5

It also widens or narrows branch spread at seed time (seed_profile._resolve_branch_temperature).

The prior is a prior

These weights are a prior, not a fit. They are the centre that the shadow prior-centred ridge model shrinks toward; see the ranker ladder for what has to be true before a fitted model is promoted over them.

Signal atoms

The functions below are what measure_candidate computes. They are the atoms the families above are assembled from — each one a measurement, with no opinion about its own importance.

source_relevance

How strongly the channel that produced this candidate "votes" for it. OpenAlex related-works votes hard (≈1.0); broad topic search votes lower (≈0.3). SPECTER2 cosine votes proportionally to the cosine value.

Range: 0…1.

text_similarity

Two paths, blended:

  1. Semantic — SPECTER2 cosine between the candidate's vector and the lens's preference centroid (mean of saved-paper vectors). Available when embeddings are enabled.
  2. Lexical — TF-IDF + character n-gram + scholarly term overlap over a richer scholarly document (title + abstract + topic terms
  3. venue), not just plain title/abstract.

Both are calibrated through piecewise curves so method-level matches aren't compressed into near-zero values. The two paths average; if only one is available, that one is used.

Range: 0…1.

author_affinity

Does the candidate's author match the canonical author signal ALMa has learned from your Library, ratings, graph context, and embeddings?

ALMa computes one shared author signal and reuses it here. The Discovery ranker takes the stable components only:

Component What it means
Saved footprint Saved Library papers by this author, normalized against the author with the largest saved footprint.
Rating Average rating of saved papers by this author; 5★ is positive, 3★ neutral, 1★ negative.
Similarity Author SPECTER2 centroid against the Library centroid. Author centroids use their own scale: cosine ≤0.35 is absent/0, cosine 1.0 is 100, and values between scale linearly.
Neighborhood Co-authorship with your Library circle and cited-by-Library adjacency, using soft saturation so one/few links are visible but do not become 100.

The volatile interaction component (fresh likes, dislikes, follows, and projected paper feedback) flows through feedback_adj instead, so the same feedback is not counted twice.

Range: -1…1 internally. Negative author affinity can lower candidates; the UI displays positive affinity as 0…100.

journal_affinity

Does the candidate's venue (journal / conference) appear often in your Library? The user's preference profile stores per-venue prevalence weights via $\log(1 + n) / \log(1 + N)$ where $n$ is the count of saved papers in this venue and $N$ is the count in your most-saved venue. The candidate's venue is matched against this dict; the resulting weight is the signal value.

This is a log-prevalence scheme — sharing the user's #1 venue gets weight 1.0, and a venue that only appears in 5/50 of the user's papers gets ~0.42 (versus ~0.10 under naive linear normalization).

Long-tail venues stay visible in scoring instead of being drowned by the dominant outlet. Same shape as topic_score and the author-rail prevalence pattern.

Range: 0…1.

recency_boost

A small boost for newer papers, decaying linearly:

$$ \text{recency}© = \max(0, 1 - \frac{\text{years_since}©}{R}) $$

where $R$ is discovery.limits.recency_window_years (default 10).

Range: 0…1.

citation_quality

Log-scaled citation count, with an "influential citation" floor:

$$ \text{eff} = \max(\text{cited_by}, 2 \times \text{influential_citations}) $$ $$ \text{citation_quality} = \min!\left(1, \frac{\log(\text{eff} + 1)}{\log(1000)}\right) $$

A paper with ~1000 effective citations gets ≈1.0; a paper with 0 gets 0. The 2 × influential_citation_count floor lets a highly-influential paper score well even with a modest raw count. The denominator is fixed so the function is interpretable across candidates.

Range: 0…1.

feedback_adj

Adjusts the score based on prior feedback on the candidate's attributes (paper, topics, authors, venue, keywords, and tags). ALMa reads three canonical preference sources through alma.application.signal_projection and folds each into the same per-paper signal map before fanning out:

Source Weight What it captures
feedback_events (paper_action + legacy single-action types) 1.0 Canonical write path (save / like / love / dislike / remove). Historical dismiss events normalize to zero because visibility is not preference.
papers.rating 0.6 Library star ratings. No time decay (a 5★ paper is still a 5★ paper).
recommendations.user_action 0.5 Legacy per-recommendation actions, age-decayed like feedback_events.

Each signed paper signal then projects to the connected graph:

Target Propagation rule
Paper Direct signed signal
Authors / co-authors Position-weighted, damped by 1 / sqrt(author_count)
Topics Topic score times the signed paper signal
Venue Weak, capped venue prior
Keywords / tags Tags stronger than extracted keywords
Semantic neighbours Close active-model embedding neighbours only
Citation neighbours Local incoming and outgoing citation edges
Author follow / reject Direct author signal plus weak profile spillover to topics, venues, keywords, tags, direct coauthors, and same-institution colleagues

The last row spreads followed-author signal slightly wider than the direct author: the followed author's frequent collaborators inherit a weak positive prior, and other authors at the same institution inherit a weaker one (capped to ≤400-author affiliations to skip mega-universities). Symmetric for missing_author_feedback rejects.

Each prior feedback event contributes:

  • Positive (rating ≥ 4) → small boost.
  • Negative (rating ≤ 2) → small penalty.

Two configurable windows exist as defaults:

Window Default Weight
feedback_decay_days_full 90 1.0
feedback_decay_days_half 180 0.5

Note: the projection path that actually drives author-suggestion and paper feedback (application/signal_projection.py) does not read these settings — it hardcodes a tanh decay with a 180-day half-life and a 730-day max age, beyond which weight tapers to 0.

Range before normalization: -1…+1. The weighted scorer stores it as a 0…1 value in the final score, and the explanation payload includes projected_feedback_raw so the signed contribution remains visible.

preference_affinity

A confidence-weighted mean of your recorded affinities for the entities this candidate touches — its topics, its authors, and the source it came from — read from preference_profiles (feedback_substrate.get_preference_affinity_signal). It is not a vector distance; every entity that matches a profile row contributes its affinity_weight, weighted by that row's confidence:

$$\text{raw} = \frac{\sum_i w_i c_i}{\sum_i c_i} \times \text{volume_scale}$$

where $w_i \in [-1, 1]$ is the stored affinity, $c_i = \min(1,\,n_i/20)$ is that entity's reliability from its own interaction count, and volume_scale ramps 0.3 → 1.0 over 2–10 interactions across the whole profile. Shifted to $[0, 1]$ for the breakdown, so 0.500 means "no matching evidence".

Dividing by $\sum c_i$ rather than by the match count matters more than it looks: until 2026-07-27 the code divided by the count, which turned a per-entity reliability into a multiplier on the output. On a personal corpus an entity is seen once or twice, so $c \approx 0.05$–$0.15$, and the signal was pinned within ±0.015 of 0.500 on 85% of rows. See discovery-pipeline §7.3 for the full diagnosis.

Range: 0…1. Requires preference_profiles to have entries; no embeddings needed.

source_relevance boost per channel

discovery_settings.weights also carries per-channel multipliers used by the retrieval phase, before ranking. They shape which candidates exist, not what they score.

Channel yield: outcomes scale retrieval, never the score

How a paper reached you is exposure, and exposure stays out of the reward model. So outcomes feed back into what is retrieved, not into a score multiplier (the per-source source_calibration_multiplier left the scoring path on 2026-07-27 and its code was removed on 2026-09-19; old stored breakdowns may still carry the key).

application/discovery/channel_yield.py, stored view discovery:channel_yield:

  • Data. Every refresh records, for each candidate it surfaced (discovery_ranking_candidates.selected = 1), which retrieval families found it (retrieval_hits). Joined to the outcome owner (recommendation_outcomes.build_paper_outcome_map), each channel gets kept / surfaced. A paper found by several channels counts for each. Surfaced-and-ignored is the denominator: explicit rejections are too rare to estimate anything from.
  • Estimator. Rates are shrunk toward the pooled rate by a strength derived from the data — the beta-binomial method-of-moments estimate of how much the channels vary beyond their sample sizes (core/scoring_math.empirical_bayes_rates). When they do not, every multiplier is exactly 1.0. No tuned constant.
  • Effect. multiplier = shrunk rate / pooled rate, clamped to outcome_calibration.MULTIPLIER_BAND ([0.5, 1.5]). At refresh the lens's channel weights (the prior: the user's sliders or the context-type defaults) are multiplied and renormalised, and the Activity log states base → effective.
  • Family → channel has one owner, retrieval/_common.CHANNEL_BY_FAMILY (semantic → vector, citation → graph, taste → external), read by the fusion and by the yield.
  • Surface. The lens's Tune this lens → Lens Weights prints, per channel, "k of n surfaced papers kept → ×m, used as w". Switch: Settings → Discovery → Retrieval Strategies → Adaptive Channel Weights (strategies.adaptive_channels, default on). GET /discovery/channel-yield is a row read.

Measured 2026-09-19 — prod snapshot: lexical 3/36 → ×0.85, vector 15/68 → ×1.13, graph 10/62 → ×0.98 (external: nothing surfaced, ×1.00). Dev: lexical 0/35 → ×0.68, vector 4/62 → ×1.50 (the band's ceiling), graph 2/55 → ×1.08, external 0/44 → ×0.62. Both installs order the channels the same way. The band's floor keeps every channel in play, and the slate's randomized exploration slots (exploration.py) are filled after fusion, so a down-weighted channel keeps producing the outcomes that could lift it again.

Branch-level outcome weighting is separate and unchanged: a branch's auto_weight (lens_crud._compute_branch_auto_weight) shapes its retrieval budget from its own save / dismiss history. The full snapshot — quality, multipliers, raw counts, impressions — also lives on retrieval_summary.calibration.{source_api, branch_mode, branch_id}.

Author rail bucket calibration

The Suggested Authors rail uses the same machinery on a different grouping. Each rail card carries a suggestion_type (the bucket: library_core / cited_by_high_signal / adjacent / semantic_similar / openalex_related / s2_related). Two log tables capture per-bucket outcomes:

  • author_suggestion_follow_log — one row per rail-originated follow, with the bucket label.
  • missing_author_feedback — one row per reject (signal_value < 0), with the bucket label since Phase 4.

compute_author_bucket_calibration(db) aggregates both into the same posterior shape, producing {bucket: multiplier}. Inside list_author_suggestions the multiplier is folded into the existing per-bucket weight pass:

$$ \text{score}© = \min\bigl(100, \text{raw}© \cdot w_{\text{bucket}} \cdot m_{\text{bucket}}\bigr) $$

The card response carries bucket_calibration_multiplier for provenance. As with paper Discovery, a fresh DB returns no multipliers → 1.0 → no behavior change until follow / reject events accumulate.

Tuning

Three knobs change the balance:

  • Per-family weights — Settings → Discovery. The sliders are normalised to sum to 1, so halving weights.text_similarity from 0.51 to 0.25 drops the semantic + lexical families from 51% to about a third of the budget.
  • Recommendation mode — see above.
  • Per-lens retrieval channels — a lens carries its own retrieval channel weights (lexical / vector / graph / external), which decide what is retrieved. The family weights that score it are global: every caller goes through ranker.resolve_family_weights.

The recommendations table caches the last batch, so re-tuning does not lose results — only the next refresh applies new weights.

What the weights card promises (2026-09-06)

The sliders are relative. The ranker rescales them to sum to 1 (resolve_family_weights) and applies the recommendation mode's multipliers before any score is computed, so the number on a slider is not what a family gets. The card therefore shows, per slider:

  • share — this slider's fraction of the current (unsaved) slider total, recomputed live on every row as you type;
  • · saved N% — the fraction the ranker gives it under the SAVED settings (GET /discovery/settings → effective_weights), shown only while it differs from the live share;
  • Typical paper scores N in the section header — reference_score, what an all-average paper scores under the saved weights (the same number every score meter draws as its tick). It moves when you save, never while you edit.

Saving writes settings only; no lens is refreshed. Verified end to end on the dev stack: recency 0.1→0.9 moved the effective weight 0.09→0.47 and the reference 55→45 on save, and the next refresh replaced 8 of the top 20 and moved their mean year by two years; a zero weight gives zero points; restoring the weights gives the original deck back. Guards: frontend/src/components/settings/DiscoveryWeightsCard.test.tsx (the live share is read over the NEW total) and frontend/e2e/discovery-weights.spec.mjs (the real page, restoring the weights it found).

Score breakdown

The stored score_breakdown carries the closed decomposition under explanation, and GET /api/v1/discovery/recommendations/{id}/explain returns it verbatim:

{
  "explanation": {
    "ranker_version": "discovery-v4-family-prior",
    "final_score": 61.4,
    "families": [
      {
        "key": "semantic",
        "label": "Semantic",
        "description": "Embedding similarity to what you already keep.",
        "value": 0.76,
        "weight": 0.127,
        "points": 9.67,
        "available": true,
        "atoms": [
          {"key": "semantic_similarity_centroid_raw", "label": "Library centroid",
           "value": 0.81, "weight": 1.0, "role": "max", "group": "positive",
           "available": true}
        ]
      }
    ],
    "adjustments": [{"key": "retraction", "label": "Retracted", "points": 0.0,
                     "available": false}],
    "clipped": 0.0
  }
}

Σ families.points + Σ adjustments.points + clipped === final_score. The paper card's Why panel renders exactly these rows, each expandable to its atoms, and nothing else — so what you read is what ranked the paper.

Alongside explanation, the breakdown carries the raw measurements (semantic_similarity_*, lexical_similarity_*, coupling_strength, …) and retrieval provenance (matched_query, consensus_count, provenance.*). Those are diagnostics and retrieval evidence — they explain why the paper surfaced, not what it scored.


Author suggestions

The Authors page rail (GET /api/v1/authors/suggestions, implemented in alma.application.authors.list_author_suggestions) runs a separate scoring pipeline from Discovery. Same band, different formulas.

The pipeline has five phases:

  1. Six bucket scans populate a candidate list, each emitting a per-bucket raw score in 0…_MAX_SUGGESTION_SCORE (= 100).
  2. Multi-source consensus pass boosts candidates that appeared in more than one bucket.
  3. Paper-feedback projection pass bumps or penalizes candidates whose author, topics, venues, keywords, or tags are connected to liked/disliked/removed papers.
  4. Dismissal cluster pass subtracts a penalty from candidates whose attributes overlap recently dismissed authors'.
  5. Per-bucket weight + sort applies the discovery_settings.author_suggestion_weights.* multipliers and orders the rail.

All scoring constants are at the top of application/authors.py:

_MAX_SUGGESTION_SCORE = 100.0                           # band ceiling
_CONSENSUS_BONUS_FRACTION = 0.12                        # 5-bucket → ~24% of band
_DISMISSAL_TOPIC_PENALTY_PER_HIT = 0.020 * _MAX        # = 2.0
_DISMISSAL_VENUE_PENALTY_PER_HIT = 0.015 * _MAX        # = 1.5
_DISMISSAL_COAUTHOR_PENALTY_PER_HIT = 0.008 * _MAX     # = 0.8 (intentionally light: see rationale)
_DISMISSAL_INSTITUTION_PENALTY_PER_HIT = 0.010 * _MAX  # = 1.0
_DISMISSAL_PENALTY_CAP = 0.30 * _MAX                   # = 30.0

Penalties / bonuses are expressed as fractions of the band so they stay calibrated if the band ever rescales — change _MAX_SUGGESTION_SCORE and every formula stays proportional.

Bucket-level formulas

library_core

Authors who appear on papers in your saved Library.

For each (candidate, library-paper) pair, contribute:

$$ \frac{\text{rating_w}(p) \times \text{position_w}(\text{pa}) \times \text{recency_w}(p)}{\sqrt{N_{\text{authors}}(p)}} $$

with:

Factor Mapping
rating_w(p) 0:1.0 (unrated = neutral) · 1:0.2 · 2:0.5 · 3:1.0 · 4:2.0 · 5:3.0
position_w(pa) first/last:1.5 · middle:1.0
recency_w(p) 1.3 if year ≥ current_year - 3 else 1.0
N author count of p from publication_authors

Sum over the candidate's library papers gives weighted_contribution. Per-bucket score:

$$ \text{score} = \min\left({\max}, 24 \cdot wc + \sum(v)\right) $$} 8 \cdot \text{prevalence}(t) + \sum_{v \in V} 6 \cdot \text{prevalence

24 is the outer multiplier that puts the band around 0–100; topic / venue overlap contributions are prevalence-weighted (see topic / venue weighting below).

A 5★ first-author of a 1-person paper saturates near 100; a middle author of a 30-person consortium paper rated neutrally lands around 7.

cited_by_high_signal

Authors whose works are cited by your Library papers rated ≥ 4★.

For each (candidate, library-citing-paper) pair, contribute:

$$ \frac{\text{citing_rating_w} \times \text{position_w}(\text{pa}) }{\sqrt{N_{\text{cited_authors}}}} $$

with citing_rating_w = 1.5 if 5★ else 1.0 (the min_rating=4 gate already drops 1-3★). Sum gives weighted_endorsement. Per-bucket score:

$$ \text{score} = \min\left(_{\max}, 30 \cdot we + 4 \cdot c\right) $$

where c is the count of distinct cited papers (a small breadth tiebreaker).

adjacent

Two SQL passes, OR'd:

  1. Citation-graph proximity — authors whose papers are directly cited by your Library papers (joined via publication_references).
  2. Topic / venue overlap fallback — authors whose publication record shares ≥ 2 of your top 12 library topics OR ≥ 1 of your top 8 library venues.

Per-bucket score:

$$ \text{score} = \min\left(_{\max}, 20 sp + 8 lp + 4 rp + 8 \sum \text{topic_prev} + 6 \sum \text{venue_prev} + 5 |\text{shared_lib_authors}|\right) $$

with sp = shared papers, lp = candidate's local paper count, rp = recent local paper count.

semantic_similar

SPECTER2 cosine of the candidate's paper-embedding centroid against your Library centroid (helper: _semantic_similar_candidates).

$$ \text{score} = \min\left(_{\max}, 90 \cdot \text{cos} + \min(\text{embedded}, 10)\right) $$

A 0.9 cosine maps to 90; the small embedded term is a tiebreak for candidates with more than one embedded paper.

Pure cache reads from author_suggestion_cache, populated asynchronously by POST /authors/suggestions/refresh-network. Each cached row carries a composite_score ∈ [0, 1] computed externally; the bucket simply rescales:

$$ \text{score} = \min\left(_{\max}, 100 \cdot \text{composite}\right) $$

Each network bucket gets network_slot_cap = max(2, ⌈limit/3⌉) new slots so that even a Library that saturates library_core still sees external suggestions. Overlap with prior buckets feeds the consensus pass, not the slot cap.

Topic / venue / author prevalence weighting

_top_topics_for_library(db, limit=12) and _top_venues_for_library(db, limit=8) return {label: paper_count}. _build_prevalence_weights converts to log-normalized weights:

$$ \text{prevalence}(t) = \frac{\log(1 + \text{count}(t))}{\log(1 + \text{count}_{\max})} $$

so the top library topic = 1.0 and a topic with count=1 in a library where the max is 20 gets ≈0.23.

The same log_prevalence_weights transform applies to author_affinity in discovery/scoring.py. Authors used to be linearly max-normalized on the rationale that "you wrote with this person or you didn't" — but on heavily skewed libraries (one PI on 70% of saved papers) that scheme floored every other author at <0.1 and let the dominant author crowd the top-K. Log-prevalence gives a co-author on 5 of 100 saved papers a meaningful 0.4 instead of an invisible 0.05. The structural per-author cap in engine.diversity_interleave is the second guardrail.

_weighted_overlap_score(shared, weights, scale) sums prevalence weights for the candidate's overlap × scale. This is what the 8 ∑ topic_prev / 6 ∑ venue_prev terms in the bucket formulas above mean. Multipliers were bumped from the pre-2026-05 values of 5 / 4 so a top-topic match is more valuable than the old equal-count scheme, not just redistributed.

Multi-source consensus bonus

After all buckets run, each candidate's consensus_buckets list contains the labels of every bucket that surfaced them. The post-pass adds:

$$ \text{bonus}(N) = _{\text{frac}} \cdot _{\max} \cdot \sqrt{N - 1} $$

where _frac = _CONSENSUS_BONUS_FRACTION = 0.12 and N = len(consensus_buckets).

N Bonus today
1 0
2 12
3 ~17
4 ~21
5 ~24
6 ~27

Diminishing returns are intentional: 5+ buckets agreeing is strong evidence but should never trivially saturate the band against a high-confidence single-bucket signal.

For overlap to even be detected, each bucket helper passes only followed_ids to its SQL exclude_ids parameter (NOT followed_ids | seen_candidates). The loop body's if oid in seen_candidates: _record_consensus(...) then captures the multi-bucket appearance instead of dropping the row.

Dismissal cluster penalty

_load_dismissal_signature(db, lookback_days=100) builds four dicts from authors with signal_value < 0 in missing_author_feedback over the lookback window:

Signature Shape Built from
topic_sig {topic: dismissed_author_count} publication_topics join
venue_sig {venue: dismissed_author_count} papers.journal join
coauthor_sig {coauthor_oid: shared_paper_count} publication_authors self-join
institution_sig {institution: dismissed_author_count} publication_authors.institution

Coauthor signature uses paper count, not dismissed-author count — collaboration depth is the relevant signal: a candidate on 5 papers with one dismissed author is more cluster-bound than one on 1 paper each with 5 dismissed authors. The per-hit penalty is intentionally low (0.008 × _MAX = 0.8 per shared paper) because dismissing an author often means "not this person", NOT "none of their co-authors". Only deep collaboration (10+ shared papers) climbs to a meaningful penalty (≥ 8 points); a single co-authorship barely registers.

_dismissal_overlap_penalty computes the per-candidate penalty:

$$ \text{penalty} = \min\left({\text{cap}}, \sum_t \text{topic_sig}[t] \cdot p_t + \sum_v \text{venue_sig}[v] \cdot p_v + \text{coauthor_sig}[c[i] \cdot p_i \right) $$}] \cdot p_c + \sum_i \text{inst_sig

with per-hit constants from the top of authors.py. Topic / venue / institution use list-overlap; coauthor is a single-ID match against the candidate's own openalex_id.

The cap (_DISMISSAL_PENALTY_CAP = 30.0) is load-bearing: it prevents the rail from permanently zeroing a candidate based on cluster overlap alone. Explicit dismissal is the only mechanism that fully removes someone.

Penalties land on each entry as a dismissal_penalty field for debugging / UI, and are subtracted from the per-bucket score after the consensus bonus, before the per-bucket weight multiplier. Ordering rationale: consensus is positive evidence about the bucket signal; dismissal is a learned negative that must attenuate even confirmed candidates; bucket weight is the final tunable normalization.

Per-bucket weights

Stored under discovery_settings.author_suggestion_weights.*. Defaults from alma.discovery.defaults:

Bucket Default weight Rationale
library_core 1.0 Strongest evidence — direct co-authorship.
cited_by_high_signal 0.9 Uses ratings end-to-end now; nearly equal to library_core.
openalex_related 0.9 External discovery; equal-footing-ish so the rail isn't dominated by local data.
s2_related 0.9 Same as openalex_related; independent source.
semantic_similar 0.8 Less interpretable than the others, so slightly lower.
adjacent 0.7 Citation/topic adjacency is a weaker primary signal than direct co-authorship.

The weight applies to the per-bucket raw score AFTER the consensus bonus and dismissal penalty:

final = weight × min(_MAX, raw_bucket_score + consensus_bonus - dismissal_penalty)

Final sort and trim

After weighting, candidates are sorted by:

  1. -score (highest first)
  2. bucket priority (library_core < cited_by_high_signal == adjacent < semantic_similar < network buckets) — only matters as a tiebreak between equal scores.
  3. -local_paper_count, -recent_paper_count, then name.

Then same-human dedup collapses entries whose normalized display names match (handles OpenAlex split profiles for the same human; the highest-scoring row wins, dropped IDs go to alt_openalex_ids on the survivor).

Finally, _diversify_final trims to the requested limit while guaranteeing at least one slot per populated bucket so a high-volume bucket cannot crowd out the others.

Per-suggestion fields

Each entry returned by list_author_suggestions carries:

Field Purpose
score Final 0–100 number after consensus + dismissal + weight.
suggestion_type The primary bucket label (used for the UI chip).
weighted_contribution Raw library_core SUM (when applicable).
weighted_endorsement Raw cited_by_high_signal SUM (when applicable).
consensus_buckets List of bucket labels that surfaced this candidate.
consensus_count len(consensus_buckets).
dismissal_penalty Subtracted points from cluster penalty (only set when > 0).
signals Priority-ordered evidence chips for the UI ("co-author of X", "SPECTER 0.83", …).
shared_topics / shared_venues / shared_followed_authors Display-side overlap lists.

Tests pinning the contract

tests/test_author_suggestions_scoring.py covers:

  • Consortium middle-author down-weight via 1/√N.
  • Rating-based separation of co-authors (5★ vs 1★).
  • Cited-by-high-signal lead vs consortium-middle.
  • Top-topic match outranking rare-topic match (prevalence weighting).
  • Dismissal penalty firing on topic, coauthor, and institution cluster overlap.
  • Multi-source consensus bumping above single-source.
  • Unrated rating=0 treated as neutral (=3), not negative.

When changing any constant or formula above, update or add a test there. The project-internal lessons file (tasks/lessons.md, gitignored) captures the rationale and gotchas under the headings "Author suggestion scoring: weight, don't count", "Author suggestion buckets must collect consensus", "Topic / venue overlap is not a count", and "Dismissal propagation".