reference-audit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. reference_audit/__init__.py +8 -0
  2. reference_audit/alignmentcheck.py +136 -0
  3. reference_audit/bookcheck.py +98 -0
  4. reference_audit/cache/__init__.py +1 -0
  5. reference_audit/cache/db.py +108 -0
  6. reference_audit/cache/store.py +128 -0
  7. reference_audit/cli.py +190 -0
  8. reference_audit/config.py +179 -0
  9. reference_audit/fieldcheck.py +599 -0
  10. reference_audit/inputs.py +96 -0
  11. reference_audit/llm/__init__.py +6 -0
  12. reference_audit/llm/client.py +85 -0
  13. reference_audit/llm/prompts.py +206 -0
  14. reference_audit/llm/schemas.py +38 -0
  15. reference_audit/matching/__init__.py +6 -0
  16. reference_audit/matching/adjudicate.py +63 -0
  17. reference_audit/matching/features.py +185 -0
  18. reference_audit/matching/names.py +238 -0
  19. reference_audit/matching/pool.py +344 -0
  20. reference_audit/matching/sameobject.py +150 -0
  21. reference_audit/matching/scoring.py +75 -0
  22. reference_audit/matching/verdict.py +56 -0
  23. reference_audit/matching/webcheck.py +189 -0
  24. reference_audit/models.py +412 -0
  25. reference_audit/parsing/__init__.py +1 -0
  26. reference_audit/parsing/bib.py +392 -0
  27. reference_audit/parsing/context.py +55 -0
  28. reference_audit/parsing/entities.py +22 -0
  29. reference_audit/parsing/identifiers.py +165 -0
  30. reference_audit/parsing/latex.py +83 -0
  31. reference_audit/parsing/tex.py +159 -0
  32. reference_audit/pdf/__init__.py +16 -0
  33. reference_audit/pdf/embedded.py +117 -0
  34. reference_audit/pdf/grobid.py +234 -0
  35. reference_audit/pdf/tei.py +470 -0
  36. reference_audit/pipeline.py +1692 -0
  37. reference_audit/report.py +330 -0
  38. reference_audit/sources/__init__.py +6 -0
  39. reference_audit/sources/arxiv.py +81 -0
  40. reference_audit/sources/base.py +54 -0
  41. reference_audit/sources/clickhouse.py +575 -0
  42. reference_audit/sources/crossref.py +90 -0
  43. reference_audit/sources/dblp.py +140 -0
  44. reference_audit/sources/google_books.py +101 -0
  45. reference_audit/sources/http.py +260 -0
  46. reference_audit/sources/normalize.py +464 -0
  47. reference_audit/sources/openalex.py +93 -0
  48. reference_audit/sources/openlibrary.py +158 -0
  49. reference_audit/sources/publisher.py +186 -0
  50. reference_audit/sources/registry.py +117 -0
  51. reference_audit/sources/render.py +115 -0
  52. reference_audit/sources/semantic_scholar.py +68 -0
  53. reference_audit/sources/titlewords.py +31 -0
  54. reference_audit/sources/web.py +256 -0
  55. reference_audit/versioning.py +125 -0
  56. reference_audit-0.1.0.dist-info/METADATA +715 -0
  57. reference_audit-0.1.0.dist-info/RECORD +60 -0
  58. reference_audit-0.1.0.dist-info/WHEEL +4 -0
  59. reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
  60. reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,185 @@
1
+ """FeatureVector: interpretable per-(entry, candidate) signals.
2
+
3
+ Decomposes `_best_match`'s scalar into named features. M2 wires title/author/year/id_agreement +
4
+ the advisory `composite`; the distinct-work detectors (prefix_trap, author_set_jaccard,
5
+ pages_conflict) are implemented here too and become load-bearing in M5's SAME-OBJECT rule.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Literal
12
+
13
+ from anyascii import anyascii
14
+ from rapidfuzz import fuzz
15
+
16
+ from reference_audit.models import BibEntry, FeatureVector, Identifiers, SourceRecord
17
+ from reference_audit.parsing.entities import decode_html_entities
18
+ from reference_audit.matching.names import author_overlap, author_set_jaccard, author_subset
19
+
20
+
21
+ def _norm_title(t: str) -> str:
22
+ text = anyascii(decode_html_entities(t or "")).lower() # "&" is no word "amp"
23
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", text)).strip()
24
+
25
+
26
+ def title_ratio(a: str, b: str) -> float:
27
+ """Length-guarded title similarity.
28
+
29
+ `token_set_ratio` returns 100 when the shorter title's tokens are a *subset* of the longer's
30
+ ("Fitness Landscapes" ⊂ "Fitness Landscapes and the Origin of Species"), which would falsely
31
+ match a different, shorter-titled work. So token_set_ratio is only trusted when the two titles
32
+ have comparable token counts; otherwise fall back to order-aware ratios that penalize the
33
+ length gap.
34
+ """
35
+ na, nb = _norm_title(a), _norm_title(b)
36
+ if not na or not nb:
37
+ return 0.0
38
+ base = max(fuzz.ratio(na, nb), fuzz.token_sort_ratio(na, nb))
39
+ ta, tb = len(na.split()), len(nb.split())
40
+ length_ratio = min(ta, tb) / max(ta, tb) if max(ta, tb) else 0.0
41
+ if length_ratio >= 0.7: # comparable lengths → token_set reordering bonus is safe
42
+ base = max(base, fuzz.token_set_ratio(na, nb))
43
+ return base / 100.0
44
+
45
+
46
+ def _tokens(t: str) -> list[str]:
47
+ return [w for w in _norm_title(t).split() if w]
48
+
49
+
50
+ def title_prefix_trap(a: str, b: str, *, tail_threshold: float) -> bool:
51
+ """High shared leading-token overlap but divergent tails (bagrov vs kravchenko, T2a)."""
52
+ ta, tb = _tokens(a), _tokens(b)
53
+ if len(ta) < 4 or len(tb) < 4:
54
+ return False
55
+ shared_prefix = 0
56
+ for x, y in zip(ta, tb):
57
+ if x == y:
58
+ shared_prefix += 1
59
+ else:
60
+ break
61
+ if shared_prefix < 3:
62
+ return False
63
+ tail_a, tail_b = set(ta[shared_prefix:]), set(tb[shared_prefix:])
64
+ if not tail_a or not tail_b:
65
+ return False
66
+ tail_jaccard = len(tail_a & tail_b) / len(tail_a | tail_b)
67
+ return tail_jaccard < tail_threshold
68
+
69
+
70
+ def year_factor(qy: int | None, iy: int | None) -> float:
71
+ if qy is None or iy is None:
72
+ return 1.0
73
+ delta = abs(qy - iy)
74
+ return 1.0 / (1.0 + (delta / 3.0) ** 2) # ~1.0 at ±1, never a hard gate (preprint↔published)
75
+
76
+
77
+ def _published_doi(doi: str | None) -> str | None:
78
+ """A DOI that identifies a published artifact (an arXiv DataCite DOI is the preprint id, not a
79
+ competing published DOI, so it must not register as a DOI conflict)."""
80
+ if not doi or doi.startswith("10.48550/arxiv"):
81
+ return None
82
+ return doi
83
+
84
+
85
+ def id_agreement(a: Identifiers, b: Identifiers) -> Literal["match", "conflict", "absent"]:
86
+ """Opaque-token compare across shared id kinds: match wins; conflict if a shared kind disagrees.
87
+
88
+ arXiv DataCite DOIs are compared as arXiv ids, not as published DOIs — a published DOI vs an
89
+ arXiv preprint DOI is a version relationship, not a conflict.
90
+ """
91
+ saw_shared = False
92
+ # ISBN is set-valued: one book registers several ISBN-13s (print + electronic, per edition), so a
93
+ # shared ISBN anywhere in the two sets is a match, and it's a conflict only when the sets are
94
+ # wholly disjoint — never because the cite and the source picked different printings of one book.
95
+ a_isbns, b_isbns = a.all_isbn13(), b.all_isbn13()
96
+ if a_isbns and b_isbns:
97
+ saw_shared = True
98
+ if a_isbns & b_isbns:
99
+ return "match"
100
+ pairs = (
101
+ (_published_doi(a.doi), _published_doi(b.doi)),
102
+ (a.arxiv_id, b.arxiv_id),
103
+ (a.pmid, b.pmid),
104
+ (a.openalex, b.openalex),
105
+ )
106
+ for x, y in pairs:
107
+ if x and y:
108
+ saw_shared = True
109
+ if x == y:
110
+ return "match"
111
+ return "conflict" if saw_shared else "absent"
112
+
113
+
114
+ _PAGE_RANGE_RE = re.compile(r"(\d+)\s*[-–—]+\s*(\d+)")
115
+
116
+
117
+ def _page_range(pages: str) -> tuple[int, int] | None:
118
+ m = _PAGE_RANGE_RE.search(pages or "")
119
+ if not m:
120
+ return None
121
+ lo, hi = int(m.group(1)), int(m.group(2))
122
+ return (lo, hi) if lo <= hi else (hi, lo)
123
+
124
+
125
+ def _pages_conflict(pages_a: str, venue_a: str, pages_b: str, venue_b: str) -> bool:
126
+ ra, rb = _page_range(pages_a), _page_range(pages_b)
127
+ if not ra or not rb:
128
+ return False
129
+ same_venue = title_ratio(venue_a, venue_b) > 0.8 if venue_a and venue_b else True
130
+ if not same_venue:
131
+ return False
132
+ return rb[1] < ra[0] or ra[1] < rb[0] # disjoint
133
+
134
+
135
+ def pages_conflict(entry: BibEntry, record: SourceRecord) -> bool:
136
+ """Disjoint page ranges in the same venue (laughlin consecutive pages, T2c)."""
137
+ return _pages_conflict(entry.pages, entry.venue, record.pages, record.venue)
138
+
139
+
140
+ def pages_conflict_records(a: SourceRecord, b: SourceRecord) -> bool:
141
+ """Record-vs-record page conflict (used by SAME-OBJECT clustering)."""
142
+ return _pages_conflict(a.pages, a.venue, b.pages, b.venue)
143
+
144
+
145
+ def venue_compatible(entry_venue: str, record_venue: str) -> float:
146
+ if not entry_venue or not record_venue:
147
+ return 1.0 # missing venue never penalizes
148
+ return 0.9 + 0.1 * (fuzz.partial_ratio(_norm_title(entry_venue), _norm_title(record_venue)) / 100.0)
149
+
150
+
151
+ # A "venue" that is really a preprint server, an institutional repository or an aggregator. A record
152
+ # with such a venue is a preprint/repository copy: it cannot confirm (or refute) the journal or
153
+ # conference an entry cites, and pooling must not let it stand in for the published venue. DBLP files
154
+ # arXiv preprints under the venue "CoRR".
155
+ _REPOSITORY_VENUE_RE = re.compile(
156
+ r"arxiv|\bcorr\b|bio\s*rxiv|med\s*rxiv|chem\s*rxiv|preprint|repositor|researchgate|\bssrn\b|"
157
+ r"zenodo|figshare|\bosf\b|hal[-\s]|scholarworks|dspace|eprints|infoscience|research\s+square|"
158
+ r"technical reports server|\bscholar \(|\(.*\buniversit",
159
+ re.IGNORECASE,
160
+ )
161
+
162
+
163
+ def is_repository_venue(venue: str | None) -> bool:
164
+ """Whether `venue` names a preprint server or repository rather than a journal or conference."""
165
+ return bool(venue and _REPOSITORY_VENUE_RE.search(venue))
166
+
167
+
168
+ def compute_features(entry: BibEntry, record: SourceRecord, *, tail_threshold: float) -> FeatureVector:
169
+ t_ratio = title_ratio(entry.title, record.title)
170
+ a_overlap = author_overlap(entry.authors, record.authors)
171
+ yf = year_factor(entry.year, record.year)
172
+ vc = venue_compatible(entry.venue, record.venue)
173
+ composite = t_ratio * (0.5 + 0.5 * a_overlap) * yf * vc
174
+ return FeatureVector(
175
+ title_ratio=t_ratio,
176
+ title_prefix_trap=title_prefix_trap(entry.title, record.title, tail_threshold=tail_threshold),
177
+ author_overlap=a_overlap,
178
+ author_set_jaccard=author_set_jaccard(entry.authors, record.authors),
179
+ author_subset=author_subset(entry.authors, record.authors),
180
+ year_factor=yf,
181
+ venue_compatible=vc,
182
+ id_agreement=id_agreement(entry.ids, record.ids),
183
+ pages_conflict=pages_conflict(entry, record),
184
+ composite=composite,
185
+ )
@@ -0,0 +1,238 @@
1
+ """Author-name matching (variant- and transliteration-aware).
2
+
3
+ Distilled from `sciwrite-lint/api.py:_name_variants/_author_overlap`. Names may be "Last, First",
4
+ "First Last", initials, or transliterated (anyascii) — we compare on normalized last names with a
5
+ fuzzy fallback, so "Vanchurin, Vitaly" ≈ "Vitaly Vanchurin" and "Müller" ≈ "Mueller".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from anyascii import anyascii
13
+ from rapidfuzz import fuzz
14
+
15
+ from reference_audit.parsing.entities import decode_html_entities
16
+
17
+
18
+ def _norm(text: str) -> str:
19
+ # Decode HTML references first: "d&apos;Amore" must compare as "d'Amore", not "dapos amore".
20
+ text = anyascii(decode_html_entities(text or ""))
21
+ return re.sub(r"[^a-z\s,.-]", "", text.lower()).strip()
22
+
23
+
24
+ # The BibTeX `and others` convention (and a written-out "et al.") is a *truncation marker*, not a
25
+ # real author. Left in, the phantom surname "others" both drags `author_overlap` down and breaks the
26
+ # subset check (an intentionally abbreviated list is no longer ⊆ the full author list), which then
27
+ # trips the distinct-author-set veto and forces needless adjudication. Drop it everywhere.
28
+ _ETAL_MARKERS = {"others", "et al", "and others"}
29
+
30
+
31
+ def _is_etal(name: str) -> bool:
32
+ return _norm(name).replace(".", "").strip() in _ETAL_MARKERS
33
+
34
+
35
+ def _named(authors: list[str]) -> list[str]:
36
+ """Author list with any et-al./`others` truncation marker removed."""
37
+ return [a for a in authors if not _is_etal(a)]
38
+
39
+
40
+ def last_name(name: str) -> str:
41
+ """Best-effort surname extraction handling 'Last, First' and 'First Last'."""
42
+ n = _norm(name)
43
+ if not n:
44
+ return ""
45
+ if "," in n:
46
+ return n.split(",", 1)[0].strip()
47
+ tokens = [t for t in n.replace(".", " ").split() if t]
48
+ return tokens[-1] if tokens else ""
49
+
50
+
51
+ def author_set(authors: list[str]) -> set[str]:
52
+ """Set of normalized surnames (for set-level Jaccard / subset checks)."""
53
+ return {ln for a in _named(authors) if (ln := last_name(a))}
54
+
55
+
56
+ def author_overlap(query_authors: list[str], item_authors: list[str]) -> float:
57
+ """Average best per-query-author surname match in [0, 1]."""
58
+ query_authors, item_authors = _named(query_authors), _named(item_authors)
59
+ if not query_authors or not item_authors:
60
+ return 0.0
61
+ item_last = [last_name(a) for a in item_authors]
62
+ item_last = [ln for ln in item_last if ln]
63
+ if not item_last:
64
+ return 0.0
65
+ scores: list[float] = []
66
+ for qa in query_authors:
67
+ ql = last_name(qa)
68
+ if not ql:
69
+ continue
70
+ best = max((fuzz.ratio(ql, il) / 100.0 for il in item_last), default=0.0)
71
+ scores.append(best)
72
+ return sum(scores) / len(scores) if scores else 0.0
73
+
74
+
75
+ def author_set_jaccard(query_authors: list[str], item_authors: list[str]) -> float:
76
+ a, b = author_set(query_authors), author_set(item_authors)
77
+ if not a or not b:
78
+ return 0.0
79
+ return len(a & b) / len(a | b)
80
+
81
+
82
+ def author_subset(query_authors: list[str], item_authors: list[str]) -> bool:
83
+ """True if one author set is contained in the other (beyond spelling) — laughlin asymmetry."""
84
+ a, b = author_set(query_authors), author_set(item_authors)
85
+ if not a or not b:
86
+ return False
87
+ return a <= b or b <= a
88
+
89
+
90
+ # ── person-level matching (the author check) ─────────────────────────────────
91
+ #
92
+ # The surname-only fuzzy match above suits scoring, where an average over the list absorbs one bad
93
+ # name. The author check names individual people, so it compares whole names, order-free: databases
94
+ # disagree on name order ('Li Tian' / 'Tian Li', OpenAlex's 'Moriano Pablo') and on how much of a
95
+ # compound surname they keep ('Lierni Sestorain' / 'Sestorain Saralegui, Lierni', 'Carlos Riquelme
96
+ # Ruiz' / 'Carlos Riquelme'). A surname-only fuzzy match also lets a fabricated author through on a
97
+ # near-namesake ('Carreira' ≈ 'Barreira' at 0.875).
98
+
99
+ _NAME_SPLIT_RE = re.compile(r"[\s,.\-‐]+")
100
+ _UMLAUT_RE = re.compile(r"([aou])e")
101
+
102
+ # Forms of one given name: a diminutive ('Tim Mann' / 'Timothy A. Mann'), and a few spellings and
103
+ # transliterations ('Aleksandar' / 'Alexander'). A prefix or fuzzy rule cannot stand in for this
104
+ # list: it would also equate surnames such as Chen / Cheng or Li / Liang.
105
+ _GIVEN_NAME_FORMS: tuple[frozenset[str], ...] = tuple(frozenset(g) for g in (
106
+ ("timothy", "tim"), ("thomas", "tom"), ("daniel", "dan", "danny"), ("david", "dave"),
107
+ ("michael", "mike"), ("william", "will", "bill", "liam"), ("robert", "rob", "bob", "bobby"),
108
+ ("alexander", "alex", "aleksandar", "aleksander", "alexandre"), ("christopher", "chris"),
109
+ ("benjamin", "ben"), ("samuel", "sam"), ("nicholas", "nick", "nicolas", "nikolas"),
110
+ ("matthew", "matt"), ("james", "jim", "jamie"), ("joseph", "joe"),
111
+ ("stephen", "steve", "steven"), ("edward", "ed", "eddie"), ("andrew", "andy"),
112
+ ("anthony", "tony"), ("elizabeth", "liz", "beth"), ("katherine", "kate", "katie", "catherine"),
113
+ ("jennifer", "jen", "jenny"), ("gregory", "greg"), ("jeffrey", "jeff"), ("geoffrey", "geoff"),
114
+ ("peter", "pete"), ("philip", "phil", "phillip"), ("nathan", "nate", "nathaniel"),
115
+ ("zachary", "zach"), ("joshua", "josh"), ("jonathan", "jon"), ("kenneth", "ken"),
116
+ ("ronald", "ron"), ("donald", "don"), ("frederick", "fred"), ("richard", "rich", "rick"),
117
+ ("charles", "charlie"), ("lawrence", "larry"), ("patrick", "pat"), ("susan", "sue"),
118
+ ("margaret", "maggie", "meg"), ("rebecca", "becky"),
119
+ ))
120
+
121
+
122
+ def _name_tokens(name: str, *, join_hyphens: bool = False) -> tuple[list[str], list[str]]:
123
+ """(full tokens, initials) of a name, lower-cased and transliterated; order is not kept.
124
+
125
+ A hyphen splits by default ('Gontijo-Lopes' / 'Gontijo Lopes'); `join_hyphens` closes it instead,
126
+ for given names a source writes without one ('Guo-Sen' / 'Guosen').
127
+ """
128
+ text = _norm(name)
129
+ if join_hyphens:
130
+ text = re.sub(r"(?<=[a-z])[-‐](?=[a-z])", "", text)
131
+ tokens = [t for t in _NAME_SPLIT_RE.split(text) if t]
132
+ return [t for t in tokens if len(t) > 1], [t for t in tokens if len(t) == 1]
133
+
134
+
135
+ def _token_match(a: str, b: str) -> bool:
136
+ """Same name token, allowing umlaut transliteration ('mueller' / 'muller'), a listed form of a
137
+ given name ('tim' / 'timothy') and a one-letter slip in a long token, but not a near-namesake
138
+ ('carreira' / 'barreira')."""
139
+ if a == b or _UMLAUT_RE.sub(r"\1", a) == _UMLAUT_RE.sub(r"\1", b):
140
+ return True
141
+ if any(a in forms and b in forms for forms in _GIVEN_NAME_FORMS):
142
+ return True
143
+ return min(len(a), len(b)) >= 5 and fuzz.ratio(a, b) >= 90
144
+
145
+
146
+ def same_person(a: str, b: str) -> bool:
147
+ """Whether two author strings can name the same person.
148
+
149
+ Every full token of the shorter name must appear in the other (in any order); the longer name may
150
+ carry extra tokens (a second surname, a middle name). Each initial of the shorter name must start
151
+ some token of the other ('D.P. Woodruff' / 'David P. Woodruff', but not 'J. Smith' / 'Adam Smith').
152
+ """
153
+ return any(
154
+ _tokens_agree(_name_tokens(a, join_hyphens=j), _name_tokens(b, join_hyphens=j))
155
+ for j in (False, True)
156
+ )
157
+
158
+
159
+ def _tokens_agree(a: tuple[list[str], list[str]], b: tuple[list[str], list[str]]) -> bool:
160
+ if not (a[0] or a[1]) or not (b[0] or b[1]):
161
+ return False
162
+ # An initials-only name ('A. B.') sorts first and agrees when its initials start the other's.
163
+ (short_full, short_init), (long_full, long_init) = sorted((a, b), key=lambda t: len(t[0]))
164
+ long_tokens = long_full + long_init
165
+ matched: set[str] = set()
166
+ for tok in short_full:
167
+ hit = next((other for other in long_full if _token_match(tok, other)), None)
168
+ if hit is not None:
169
+ matched.add(hit)
170
+ # a given name may be an initial on the other side ('David' / 'D.')
171
+ elif not (tok[0] in long_init and len(short_full) > 1):
172
+ return False
173
+ # An initial must fit the other name ('J.' does not fit 'Adam Smith'), unless every name of the
174
+ # other is already accounted for: then it is a dropped middle initial ('Timothy A. Mann' /
175
+ # 'Tim Mann').
176
+ unaccounted = [t for t in long_full if t not in matched]
177
+ return all(
178
+ any(t.startswith(i) for t in long_tokens) or not unaccounted for i in short_init
179
+ )
180
+
181
+
182
+ def check_cited_authors(
183
+ bib_authors: list[str], record_author_lists: list[list[str]]
184
+ ) -> tuple[list[str], list[str]]:
185
+ """(missing, unchecked): cited authors who appear in none of the matched work's records, and
186
+ cited authors no record reaches far enough to check.
187
+
188
+ Every source record of the work is consulted, so one source's defect (S2's 'Wenhan Wang' for
189
+ Wenhan Yang, OpenAlex's surname-less 'Ed H.') does not make a real author look fabricated.
190
+
191
+ A record may be truncated (OpenAlex stops at 100 authors). When every record is shorter than the
192
+ cited list and the longest one is, in order, its leading part, the cited authors past its end are
193
+ `unchecked`: the record may have been cut there. When the record and the cited list disagree, the
194
+ list was not truncated but replaced, and every cited author is checked.
195
+ """
196
+ bib = _named(bib_authors)
197
+ lists = [named for lst in record_author_lists if (named := _named(lst))]
198
+ if not bib or not lists:
199
+ return [], []
200
+ pool = [name for lst in lists for name in lst]
201
+ missing = [i for i, a in enumerate(bib) if not any(same_person(a, other) for other in pool)]
202
+ longest = max(lists, key=len)
203
+ k = len(longest)
204
+ if missing and k < len(bib) and all(same_person(x, y) for x, y in zip(longest, bib)):
205
+ return [bib[i] for i in missing if i < k], [bib[i] for i in missing if i >= k]
206
+ return [bib[i] for i in missing], []
207
+
208
+
209
+ def authors_missing(bib_authors: list[str], record_author_lists: list[list[str]]) -> list[str]:
210
+ """Cited authors who appear in none of the matched work's records (see `check_cited_authors`)."""
211
+ return check_cited_authors(bib_authors, record_author_lists)[0]
212
+
213
+
214
+ def omitted_authors(bib_authors: list[str], record_authors: list[str]) -> list[str]:
215
+ """Authors of the record that the cited list leaves out, when it does not say it is shortened.
216
+
217
+ An `and others` / `et al.` in the cited list is an explicit truncation, so nothing is omitted.
218
+ A cited list as long as the record's omits nobody: a record name it does not match is spelled
219
+ differently ('Brandon RichardWebster', 'Misha Belkin', a record's typo), not left out. So a list
220
+ is partial only when it names fewer authors than the record.
221
+ """
222
+ if any(_is_etal(a) for a in bib_authors):
223
+ return []
224
+ bib, record = _named(bib_authors), _named(record_authors)
225
+ if not bib or len(bib) >= len(record):
226
+ return []
227
+ return [r for r in record if not any(same_person(r, b) for b in bib)]
228
+
229
+
230
+ def authors_compatible(a: list[str], b: list[str], threshold: float = 0.8) -> bool:
231
+ """Whether two records' author lists can belong to one work: at least `threshold` of the
232
+ shorter list are people on the longer one. Unknown (an empty list) counts as compatible."""
233
+ a, b = _named(a), _named(b)
234
+ if not a or not b:
235
+ return True
236
+ short, long_ = sorted((a, b), key=len)
237
+ found = sum(1 for x in short if any(same_person(x, y) for y in long_))
238
+ return found / len(short) >= threshold