forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_hybrid.py
More file actions
150 lines (127 loc) · 5.75 KB
/
Copy pathtest_hybrid.py
File metadata and controls
150 lines (127 loc) · 5.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""Hybrid recommender: collaborative co-occurrence, embeddings, aperture, DNF."""
from __future__ import annotations
from ingest.models import (
Author,
Book,
ReadingState,
ReadingStatus,
Source,
SourceKind,
ThemeTag,
)
from recommender.collaborative import cooccurrence_anchors
from recommender.embeddings import HashingEmbedder, book_text, cosine, taste_vector
from recommender.hybrid import recommend_hybrid
from recommender.model import build_taste_profile
from recommender.rerank import aperture_boost, novelty_themes
def test_cooccurrence_anchors_demo(states: list, candidates: tuple, lists: tuple) -> None:
books = tuple(c.book for c in candidates)
anchors = cooccurrence_anchors(states, books, lists)
# "The Fifth Season" shares "Speculative Feminist Classics" with Dawn (Butler),
# and the reader finished Octavia E. Butler.
fifth = anchors.get("ol:fifth-season", ())
assert any(a.author == "Octavia E. Butler" for a in fifth)
def test_hybrid_recommends_and_explains(states: list, candidates: tuple, lists: tuple) -> None:
books = tuple(c.book for c in candidates)
recs = recommend_hybrid(states, books, lists=lists, k=10)
assert recs
for r in recs:
assert r.explanation.signals
assert r.explanation.sources
# On-canon picks still beat the popular distractors.
ranked = [r.book.book_id for r in recs]
distractors = {"ol:thriller", "ol:memoir", "ol:fantasy-doorstop"}
present = [ranked.index(b) for b in distractors if b in ranked]
assert min(ranked.index(b) for b in ("ol:nevada", "ol:fifth-season") if b in ranked) < (
min(present) if present else 999
)
def test_hybrid_collaborative_signal_present(states: list, candidates: tuple, lists: tuple) -> None:
books = tuple(c.book for c in candidates)
recs = recommend_hybrid(states, books, lists=lists, k=10)
fifth = next((r for r in recs if r.book.book_id == "ol:fifth-season"), None)
assert fifth is not None
kinds = {s.kind for s in fifth.explanation.signals}
assert "collaborative" in kinds
def test_hybrid_is_deterministic(states: list, candidates: tuple, lists: tuple) -> None:
books = tuple(c.book for c in candidates)
a = [r.book.book_id for r in recommend_hybrid(states, books, lists=lists, k=10)]
b = [r.book.book_id for r in recommend_hybrid(states, books, lists=lists, k=10)]
assert a == b
def test_aperture_is_boost_only(states: list, candidates: tuple, lists: tuple) -> None:
books = tuple(c.book for c in candidates)
base = {r.book.book_id: r.score for r in recommend_hybrid(states, books, lists=lists, k=20)}
widened = {
r.book.book_id: r.score
for r in recommend_hybrid(states, books, lists=lists, k=20, aperture_strength=1.0)
}
# Every candidate that survived both runs scores >= its non-aperture score.
for bid in base.keys() & widened.keys():
assert widened[bid] >= base[bid] - 1e-9
def test_novelty_and_aperture_boost() -> None:
src = Source(SourceKind.CALIBRE_TAG, "calibre:local", "2026-06-05", "trans")
finished = ReadingState(
title="Owned",
authors=("Me",),
status=ReadingStatus.FINISHED,
book=Book(
book_id="own",
title="Owned",
authors=(Author("Me"),),
theme_tags=(ThemeTag("trans", src),),
),
)
taste = build_taste_profile([finished])
osrc = Source(
SourceKind.OPENLIBRARY_SUBJECT, "https://openlibrary.org/x", "2026-06-05", "solarpunk"
)
cand = Book(book_id="c", title="New", theme_tags=(ThemeTag("solarpunk", osrc),))
assert novelty_themes(taste, cand) == ("solarpunk",)
boost, themes = aperture_boost(taste, cand, 1.0)
assert boost > 0 and themes == ("solarpunk",)
assert aperture_boost(taste, cand, 0.0) == (0.0, ())
def test_dnf_signals_downweight(states: list) -> None:
base = build_taste_profile(states)
dnf = build_taste_profile(states, dnf_signals=True)
# Stone Butch Blues is ~47% read (not a DNF), so the soft-DNF rule doesn't
# fire here; profiles match. (Behaviour asserted directly below.)
assert base.theme_weights.keys() == dnf.theme_weights.keys()
def test_dnf_downweights_stalled_book() -> None:
src = Source(SourceKind.CALIBRE_TAG, "calibre:local", "2026-06-05", "horror")
from ingest.models import ReadingStat
stalled = ReadingState(
title="Bounced",
authors=("X",),
status=ReadingStatus.READING,
book=Book(
book_id="b",
title="Bounced",
authors=(Author("X"),),
theme_tags=(ThemeTag("horror", src),),
),
stat=ReadingStat("k", "Bounced", ("X",), 5, 100, 60, 1, 1),
)
on = build_taste_profile([stalled], dnf_signals=True)
assert on.theme_weights["horror"] < 0 # gentle negative
off = build_taste_profile([stalled], dnf_signals=False)
assert off.theme_weights["horror"] > 0
def test_embeddings_local_similarity() -> None:
emb = HashingEmbedder()
a = Book(book_id="a", title="trans speculative novel")
b = Book(book_id="b", title="trans speculative story")
c = Book(book_id="c", title="corporate finance handbook")
va, vb, vc = (emb.embed(book_text(x)) for x in (a, b, c))
assert cosine(va, vb) > cosine(va, vc)
assert taste_vector([], emb) == []
def test_embeddings_change_nothing_when_off_vs_deterministic_when_on(
states: list, candidates: tuple, lists: tuple
) -> None:
books = tuple(c.book for c in candidates)
on1 = [
r.book.book_id
for r in recommend_hybrid(states, books, lists=lists, k=10, use_embeddings=True)
]
on2 = [
r.book.book_id
for r in recommend_hybrid(states, books, lists=lists, k=10, use_embeddings=True)
]
assert on1 == on2 # deterministic with embeddings on