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:
- Measure.
alma.discovery.scoring.measure_candidateobserves everything observable about a candidate and returns a breakdown. It deliberately returns no score: it decides nothing about importance. - Rank.
ranker.rank_candidate(single) orranker.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:
semanticalone beat the previous full score. Every family that diluted it cost ranking quality.authoranti-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.topicadded nothing oversemantic, andrecencypointed 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_refreshis 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
currentand the sentence says it describes an earlier state. - Honesty at small n — under 40 dated Library papers the state is
not_readywith 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_candidateand 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, everyschedule.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:
maxwhen 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— addsweight × 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:
- Semantic — SPECTER2 cosine between the candidate's vector and the lens's preference centroid (mean of saved-paper vectors). Available when embeddings are enabled.
- Lexical — TF-IDF + character n-gram + scholarly term overlap over a richer scholarly document (title + abstract + topic terms
- 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 getskept / 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 tooutcome_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-yieldis 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_similarityfrom 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:
- Six bucket scans populate a candidate list, each emitting
a per-bucket raw score in 0…
_MAX_SUGGESTION_SCORE(= 100). - Multi-source consensus pass boosts candidates that appeared in more than one bucket.
- Paper-feedback projection pass bumps or penalizes candidates whose author, topics, venues, keywords, or tags are connected to liked/disliked/removed papers.
- Dismissal cluster pass subtracts a penalty from candidates whose attributes overlap recently dismissed authors'.
- 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:
- Citation-graph proximity — authors whose papers are
directly cited by your Library papers (joined via
publication_references). - 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.
openalex_related / s2_related¶
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 sort and trim¶
After weighting, candidates are sorted by:
-score(highest first)- bucket priority (
library_core<cited_by_high_signal==adjacent<semantic_similar< network buckets) — only matters as a tiebreak between equal scores. -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".