Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app/config.py +24 -2
- app/gapmap.py +208 -57
- app/gapmap_service.py +404 -62
- app/main.py +64 -0
- app/repository.py +110 -15
app/config.py
CHANGED
|
@@ -47,14 +47,36 @@ class Settings(BaseSettings):
|
|
| 47 |
europepmc_url: str = "https://www.ebi.ac.uk/europepmc/webservices/rest"
|
| 48 |
fulltext_timeout: float = 20.0
|
| 49 |
fulltext_max_lines: int = Field(default=160, ge=20, le=600)
|
| 50 |
-
# Gap map:
|
| 51 |
# The region count is a resolution, and what counts as an empty band depends on
|
| 52 |
# it: measured on 6000 documents of the live index, 24 regions leave the corpus
|
| 53 |
# continuous (no band under the occupancy threshold) while 80 keep a median of
|
| 54 |
# 64 primary attempts per region and still expose bands.
|
| 55 |
-
gapmap_sample: int = Field(default=6000, ge=100, le=10000)
|
| 56 |
gapmap_regions: int = Field(default=80, ge=2, le=200)
|
| 57 |
gapmap_seed: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
max_concurrent_searches: int = 4
|
| 59 |
frontend_dist: str = str(Path(__file__).resolve().parents[2] / "dist")
|
| 60 |
cors_origins: list[str] = ["http://localhost:5173"]
|
|
|
|
| 47 |
europepmc_url: str = "https://www.ebi.ac.uk/europepmc/webservices/rest"
|
| 48 |
fulltext_timeout: float = 20.0
|
| 49 |
fulltext_max_lines: int = Field(default=160, ge=20, le=600)
|
| 50 |
+
# Gap map: every embedded study, scanned in pages and clustered once.
|
| 51 |
# The region count is a resolution, and what counts as an empty band depends on
|
| 52 |
# it: measured on 6000 documents of the live index, 24 regions leave the corpus
|
| 53 |
# continuous (no band under the occupancy threshold) while 80 keep a median of
|
| 54 |
# 64 primary attempts per region and still expose bands.
|
|
|
|
| 55 |
gapmap_regions: int = Field(default=80, ge=2, le=200)
|
| 56 |
gapmap_seed: int = 0
|
| 57 |
+
# A cap on how much of the embedded corpus one build reads; 0 is no cap, and
|
| 58 |
+
# the coverage line and the warning always report what was actually read.
|
| 59 |
+
# Measured on the live index (2,078,515 embedded studies, 384 dimensions):
|
| 60 |
+
# the scan runs at ~11k documents/s and the vectors weigh ~3.0 GiB, so an
|
| 61 |
+
# uncapped build is ~3 minutes and needs a host with several GB to spare.
|
| 62 |
+
gapmap_scan_limit: int = Field(default=0, ge=0)
|
| 63 |
+
# Page size and concurrent point-in-time slices for that scan. Sixteen slices
|
| 64 |
+
# measured ~11k docs/s against ~3.2k for a single unsliced walk.
|
| 65 |
+
gapmap_batch: int = Field(default=2000, ge=100, le=10000)
|
| 66 |
+
gapmap_slices: int = Field(default=16, ge=1, le=64)
|
| 67 |
+
# How many of the clustered studies the canvas draws. Every study shapes the
|
| 68 |
+
# regions; a browser cannot paint two million marks, so the drawn subset is
|
| 69 |
+
# thinned by a hash of the document id.
|
| 70 |
+
gapmap_points: int = Field(default=6000, ge=100, le=50000)
|
| 71 |
+
# Rows the 2-D projection is fitted on. The basis is a drawing choice, and a
|
| 72 |
+
# full-corpus SVD would stall the build for minutes to move points by pixels.
|
| 73 |
+
gapmap_projection_sample: int = Field(default=50000, ge=1000, le=500000)
|
| 74 |
+
# Bound on the full-corpus k-means. Measured at 200k documents and 80
|
| 75 |
+
# regions: 10 iterations ~3.2 s, 40 ~7.7 s; the assignment usually settles
|
| 76 |
+
# first and the loop stops when it does.
|
| 77 |
+
gapmap_iterations: int = Field(default=25, ge=1, le=200)
|
| 78 |
+
# Minimum seconds between two streamed frames while the corpus is scanning.
|
| 79 |
+
gapmap_tick_seconds: float = Field(default=0.4, ge=0.0, le=10.0)
|
| 80 |
max_concurrent_searches: int = 4
|
| 81 |
frontend_dist: str = str(Path(__file__).resolve().parents[2] / "dist")
|
| 82 |
cors_origins: list[str] = ["http://localhost:5173"]
|
app/gapmap.py
CHANGED
|
@@ -33,6 +33,9 @@ MAP_VERSION = "gapmap-v1"
|
|
| 33 |
# A region needs this many primary studies before its verdict mix means anything;
|
| 34 |
# below it the honest label is "thin", not a finding about the science.
|
| 35 |
MIN_REGION_ATTEMPTS = 5
|
|
|
|
|
|
|
|
|
|
| 36 |
# Share of attempts that must be null (or unreported) before the region is called
|
| 37 |
# saturated (or dark). Both are deliberately majority-ish rather than "any".
|
| 38 |
NULL_SATURATED_SHARE = 0.5
|
|
@@ -75,13 +78,8 @@ def normalize(vectors: np.ndarray) -> np.ndarray:
|
|
| 75 |
return vectors / np.clip(norms, 1e-8, None)
|
| 76 |
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
"""
|
| 80 |
-
|
| 81 |
-
Deterministic for a given seed so a map can be rebuilt and compared. Written
|
| 82 |
-
out here rather than pulled from scikit-learn because the ingest extra that
|
| 83 |
-
provides it is optional and the serving path must not depend on it.
|
| 84 |
-
"""
|
| 85 |
rng = np.random.default_rng(seed)
|
| 86 |
count = len(vectors)
|
| 87 |
k = max(1, min(k, count))
|
|
@@ -93,20 +91,123 @@ def kmeans(vectors: np.ndarray, k: int, seed: int = 0, iterations: int = 40) ->
|
|
| 93 |
total = distance.sum()
|
| 94 |
pick = rng.integers(count) if total <= 0 else rng.choice(count, p=distance / total)
|
| 95 |
centroids[index] = vectors[pick]
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
labels = assigned
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
return labels
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def fit_projection(vectors: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 111 |
"""A deterministic 2-D basis for drawing the map: PCA over centered vectors.
|
| 112 |
|
|
@@ -207,51 +308,88 @@ def _exemplars(documents: list[dict], limit: int = 3) -> list[dict]:
|
|
| 207 |
]
|
| 208 |
|
| 209 |
|
| 210 |
-
def
|
| 211 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""
|
| 217 |
-
embedded = [(doc, vector) for doc in documents if (vector := _vector(doc)) is not None]
|
| 218 |
-
skipped = len(documents) - len(embedded)
|
| 219 |
-
if not embedded:
|
| 220 |
-
return {"version": MAP_VERSION, "regions": [], "documents": 0, "skipped": skipped}
|
| 221 |
-
corpus = [doc for doc, _ in embedded]
|
| 222 |
-
vectors = normalize(np.vstack([vector for _, vector in embedded]))
|
| 223 |
-
labels = kmeans(vectors, regions, seed=seed)
|
| 224 |
described = []
|
| 225 |
-
for index in
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
| 229 |
counts = Counter(_bucket(doc) for doc in primary)
|
| 230 |
-
years = [doc["year"] for doc in
|
| 231 |
citations = [doc.get("cited_by_count") or 0 for doc in primary]
|
| 232 |
described.append(
|
| 233 |
{
|
| 234 |
"id": int(index),
|
| 235 |
-
"size": len(
|
| 236 |
"attempts": len(primary),
|
| 237 |
"centroid": [float(value) for value in centroid],
|
| 238 |
"label": label_region(counts, len(primary)),
|
| 239 |
"bucketCounts": dict(counts),
|
| 240 |
"medianYear": int(np.median(years)) if years else None,
|
| 241 |
"medianCitations": float(np.median(citations)) if citations else None,
|
| 242 |
-
"coherence": float((
|
| 243 |
-
"exemplars": _exemplars(
|
| 244 |
}
|
| 245 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
return {
|
| 247 |
"version": MAP_VERSION,
|
| 248 |
-
"regions":
|
| 249 |
-
"documents": len(
|
| 250 |
"skipped": skipped,
|
| 251 |
}
|
| 252 |
|
| 253 |
|
| 254 |
-
def _corpus(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
embedded = [(doc, vector) for doc in documents if (vector := _vector(doc)) is not None]
|
| 256 |
if not embedded:
|
| 257 |
return [], np.zeros((0, 0), dtype=np.float32)
|
|
@@ -270,7 +408,11 @@ def nearest_neighbors(
|
|
| 270 |
real papers near a point is the legible form of "how occupied is this spot".
|
| 271 |
"""
|
| 272 |
similarity = vectors @ vector
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
return [
|
| 275 |
{
|
| 276 |
"id": documents[index].get("id", ""),
|
|
@@ -302,6 +444,7 @@ def find_gaps(
|
|
| 302 |
limit: int = 10,
|
| 303 |
neighbour_cosine: float | None = None,
|
| 304 |
occupancy: float = GAP_OCCUPANCY,
|
|
|
|
| 305 |
) -> list[dict]:
|
| 306 |
"""Bands between neighbouring regions that the indexed corpus barely occupies.
|
| 307 |
|
|
@@ -322,13 +465,13 @@ def find_gaps(
|
|
| 322 |
The parent labels carry the warning that matters — an open band between two
|
| 323 |
null-saturated regions is unexplored *because the neighbours failed*.
|
| 324 |
"""
|
| 325 |
-
corpus, vectors = _corpus(documents)
|
| 326 |
if not corpus or len(regions) < 2:
|
| 327 |
return []
|
| 328 |
centroids = normalize(np.vstack([region["centroid"] for region in regions]).astype(np.float32))
|
| 329 |
if neighbour_cosine is None:
|
| 330 |
neighbour_cosine = neighbour_threshold(centroids)
|
| 331 |
-
assigned = (vectors
|
| 332 |
gaps = []
|
| 333 |
for left in range(len(regions)):
|
| 334 |
for right in range(left + 1, len(regions)):
|
|
@@ -340,14 +483,10 @@ def find_gaps(
|
|
| 340 |
others = np.delete(to_midpoint, [left, right])
|
| 341 |
if len(others) and others.max() >= min(to_midpoint[left], to_midpoint[right]):
|
| 342 |
continue # a third literature already sits in the band
|
| 343 |
-
members = (assigned == left) | (assigned == right)
|
| 344 |
-
owner =
|
| 345 |
-
[
|
| 346 |
-
|
| 347 |
-
vectors[members] @ centroids[right],
|
| 348 |
-
vectors[members] @ midpoint,
|
| 349 |
-
]
|
| 350 |
-
).argmax(axis=0)
|
| 351 |
support = min(int((owner == 0).sum()), int((owner == 1).sum()))
|
| 352 |
if not support:
|
| 353 |
continue
|
|
@@ -380,6 +519,7 @@ def place(
|
|
| 380 |
regions: list[dict],
|
| 381 |
gaps: list[dict] | None = None,
|
| 382 |
nearest_limit: int = 5,
|
|
|
|
| 383 |
) -> dict:
|
| 384 |
"""Locate an idea on the map: how redundant it is and what surrounds it.
|
| 385 |
|
|
@@ -388,7 +528,7 @@ def place(
|
|
| 388 |
published, and the nearest papers are shown so the claim can be checked by
|
| 389 |
reading them.
|
| 390 |
"""
|
| 391 |
-
corpus, vectors = _corpus(documents)
|
| 392 |
query = normalize(np.asarray(vector, dtype=np.float32)[None, :])[0]
|
| 393 |
if not corpus:
|
| 394 |
return {
|
|
@@ -442,6 +582,7 @@ def calibrate(
|
|
| 442 |
cutoff_year: int,
|
| 443 |
regions: int = 24,
|
| 444 |
seed: int = 0,
|
|
|
|
| 445 |
) -> dict:
|
| 446 |
"""Rebuild the map as it would have looked at ``cutoff_year`` and score it since.
|
| 447 |
|
|
@@ -453,11 +594,21 @@ def calibrate(
|
|
| 453 |
study: a region can be mislabelled and still look right if later authors
|
| 454 |
simply copied the earlier design.
|
| 455 |
"""
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
]
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
if not built["regions"] or not future:
|
| 462 |
return {
|
| 463 |
"version": MAP_VERSION,
|
|
@@ -470,7 +621,7 @@ def calibrate(
|
|
| 470 |
np.vstack([region["centroid"] for region in built["regions"]]).astype(np.float32)
|
| 471 |
)
|
| 472 |
later: dict[int, list[dict]] = {region["id"]: [] for region in built["regions"]}
|
| 473 |
-
corpus, vectors = _corpus(future)
|
| 474 |
for document, vector in zip(corpus, vectors):
|
| 475 |
later[built["regions"][int((centroids @ vector).argmax())]["id"]].append(document)
|
| 476 |
rows: dict[str, dict] = {}
|
|
|
|
| 33 |
# A region needs this many primary studies before its verdict mix means anything;
|
| 34 |
# below it the honest label is "thin", not a finding about the science.
|
| 35 |
MIN_REGION_ATTEMPTS = 5
|
| 36 |
+
# Rows per chunk when multiplying the corpus against the centroids. Bounds the
|
| 37 |
+
# intermediate to a few tens of megabytes whatever the corpus size.
|
| 38 |
+
ASSIGN_CHUNK = 50_000
|
| 39 |
# Share of attempts that must be null (or unreported) before the region is called
|
| 40 |
# saturated (or dark). Both are deliberately majority-ish rather than "any".
|
| 41 |
NULL_SATURATED_SHARE = 0.5
|
|
|
|
| 78 |
return vectors / np.clip(norms, 1e-8, None)
|
| 79 |
|
| 80 |
|
| 81 |
+
def seed_centroids(vectors: np.ndarray, k: int, seed: int = 0) -> np.ndarray:
|
| 82 |
+
"""k-means++ seeding over a cosine space, deterministic for a given seed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
rng = np.random.default_rng(seed)
|
| 84 |
count = len(vectors)
|
| 85 |
k = max(1, min(k, count))
|
|
|
|
| 91 |
total = distance.sum()
|
| 92 |
pick = rng.integers(count) if total <= 0 else rng.choice(count, p=distance / total)
|
| 93 |
centroids[index] = vectors[pick]
|
| 94 |
+
return centroids
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def assign(vectors: np.ndarray, centroids: np.ndarray, chunk: int = ASSIGN_CHUNK) -> np.ndarray:
|
| 98 |
+
"""Nearest centroid per row, in chunks so a corpus-sized matrix is never built.
|
| 99 |
+
|
| 100 |
+
``vectors @ centroids.T`` over two million rows and eighty centroids is a
|
| 101 |
+
640 MB intermediate; the chunked loop is the same arithmetic in 16 MB pieces.
|
| 102 |
+
"""
|
| 103 |
+
if not len(vectors):
|
| 104 |
+
return np.zeros(0, dtype=np.int64)
|
| 105 |
+
labels = np.empty(len(vectors), dtype=np.int64)
|
| 106 |
+
for start in range(0, len(vectors), chunk):
|
| 107 |
+
stop = start + chunk
|
| 108 |
+
labels[start:stop] = (vectors[start:stop] @ centroids.T).argmax(axis=1)
|
| 109 |
+
return labels
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _recentre(vectors: np.ndarray, labels: np.ndarray, centroids: np.ndarray) -> np.ndarray:
|
| 113 |
+
"""Move each centroid onto the mean direction of the rows assigned to it.
|
| 114 |
+
|
| 115 |
+
Empty clusters keep their previous position rather than being reseeded, so a
|
| 116 |
+
warm-started run cannot make the map jump between successive iterations.
|
| 117 |
+
"""
|
| 118 |
+
moved = centroids.copy()
|
| 119 |
+
order = np.argsort(labels, kind="stable")
|
| 120 |
+
bounds = np.searchsorted(labels[order], np.arange(len(centroids) + 1))
|
| 121 |
+
for index in range(len(centroids)):
|
| 122 |
+
members = order[bounds[index] : bounds[index + 1]]
|
| 123 |
+
if len(members):
|
| 124 |
+
moved[index] = vectors[members].mean(axis=0)
|
| 125 |
+
return normalize(moved)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def kmeans_steps(
|
| 129 |
+
vectors: np.ndarray,
|
| 130 |
+
k: int,
|
| 131 |
+
seed: int = 0,
|
| 132 |
+
iterations: int = 40,
|
| 133 |
+
centroids: np.ndarray | None = None,
|
| 134 |
+
):
|
| 135 |
+
"""Spherical k-means, yielding ``(centroids, labels)`` after every iteration.
|
| 136 |
+
|
| 137 |
+
Yielding the intermediate states is what lets a caller show the map settling
|
| 138 |
+
instead of a spinner: each yielded pair is a real assignment of the corpus,
|
| 139 |
+
not an interpolation. Deterministic for a given seed and starting position,
|
| 140 |
+
and stops early once the assignment stops changing.
|
| 141 |
+
"""
|
| 142 |
+
if not len(vectors):
|
| 143 |
+
return
|
| 144 |
+
if centroids is None:
|
| 145 |
+
centroids = seed_centroids(vectors, k, seed)
|
| 146 |
+
centroids = normalize(np.asarray(centroids, dtype=np.float32))
|
| 147 |
+
labels = np.full(len(vectors), -1, dtype=np.int64)
|
| 148 |
+
for _ in range(max(1, iterations)):
|
| 149 |
+
assigned = assign(vectors, centroids)
|
| 150 |
+
settled = np.array_equal(assigned, labels)
|
| 151 |
labels = assigned
|
| 152 |
+
centroids = _recentre(vectors, labels, centroids)
|
| 153 |
+
yield centroids, labels
|
| 154 |
+
if settled:
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def kmeans(vectors: np.ndarray, k: int, seed: int = 0, iterations: int = 40) -> np.ndarray:
|
| 159 |
+
"""Spherical k-means with k-means++ seeding, returning a label per row.
|
| 160 |
+
|
| 161 |
+
Deterministic for a given seed so a map can be rebuilt and compared. Written
|
| 162 |
+
out here rather than pulled from scikit-learn because the ingest extra that
|
| 163 |
+
provides it is optional and the serving path must not depend on it.
|
| 164 |
+
"""
|
| 165 |
+
labels = np.zeros(len(vectors), dtype=np.int64)
|
| 166 |
+
for _, labels in kmeans_steps(vectors, k, seed=seed, iterations=iterations):
|
| 167 |
+
pass
|
| 168 |
return labels
|
| 169 |
|
| 170 |
|
| 171 |
+
class RunningClusters:
|
| 172 |
+
"""Mini-batch spherical k-means: centroids that exist before the scan ends.
|
| 173 |
+
|
| 174 |
+
The full corpus cannot be clustered until it has all arrived, but a map that
|
| 175 |
+
only appears at the end cannot be watched. Each batch is assigned to the
|
| 176 |
+
current centroids and each centroid is moved towards the mean of what it
|
| 177 |
+
just received, weighted by how much it has seen — the standard mini-batch
|
| 178 |
+
update. These centroids are provisional and are replaced by full-corpus
|
| 179 |
+
k-means once the scan finishes; nothing is reported as final until then.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
def __init__(self, k: int, seed: int = 0):
|
| 183 |
+
self.k = max(1, k)
|
| 184 |
+
self.seed = seed
|
| 185 |
+
self.centroids: np.ndarray | None = None
|
| 186 |
+
self.counts: np.ndarray | None = None
|
| 187 |
+
|
| 188 |
+
def update(self, vectors: np.ndarray) -> np.ndarray | None:
|
| 189 |
+
if not len(vectors):
|
| 190 |
+
return self.centroids
|
| 191 |
+
if self.centroids is None:
|
| 192 |
+
if len(vectors) < self.k:
|
| 193 |
+
return None # too few rows to seed k distinct centroids honestly
|
| 194 |
+
self.centroids = normalize(seed_centroids(vectors, self.k, self.seed))
|
| 195 |
+
self.counts = np.zeros(len(self.centroids), dtype=np.float64)
|
| 196 |
+
labels = assign(vectors, self.centroids)
|
| 197 |
+
sums = np.zeros_like(self.centroids, dtype=np.float32)
|
| 198 |
+
np.add.at(sums, labels, vectors)
|
| 199 |
+
counts = np.bincount(labels, minlength=len(self.centroids)).astype(np.float64)
|
| 200 |
+
assert self.counts is not None
|
| 201 |
+
seen = self.counts + counts
|
| 202 |
+
rate = np.divide(counts, seen, out=np.zeros_like(seen), where=seen > 0)[:, None]
|
| 203 |
+
self.centroids = normalize(
|
| 204 |
+
self.centroids * (1 - rate)
|
| 205 |
+
+ np.divide(sums, np.clip(counts, 1, None)[:, None]) * rate
|
| 206 |
+
)
|
| 207 |
+
self.counts = seen
|
| 208 |
+
return self.centroids
|
| 209 |
+
|
| 210 |
+
|
| 211 |
def fit_projection(vectors: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 212 |
"""A deterministic 2-D basis for drawing the map: PCA over centered vectors.
|
| 213 |
|
|
|
|
| 308 |
]
|
| 309 |
|
| 310 |
|
| 311 |
+
def group(labels: np.ndarray, clusters: int) -> list[np.ndarray]:
|
| 312 |
+
"""Row indices per cluster. One sort rather than a scan per cluster: the
|
| 313 |
+
obvious ``labels == index`` loop is O(rows × clusters), which is hours of
|
| 314 |
+
Python over a corpus of millions."""
|
| 315 |
+
order = np.argsort(labels, kind="stable")
|
| 316 |
+
bounds = np.searchsorted(labels[order], np.arange(clusters + 1))
|
| 317 |
+
return [order[bounds[index] : bounds[index + 1]] for index in range(clusters)]
|
| 318 |
|
| 319 |
+
|
| 320 |
+
def describe_regions(
|
| 321 |
+
documents: list[dict], vectors: np.ndarray, labels: np.ndarray, clusters: int
|
| 322 |
+
) -> list[dict]:
|
| 323 |
+
"""Describe each occupied cluster by what happened in the documents it holds.
|
| 324 |
+
|
| 325 |
+
Cluster indices are the region ids, and an empty cluster yields no region, so
|
| 326 |
+
a region keeps its identity between two runs that share centroids — which is
|
| 327 |
+
what lets a partially built map be redrawn rather than replaced.
|
| 328 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
described = []
|
| 330 |
+
for index, members in enumerate(group(labels, clusters)):
|
| 331 |
+
if not len(members):
|
| 332 |
+
continue
|
| 333 |
+
rows = vectors[members]
|
| 334 |
+
centroid = normalize(rows.mean(axis=0, keepdims=True))[0]
|
| 335 |
+
held = [documents[row] for row in members.tolist()]
|
| 336 |
+
primary = [doc for doc in held if _is_primary(doc)]
|
| 337 |
counts = Counter(_bucket(doc) for doc in primary)
|
| 338 |
+
years = [doc["year"] for doc in held if isinstance(doc.get("year"), int)]
|
| 339 |
citations = [doc.get("cited_by_count") or 0 for doc in primary]
|
| 340 |
described.append(
|
| 341 |
{
|
| 342 |
"id": int(index),
|
| 343 |
+
"size": len(held),
|
| 344 |
"attempts": len(primary),
|
| 345 |
"centroid": [float(value) for value in centroid],
|
| 346 |
"label": label_region(counts, len(primary)),
|
| 347 |
"bucketCounts": dict(counts),
|
| 348 |
"medianYear": int(np.median(years)) if years else None,
|
| 349 |
"medianCitations": float(np.median(citations)) if citations else None,
|
| 350 |
+
"coherence": float((rows @ centroid).mean()),
|
| 351 |
+
"exemplars": _exemplars(held),
|
| 352 |
}
|
| 353 |
)
|
| 354 |
+
return described
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def build_regions(
|
| 358 |
+
documents: list[dict],
|
| 359 |
+
regions: int = 24,
|
| 360 |
+
seed: int = 0,
|
| 361 |
+
vectors: np.ndarray | None = None,
|
| 362 |
+
) -> dict:
|
| 363 |
+
"""Cluster embedded documents and describe each cluster by what happened in it.
|
| 364 |
+
|
| 365 |
+
Documents without a stored vector are counted in ``skipped`` rather than
|
| 366 |
+
dropped silently: a region map over a partly unembedded corpus describes the
|
| 367 |
+
embedded part only, and saying so is the difference between a map and a claim.
|
| 368 |
+
|
| 369 |
+
``vectors`` lets a caller that already holds the corpus as one normalized
|
| 370 |
+
matrix — the streaming build does — skip rebuilding it from the documents,
|
| 371 |
+
which would cost a second copy of several gigabytes.
|
| 372 |
+
"""
|
| 373 |
+
corpus, matrix = _corpus(documents, vectors)
|
| 374 |
+
skipped = len(documents) - len(corpus)
|
| 375 |
+
if not len(matrix):
|
| 376 |
+
return {"version": MAP_VERSION, "regions": [], "documents": 0, "skipped": skipped}
|
| 377 |
+
clusters = max(1, min(regions, len(matrix)))
|
| 378 |
+
labels = kmeans(matrix, clusters, seed=seed)
|
| 379 |
return {
|
| 380 |
"version": MAP_VERSION,
|
| 381 |
+
"regions": describe_regions(corpus, matrix, labels, clusters),
|
| 382 |
+
"documents": len(corpus),
|
| 383 |
"skipped": skipped,
|
| 384 |
}
|
| 385 |
|
| 386 |
|
| 387 |
+
def _corpus(
|
| 388 |
+
documents: list[dict], vectors: np.ndarray | None = None
|
| 389 |
+
) -> tuple[list[dict], np.ndarray]:
|
| 390 |
+
"""Documents paired with their unit vectors, from the dicts or from a matrix."""
|
| 391 |
+
if vectors is not None:
|
| 392 |
+
return documents, vectors
|
| 393 |
embedded = [(doc, vector) for doc in documents if (vector := _vector(doc)) is not None]
|
| 394 |
if not embedded:
|
| 395 |
return [], np.zeros((0, 0), dtype=np.float32)
|
|
|
|
| 408 |
real papers near a point is the legible form of "how occupied is this spot".
|
| 409 |
"""
|
| 410 |
similarity = vectors @ vector
|
| 411 |
+
limit = max(1, min(limit, len(similarity)))
|
| 412 |
+
# argpartition first: a full sort of a corpus-sized array to read five rows
|
| 413 |
+
# off the top is seconds of wasted work on the live index.
|
| 414 |
+
top = np.argpartition(-similarity, limit - 1)[:limit]
|
| 415 |
+
order = top[np.argsort(-similarity[top])]
|
| 416 |
return [
|
| 417 |
{
|
| 418 |
"id": documents[index].get("id", ""),
|
|
|
|
| 444 |
limit: int = 10,
|
| 445 |
neighbour_cosine: float | None = None,
|
| 446 |
occupancy: float = GAP_OCCUPANCY,
|
| 447 |
+
vectors: np.ndarray | None = None,
|
| 448 |
) -> list[dict]:
|
| 449 |
"""Bands between neighbouring regions that the indexed corpus barely occupies.
|
| 450 |
|
|
|
|
| 465 |
The parent labels carry the warning that matters — an open band between two
|
| 466 |
null-saturated regions is unexplored *because the neighbours failed*.
|
| 467 |
"""
|
| 468 |
+
corpus, vectors = _corpus(documents, vectors)
|
| 469 |
if not corpus or len(regions) < 2:
|
| 470 |
return []
|
| 471 |
centroids = normalize(np.vstack([region["centroid"] for region in regions]).astype(np.float32))
|
| 472 |
if neighbour_cosine is None:
|
| 473 |
neighbour_cosine = neighbour_threshold(centroids)
|
| 474 |
+
assigned = assign(vectors, centroids)
|
| 475 |
gaps = []
|
| 476 |
for left in range(len(regions)):
|
| 477 |
for right in range(left + 1, len(regions)):
|
|
|
|
| 483 |
others = np.delete(to_midpoint, [left, right])
|
| 484 |
if len(others) and others.max() >= min(to_midpoint[left], to_midpoint[right]):
|
| 485 |
continue # a third literature already sits in the band
|
| 486 |
+
members = vectors[(assigned == left) | (assigned == right)]
|
| 487 |
+
owner = (
|
| 488 |
+
members @ np.vstack([centroids[left], centroids[right], midpoint]).T
|
| 489 |
+
).argmax(axis=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
support = min(int((owner == 0).sum()), int((owner == 1).sum()))
|
| 491 |
if not support:
|
| 492 |
continue
|
|
|
|
| 519 |
regions: list[dict],
|
| 520 |
gaps: list[dict] | None = None,
|
| 521 |
nearest_limit: int = 5,
|
| 522 |
+
vectors: np.ndarray | None = None,
|
| 523 |
) -> dict:
|
| 524 |
"""Locate an idea on the map: how redundant it is and what surrounds it.
|
| 525 |
|
|
|
|
| 528 |
published, and the nearest papers are shown so the claim can be checked by
|
| 529 |
reading them.
|
| 530 |
"""
|
| 531 |
+
corpus, vectors = _corpus(documents, vectors)
|
| 532 |
query = normalize(np.asarray(vector, dtype=np.float32)[None, :])[0]
|
| 533 |
if not corpus:
|
| 534 |
return {
|
|
|
|
| 582 |
cutoff_year: int,
|
| 583 |
regions: int = 24,
|
| 584 |
seed: int = 0,
|
| 585 |
+
vectors: np.ndarray | None = None,
|
| 586 |
) -> dict:
|
| 587 |
"""Rebuild the map as it would have looked at ``cutoff_year`` and score it since.
|
| 588 |
|
|
|
|
| 594 |
study: a region can be mislabelled and still look right if later authors
|
| 595 |
simply copied the earlier design.
|
| 596 |
"""
|
| 597 |
+
corpus, matrix = _corpus(documents, vectors)
|
| 598 |
+
dated = [
|
| 599 |
+
index for index, doc in enumerate(corpus) if isinstance(doc.get("year"), int)
|
| 600 |
]
|
| 601 |
+
before = np.array(
|
| 602 |
+
[index for index in dated if corpus[index]["year"] < cutoff_year], dtype=np.int64
|
| 603 |
+
)
|
| 604 |
+
after = np.array(
|
| 605 |
+
[index for index in dated if corpus[index]["year"] >= cutoff_year], dtype=np.int64
|
| 606 |
+
)
|
| 607 |
+
past = [corpus[index] for index in before.tolist()]
|
| 608 |
+
future = [corpus[index] for index in after.tolist()]
|
| 609 |
+
built = build_regions(
|
| 610 |
+
past, regions=regions, seed=seed, vectors=matrix[before] if len(before) else None
|
| 611 |
+
)
|
| 612 |
if not built["regions"] or not future:
|
| 613 |
return {
|
| 614 |
"version": MAP_VERSION,
|
|
|
|
| 621 |
np.vstack([region["centroid"] for region in built["regions"]]).astype(np.float32)
|
| 622 |
)
|
| 623 |
later: dict[int, list[dict]] = {region["id"]: [] for region in built["regions"]}
|
| 624 |
+
corpus, vectors = _corpus(future, matrix[after] if len(after) else None)
|
| 625 |
for document, vector in zip(corpus, vectors):
|
| 626 |
later[built["regions"][int((centroids @ vector).argmax())]["id"]].append(document)
|
| 627 |
rows: dict[str, dict] = {}
|
app/gapmap_service.py
CHANGED
|
@@ -1,71 +1,130 @@
|
|
| 1 |
-
"""Serves the gap map:
|
| 2 |
|
| 3 |
-
The map is a property of the corpus, not of a query, so it is computed
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
import numpy as np
|
| 15 |
|
| 16 |
from app.config import Settings, settings
|
| 17 |
from app.embeddings import QueryEmbedder
|
| 18 |
from app.gapmap import (
|
|
|
|
|
|
|
| 19 |
_bucket,
|
| 20 |
-
|
| 21 |
-
build_regions,
|
| 22 |
calibrate,
|
| 23 |
combine,
|
|
|
|
| 24 |
find_gaps,
|
| 25 |
fit_projection,
|
|
|
|
| 26 |
normalize,
|
| 27 |
place,
|
| 28 |
project_with,
|
| 29 |
)
|
| 30 |
from app.models import MapArithmetic
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def _public(region: dict) -> dict:
|
| 34 |
"""Region without its centroid: a 384-float vector is not UI payload."""
|
| 35 |
return {key: value for key, value in region.items() if key != "centroid"}
|
| 36 |
|
| 37 |
|
| 38 |
-
def
|
| 39 |
-
"""
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
"""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
for
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
class GapMapService:
|
|
@@ -75,40 +134,272 @@ class GapMapService:
|
|
| 75 |
self.embedder = QueryEmbedder(config)
|
| 76 |
self._built: dict | None = None
|
| 77 |
self._lock = asyncio.Lock()
|
|
|
|
|
|
|
| 78 |
|
| 79 |
async def embed(self, text: str) -> list[float] | None:
|
| 80 |
return await self.embedder.embed(text)
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
async def build(self, refresh: bool = False) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
async with self._lock:
|
| 84 |
if self._built is not None and not refresh:
|
| 85 |
return self._built
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
self.config.gapmap_regions,
|
| 94 |
-
self.config.gapmap_seed,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
"coverage": {
|
| 105 |
-
"
|
| 106 |
-
"corpus":
|
| 107 |
-
"regions":
|
|
|
|
|
|
|
|
|
|
| 108 |
},
|
| 109 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
def _decorate(
|
| 114 |
self, placement: dict, vector: np.ndarray | list[float], built: dict
|
|
@@ -147,7 +438,12 @@ class GapMapService:
|
|
| 147 |
)
|
| 148 |
return None
|
| 149 |
placement = await asyncio.to_thread(
|
| 150 |
-
place,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
)
|
| 152 |
placement["expression"] = {
|
| 153 |
"start": expression.start,
|
|
@@ -166,10 +462,15 @@ class GapMapService:
|
|
| 166 |
built = await self.build(refresh=refresh)
|
| 167 |
warnings: list[str] = []
|
| 168 |
coverage = built["coverage"]
|
| 169 |
-
if coverage["corpus"] > coverage["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
warnings.append(
|
| 171 |
-
f"
|
| 172 |
-
f"{coverage['
|
| 173 |
)
|
| 174 |
placement = None
|
| 175 |
if arithmetic is not None:
|
|
@@ -180,7 +481,12 @@ class GapMapService:
|
|
| 180 |
warnings.append("Embeddings are disabled, so the idea could not be placed.")
|
| 181 |
else:
|
| 182 |
placement = await asyncio.to_thread(
|
| 183 |
-
place,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
)
|
| 185 |
placement = self._decorate(placement, vector, built)
|
| 186 |
calibration = None
|
|
@@ -191,6 +497,7 @@ class GapMapService:
|
|
| 191 |
cutoff_year,
|
| 192 |
self.config.gapmap_regions,
|
| 193 |
self.config.gapmap_seed,
|
|
|
|
| 194 |
)
|
| 195 |
return {
|
| 196 |
"version": built["version"],
|
|
@@ -202,3 +509,38 @@ class GapMapService:
|
|
| 202 |
"calibration": calibration,
|
| 203 |
"warnings": warnings,
|
| 204 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serves the gap map: cluster the whole embedded index, describe it, place ideas on it.
|
| 2 |
|
| 3 |
+
The map is a property of the corpus, not of a query, so it is computed once and
|
| 4 |
+
cached. Placing an idea then costs one query embedding and two matrix products,
|
| 5 |
+
which is what makes the "where does this land" answer instant while still being
|
| 6 |
+
computed over every embedded paper rather than the ten a chat model can read.
|
| 7 |
+
|
| 8 |
+
Covering the whole index means the build is long enough to be worth watching, so
|
| 9 |
+
it is written as a stream: documents are scanned in pages, provisional centroids
|
| 10 |
+
move as pages arrive, and full-corpus k-means then runs to convergence, emitting
|
| 11 |
+
the real intermediate state after every iteration. Nothing emitted is
|
| 12 |
+
interpolated or replayed — a partial map is labelled partial, and only the final
|
| 13 |
+
event describes the whole corpus. Measured on the live index (2,096,736 embedded
|
| 14 |
+
studies): 264 s end to end — roughly 130 s to scan at sixteen slices and 25
|
| 15 |
+
bounded k-means iterations over the whole corpus in the rest, peaking at 6.9 GB.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import asyncio
|
| 21 |
+
import contextlib
|
| 22 |
+
import logging
|
| 23 |
+
import zlib
|
| 24 |
+
from collections.abc import Awaitable, Callable
|
| 25 |
|
| 26 |
import numpy as np
|
| 27 |
|
| 28 |
from app.config import Settings, settings
|
| 29 |
from app.embeddings import QueryEmbedder
|
| 30 |
from app.gapmap import (
|
| 31 |
+
MAP_VERSION,
|
| 32 |
+
RunningClusters,
|
| 33 |
_bucket,
|
| 34 |
+
assign,
|
|
|
|
| 35 |
calibrate,
|
| 36 |
combine,
|
| 37 |
+
describe_regions,
|
| 38 |
find_gaps,
|
| 39 |
fit_projection,
|
| 40 |
+
kmeans_steps,
|
| 41 |
normalize,
|
| 42 |
place,
|
| 43 |
project_with,
|
| 44 |
)
|
| 45 |
from app.models import MapArithmetic
|
| 46 |
|
| 47 |
+
logger = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
Progress = Callable[[dict], Awaitable[None]]
|
| 50 |
+
|
| 51 |
|
| 52 |
def _public(region: dict) -> dict:
|
| 53 |
"""Region without its centroid: a 384-float vector is not UI payload."""
|
| 54 |
return {key: value for key, value in region.items() if key != "centroid"}
|
| 55 |
|
| 56 |
|
| 57 |
+
def _drawn(identifier: str, stride: int) -> bool:
|
| 58 |
+
"""Whether a document is one of the ones drawn on the canvas.
|
| 59 |
|
| 60 |
+
A browser cannot render two million marks, so the canvas shows a subset —
|
| 61 |
+
chosen by a hash of the document id, which is deterministic, independent of
|
| 62 |
+
the order pages happen to arrive in, and unrelated to anything the map
|
| 63 |
+
labels. Every document is still clustered; only the drawing is thinned.
|
| 64 |
"""
|
| 65 |
+
return stride <= 1 or zlib.crc32(identifier.encode("utf-8")) % stride == 0
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class MapBuild:
|
| 69 |
+
"""The corpus as it accumulates: vectors in one matrix, documents beside it.
|
| 70 |
+
|
| 71 |
+
The matrix is grown by doubling and handed out as a view, so clustering a
|
| 72 |
+
corpus of millions never holds two copies of it and adding a page costs the
|
| 73 |
+
page, not the corpus. The embedding is dropped from each document once it
|
| 74 |
+
has been copied into the matrix: keeping both is three gigabytes of Python
|
| 75 |
+
floats for nothing.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
def __init__(self, corpus: int, capacity: int = 0):
|
| 79 |
+
self.corpus = corpus
|
| 80 |
+
self.documents: list[dict] = []
|
| 81 |
+
self.dimensions: int | None = None
|
| 82 |
+
self._capacity = max(1024, capacity)
|
| 83 |
+
self._buffer: np.ndarray | None = None
|
| 84 |
+
self._rows = 0
|
| 85 |
+
|
| 86 |
+
def _reserve(self, rows: int) -> None:
|
| 87 |
+
assert self.dimensions is not None
|
| 88 |
+
if self._buffer is None:
|
| 89 |
+
self._buffer = np.empty((self._capacity, self.dimensions), dtype=np.float32)
|
| 90 |
+
while self._rows + rows > len(self._buffer):
|
| 91 |
+
grown = np.empty((len(self._buffer) * 2, self.dimensions), dtype=np.float32)
|
| 92 |
+
grown[: self._rows] = self._buffer[: self._rows]
|
| 93 |
+
self._buffer = grown
|
| 94 |
+
|
| 95 |
+
def add(self, documents: list[dict]) -> int:
|
| 96 |
+
"""Append a page's vectors to the matrix; returns how many were usable."""
|
| 97 |
+
rows, kept = [], []
|
| 98 |
+
for document in documents:
|
| 99 |
+
embedding = document.pop("embedding", None)
|
| 100 |
+
if not isinstance(embedding, (list, tuple)) or not embedding:
|
| 101 |
+
continue
|
| 102 |
+
vector = np.asarray(embedding, dtype=np.float32)
|
| 103 |
+
if not np.isfinite(vector).all() or not vector.any():
|
| 104 |
+
continue
|
| 105 |
+
if self.dimensions is None:
|
| 106 |
+
self.dimensions = len(vector)
|
| 107 |
+
if len(vector) != self.dimensions:
|
| 108 |
+
continue
|
| 109 |
+
rows.append(vector)
|
| 110 |
+
kept.append(document)
|
| 111 |
+
if not rows:
|
| 112 |
+
return 0
|
| 113 |
+
self._reserve(len(rows))
|
| 114 |
+
assert self._buffer is not None
|
| 115 |
+
self._buffer[self._rows : self._rows + len(rows)] = normalize(np.vstack(rows))
|
| 116 |
+
self._rows += len(rows)
|
| 117 |
+
self.documents.extend(kept)
|
| 118 |
+
return len(kept)
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def vectors(self) -> np.ndarray:
|
| 122 |
+
if self._buffer is None:
|
| 123 |
+
return np.zeros((0, self.dimensions or 1), dtype=np.float32)
|
| 124 |
+
return self._buffer[: self._rows]
|
| 125 |
+
|
| 126 |
+
def __len__(self) -> int:
|
| 127 |
+
return self._rows
|
| 128 |
|
| 129 |
|
| 130 |
class GapMapService:
|
|
|
|
| 134 |
self.embedder = QueryEmbedder(config)
|
| 135 |
self._built: dict | None = None
|
| 136 |
self._lock = asyncio.Lock()
|
| 137 |
+
self._subscribers: set[asyncio.Queue] = set()
|
| 138 |
+
self._task: asyncio.Task | None = None
|
| 139 |
|
| 140 |
async def embed(self, text: str) -> list[float] | None:
|
| 141 |
return await self.embedder.embed(text)
|
| 142 |
|
| 143 |
+
# ---------------------------------------------------------------- building
|
| 144 |
+
|
| 145 |
+
async def _publish(self, payload: dict) -> None:
|
| 146 |
+
for queue in list(self._subscribers):
|
| 147 |
+
if queue.full(): # a slow reader must not stall the build
|
| 148 |
+
continue
|
| 149 |
+
queue.put_nowait(payload)
|
| 150 |
+
|
| 151 |
async def build(self, refresh: bool = False) -> dict:
|
| 152 |
+
"""The cached map, building it (once, shared) if it is not there yet.
|
| 153 |
+
|
| 154 |
+
The build is a task of its own rather than a coroutine of whichever
|
| 155 |
+
request arrived first: a viewer who closes the tab after twenty seconds
|
| 156 |
+
must not throw away a three-minute clustering run that other viewers,
|
| 157 |
+
and the cache, are waiting for.
|
| 158 |
+
"""
|
| 159 |
+
if self._built is not None and not refresh:
|
| 160 |
+
return self._built
|
| 161 |
async with self._lock:
|
| 162 |
if self._built is not None and not refresh:
|
| 163 |
return self._built
|
| 164 |
+
if self._task is None or self._task.done():
|
| 165 |
+
self._task = asyncio.create_task(self._build())
|
| 166 |
+
task = self._task
|
| 167 |
+
self._built = await asyncio.shield(task)
|
| 168 |
+
return self._built
|
| 169 |
+
|
| 170 |
+
async def _build(self) -> dict:
|
| 171 |
+
"""Scan the embedded corpus, cluster it, and describe the result."""
|
| 172 |
+
corpus = await self.repo.count_embedded()
|
| 173 |
+
limit = max(0, self.config.gapmap_scan_limit)
|
| 174 |
+
target = min(corpus, limit) if limit else corpus
|
| 175 |
+
stride = max(1, target // max(1, self.config.gapmap_points))
|
| 176 |
+
build = MapBuild(corpus, capacity=target)
|
| 177 |
+
clusters = RunningClusters(self.config.gapmap_regions, self.config.gapmap_seed)
|
| 178 |
+
drawn: list[int] = [] # row indices of the documents the canvas shows
|
| 179 |
+
basis: tuple[np.ndarray, np.ndarray] | None = None
|
| 180 |
+
emitted = 0
|
| 181 |
+
ticked = 0.0
|
| 182 |
+
loop = asyncio.get_running_loop()
|
| 183 |
+
|
| 184 |
+
async for batch in self.repo.scan_embedded(
|
| 185 |
+
batch_size=self.config.gapmap_batch,
|
| 186 |
+
slices=self.config.gapmap_slices,
|
| 187 |
+
limit=limit,
|
| 188 |
+
):
|
| 189 |
+
start = len(build)
|
| 190 |
+
build.add(batch)
|
| 191 |
+
drawn.extend(
|
| 192 |
+
row
|
| 193 |
+
for row in range(start, len(build))
|
| 194 |
+
if _drawn(str(build.documents[row].get("id", row)), stride)
|
| 195 |
)
|
| 196 |
+
await asyncio.to_thread(clusters.update, build.vectors[start:])
|
| 197 |
+
if basis is None and len(build) >= self.config.gapmap_projection_sample:
|
| 198 |
+
basis = await asyncio.to_thread(fit_projection, build.vectors)
|
| 199 |
+
# One frame per interval, not one per page: at eleven thousand
|
| 200 |
+
# documents a second the pages are far faster than anything a canvas
|
| 201 |
+
# can show, and every frame resends where each drawn point now sits.
|
| 202 |
+
if (
|
| 203 |
+
basis is not None
|
| 204 |
+
and clusters.centroids is not None
|
| 205 |
+
and loop.time() - ticked >= self.config.gapmap_tick_seconds
|
| 206 |
+
):
|
| 207 |
+
ticked = loop.time()
|
| 208 |
+
emitted = await self._emit_partial(
|
| 209 |
+
build, clusters.centroids, basis, drawn, emitted, target
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
if basis is None and len(build):
|
| 213 |
+
basis = await asyncio.to_thread(fit_projection, build.vectors)
|
| 214 |
+
|
| 215 |
+
centroids = clusters.centroids
|
| 216 |
+
labels = np.zeros(len(build), dtype=np.int64)
|
| 217 |
+
iteration = 0
|
| 218 |
+
if len(build):
|
| 219 |
+
# Stepped from a thread one iteration at a time: each step is a pass
|
| 220 |
+
# over the whole corpus, so running the generator inline would block
|
| 221 |
+
# the event loop for exactly as long as the frames it is producing.
|
| 222 |
+
steps = kmeans_steps(
|
| 223 |
+
build.vectors,
|
| 224 |
self.config.gapmap_regions,
|
| 225 |
+
seed=self.config.gapmap_seed,
|
| 226 |
+
iterations=self.config.gapmap_iterations,
|
| 227 |
+
centroids=centroids,
|
| 228 |
+
)
|
| 229 |
+
while True:
|
| 230 |
+
step = await asyncio.to_thread(next, steps, None)
|
| 231 |
+
if step is None:
|
| 232 |
+
break
|
| 233 |
+
centroids, labels = step
|
| 234 |
+
iteration += 1
|
| 235 |
+
if basis is not None:
|
| 236 |
+
emitted = await self._emit_partial(
|
| 237 |
+
build,
|
| 238 |
+
centroids,
|
| 239 |
+
basis,
|
| 240 |
+
drawn,
|
| 241 |
+
emitted,
|
| 242 |
+
target,
|
| 243 |
+
iteration=iteration,
|
| 244 |
+
labels=labels,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
clusters_used = len(centroids) if centroids is not None else 0
|
| 248 |
+
regions = await asyncio.to_thread(
|
| 249 |
+
describe_regions, build.documents, build.vectors, labels, clusters_used
|
| 250 |
+
)
|
| 251 |
+
gaps = await asyncio.to_thread(
|
| 252 |
+
find_gaps, build.documents, regions, vectors=build.vectors
|
| 253 |
+
)
|
| 254 |
+
points = self._points(build, regions, labels, basis, drawn)
|
| 255 |
+
if basis is not None and regions:
|
| 256 |
+
centre, plane = basis
|
| 257 |
+
positions = project_with(
|
| 258 |
+
centre,
|
| 259 |
+
plane,
|
| 260 |
+
np.vstack([region["centroid"] for region in regions]).astype(np.float32),
|
| 261 |
)
|
| 262 |
+
for region, (x, y) in zip(regions, positions):
|
| 263 |
+
region["x"], region["y"] = float(x), float(y)
|
| 264 |
+
built = {
|
| 265 |
+
"documents": build.documents,
|
| 266 |
+
"vectors": build.vectors,
|
| 267 |
+
"regions": regions,
|
| 268 |
+
"gaps": gaps,
|
| 269 |
+
"points": points,
|
| 270 |
+
"basis": basis,
|
| 271 |
+
"coverage": {
|
| 272 |
+
"clustered": len(build),
|
| 273 |
+
"corpus": corpus,
|
| 274 |
+
"regions": len(regions),
|
| 275 |
+
"drawn": len(points),
|
| 276 |
+
"complete": True,
|
| 277 |
+
},
|
| 278 |
+
"version": MAP_VERSION,
|
| 279 |
+
}
|
| 280 |
+
logger.info(
|
| 281 |
+
"Gap map built over %s of %s embedded studies in %s regions after %s iterations",
|
| 282 |
+
len(build),
|
| 283 |
+
corpus,
|
| 284 |
+
len(regions),
|
| 285 |
+
iteration,
|
| 286 |
+
)
|
| 287 |
+
await self._publish({"event": "built"})
|
| 288 |
+
return built
|
| 289 |
+
|
| 290 |
+
async def _emit_partial(
|
| 291 |
+
self,
|
| 292 |
+
build: MapBuild,
|
| 293 |
+
centroids: np.ndarray,
|
| 294 |
+
basis: tuple[np.ndarray, np.ndarray],
|
| 295 |
+
drawn: list[int],
|
| 296 |
+
emitted: int,
|
| 297 |
+
target: int,
|
| 298 |
+
iteration: int | None = None,
|
| 299 |
+
labels: np.ndarray | None = None,
|
| 300 |
+
) -> int:
|
| 301 |
+
"""Publish the map as it stands: new points, where the regions are now.
|
| 302 |
+
|
| 303 |
+
Only the points the canvas has not seen are sent in full; the ones it
|
| 304 |
+
already holds are recoloured through ``pointRegions``, because a region
|
| 305 |
+
a point belongs to changes on every k-means iteration while its position
|
| 306 |
+
does not.
|
| 307 |
+
"""
|
| 308 |
+
if not self._subscribers:
|
| 309 |
+
return emitted
|
| 310 |
+
centre, plane = basis
|
| 311 |
+
rows = np.array(drawn, dtype=np.int64)
|
| 312 |
+
if not len(rows):
|
| 313 |
+
return emitted
|
| 314 |
+
if labels is None:
|
| 315 |
+
member = assign(build.vectors[rows], centroids)
|
| 316 |
+
else:
|
| 317 |
+
member = labels[rows]
|
| 318 |
+
fresh = []
|
| 319 |
+
if len(rows) > emitted:
|
| 320 |
+
new_rows = rows[emitted:]
|
| 321 |
+
coords = project_with(centre, plane, build.vectors[new_rows])
|
| 322 |
+
for offset, row in enumerate(new_rows.tolist()):
|
| 323 |
+
document = build.documents[row]
|
| 324 |
+
title = str(document.get("title") or "")
|
| 325 |
+
fresh.append(
|
| 326 |
+
{
|
| 327 |
+
"id": str(document.get("id", "")),
|
| 328 |
+
"x": float(coords[offset, 0]),
|
| 329 |
+
"y": float(coords[offset, 1]),
|
| 330 |
+
"region": int(member[emitted + offset]),
|
| 331 |
+
"bucket": _bucket(document),
|
| 332 |
+
"title": title if len(title) <= 120 else f"{title[:119]}…",
|
| 333 |
+
"year": document.get("year") if isinstance(document.get("year"), int) else None,
|
| 334 |
+
}
|
| 335 |
+
)
|
| 336 |
+
positions = project_with(centre, plane, centroids)
|
| 337 |
+
counts = np.bincount(member, minlength=len(centroids))
|
| 338 |
+
await self._publish(
|
| 339 |
+
{
|
| 340 |
+
"event": "progress",
|
| 341 |
+
"stage": "clustering" if iteration else "scanning",
|
| 342 |
+
"iteration": iteration,
|
| 343 |
"coverage": {
|
| 344 |
+
"clustered": len(build),
|
| 345 |
+
"corpus": build.corpus,
|
| 346 |
+
"regions": int((counts > 0).sum()),
|
| 347 |
+
"drawn": len(rows),
|
| 348 |
+
"target": target,
|
| 349 |
+
"complete": False,
|
| 350 |
},
|
| 351 |
+
"points": fresh,
|
| 352 |
+
"pointRegions": [int(value) for value in member.tolist()],
|
| 353 |
+
"regions": [
|
| 354 |
+
{
|
| 355 |
+
"id": index,
|
| 356 |
+
"size": int(counts[index]),
|
| 357 |
+
"x": float(positions[index, 0]),
|
| 358 |
+
"y": float(positions[index, 1]),
|
| 359 |
+
}
|
| 360 |
+
for index in range(len(centroids))
|
| 361 |
+
if counts[index]
|
| 362 |
+
],
|
| 363 |
}
|
| 364 |
+
)
|
| 365 |
+
return len(rows)
|
| 366 |
+
|
| 367 |
+
def _points(
|
| 368 |
+
self,
|
| 369 |
+
build: MapBuild,
|
| 370 |
+
regions: list[dict],
|
| 371 |
+
labels: np.ndarray,
|
| 372 |
+
basis: tuple[np.ndarray, np.ndarray] | None,
|
| 373 |
+
drawn: list[int],
|
| 374 |
+
) -> list[dict]:
|
| 375 |
+
"""2-D positions for the drawn subset, each carrying its region."""
|
| 376 |
+
if basis is None or not regions or not drawn:
|
| 377 |
+
return []
|
| 378 |
+
centre, plane = basis
|
| 379 |
+
rows = np.array(drawn, dtype=np.int64)
|
| 380 |
+
coords = project_with(centre, plane, build.vectors[rows])
|
| 381 |
+
known = {region["id"] for region in regions}
|
| 382 |
+
points = []
|
| 383 |
+
for offset, row in enumerate(rows.tolist()):
|
| 384 |
+
region = int(labels[row])
|
| 385 |
+
if region not in known:
|
| 386 |
+
continue
|
| 387 |
+
document = build.documents[row]
|
| 388 |
+
title = str(document.get("title") or "")
|
| 389 |
+
points.append(
|
| 390 |
+
{
|
| 391 |
+
"id": str(document.get("id", "")),
|
| 392 |
+
"x": float(coords[offset, 0]),
|
| 393 |
+
"y": float(coords[offset, 1]),
|
| 394 |
+
"region": region,
|
| 395 |
+
"bucket": _bucket(document),
|
| 396 |
+
"title": title if len(title) <= 120 else f"{title[:119]}…",
|
| 397 |
+
"year": document.get("year") if isinstance(document.get("year"), int) else None,
|
| 398 |
+
}
|
| 399 |
+
)
|
| 400 |
+
return points
|
| 401 |
+
|
| 402 |
+
# ---------------------------------------------------------------- serving
|
| 403 |
|
| 404 |
def _decorate(
|
| 405 |
self, placement: dict, vector: np.ndarray | list[float], built: dict
|
|
|
|
| 438 |
)
|
| 439 |
return None
|
| 440 |
placement = await asyncio.to_thread(
|
| 441 |
+
place,
|
| 442 |
+
combined,
|
| 443 |
+
built["documents"],
|
| 444 |
+
built["regions"],
|
| 445 |
+
built["gaps"],
|
| 446 |
+
vectors=built["vectors"],
|
| 447 |
)
|
| 448 |
placement["expression"] = {
|
| 449 |
"start": expression.start,
|
|
|
|
| 462 |
built = await self.build(refresh=refresh)
|
| 463 |
warnings: list[str] = []
|
| 464 |
coverage = built["coverage"]
|
| 465 |
+
if coverage["corpus"] > coverage["clustered"]:
|
| 466 |
+
warnings.append(
|
| 467 |
+
f"The map clusters {coverage['clustered']} of {coverage['corpus']} "
|
| 468 |
+
"embedded studies, not the whole index."
|
| 469 |
+
)
|
| 470 |
+
if coverage["drawn"] < coverage["clustered"]:
|
| 471 |
warnings.append(
|
| 472 |
+
f"All {coverage['clustered']} clustered studies shape the regions; the canvas "
|
| 473 |
+
f"draws {coverage['drawn']} of them."
|
| 474 |
)
|
| 475 |
placement = None
|
| 476 |
if arithmetic is not None:
|
|
|
|
| 481 |
warnings.append("Embeddings are disabled, so the idea could not be placed.")
|
| 482 |
else:
|
| 483 |
placement = await asyncio.to_thread(
|
| 484 |
+
place,
|
| 485 |
+
vector,
|
| 486 |
+
built["documents"],
|
| 487 |
+
built["regions"],
|
| 488 |
+
built["gaps"],
|
| 489 |
+
vectors=built["vectors"],
|
| 490 |
)
|
| 491 |
placement = self._decorate(placement, vector, built)
|
| 492 |
calibration = None
|
|
|
|
| 497 |
cutoff_year,
|
| 498 |
self.config.gapmap_regions,
|
| 499 |
self.config.gapmap_seed,
|
| 500 |
+
vectors=built["vectors"],
|
| 501 |
)
|
| 502 |
return {
|
| 503 |
"version": built["version"],
|
|
|
|
| 509 |
"calibration": calibration,
|
| 510 |
"warnings": warnings,
|
| 511 |
}
|
| 512 |
+
|
| 513 |
+
async def stream(
|
| 514 |
+
self,
|
| 515 |
+
idea: str | None = None,
|
| 516 |
+
arithmetic: MapArithmetic | None = None,
|
| 517 |
+
cutoff_year: int | None = None,
|
| 518 |
+
refresh: bool = False,
|
| 519 |
+
progress: Progress | None = None,
|
| 520 |
+
) -> dict:
|
| 521 |
+
"""``assess`` with the build published as it happens.
|
| 522 |
+
|
| 523 |
+
A cached map produces no progress events: replaying a finished build as
|
| 524 |
+
though it were happening would be theatre, and the caller can tell the
|
| 525 |
+
difference because the first thing it receives is the finished result.
|
| 526 |
+
"""
|
| 527 |
+
if progress is None:
|
| 528 |
+
return await self.assess(idea, arithmetic, cutoff_year, refresh)
|
| 529 |
+
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
| 530 |
+
self._subscribers.add(queue)
|
| 531 |
+
pump = asyncio.create_task(self._pump(queue, progress))
|
| 532 |
+
try:
|
| 533 |
+
return await self.assess(idea, arithmetic, cutoff_year, refresh)
|
| 534 |
+
finally:
|
| 535 |
+
self._subscribers.discard(queue)
|
| 536 |
+
queue.put_nowait(None)
|
| 537 |
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
| 538 |
+
await pump
|
| 539 |
+
|
| 540 |
+
@staticmethod
|
| 541 |
+
async def _pump(queue: asyncio.Queue, progress: Progress) -> None:
|
| 542 |
+
while True:
|
| 543 |
+
payload = await queue.get()
|
| 544 |
+
if payload is None or payload.get("event") == "built":
|
| 545 |
+
return
|
| 546 |
+
await progress({key: value for key, value in payload.items() if key != "event"})
|
app/main.py
CHANGED
|
@@ -172,6 +172,70 @@ async def gap_map(body: MapRequest, request: Request):
|
|
| 172 |
) from None
|
| 173 |
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
@app.post("/concepts/search")
|
| 176 |
@app.post("/api/concepts/search", include_in_schema=False)
|
| 177 |
async def concept_search(body: ConceptSearchRequest, request: Request):
|
|
|
|
| 172 |
) from None
|
| 173 |
|
| 174 |
|
| 175 |
+
@app.post("/map/stream")
|
| 176 |
+
@app.post("/api/map/stream", include_in_schema=False)
|
| 177 |
+
async def gap_map_stream(body: MapRequest, request: Request):
|
| 178 |
+
"""The map as it is built: the same payload as ``/map``, plus the states on
|
| 179 |
+
the way to it. A cached map arrives as one `result` event with no progress
|
| 180 |
+
before it, which is the honest thing for work that did not happen."""
|
| 181 |
+
|
| 182 |
+
async def stream():
|
| 183 |
+
queue: asyncio.Queue = asyncio.Queue(maxsize=32)
|
| 184 |
+
|
| 185 |
+
async def progress(payload):
|
| 186 |
+
await queue.put(("progress", payload))
|
| 187 |
+
|
| 188 |
+
async def run():
|
| 189 |
+
try:
|
| 190 |
+
result = await request.app.state.gapmap.stream(
|
| 191 |
+
idea=body.idea,
|
| 192 |
+
arithmetic=body.arithmetic,
|
| 193 |
+
cutoff_year=body.cutoffYear,
|
| 194 |
+
refresh=body.refresh,
|
| 195 |
+
progress=progress,
|
| 196 |
+
)
|
| 197 |
+
await queue.put(("result", result))
|
| 198 |
+
except asyncio.CancelledError:
|
| 199 |
+
raise
|
| 200 |
+
except Exception as exc:
|
| 201 |
+
logger.error("Streamed gap map failed: %s", type(exc).__name__)
|
| 202 |
+
await queue.put(
|
| 203 |
+
(
|
| 204 |
+
"error",
|
| 205 |
+
{
|
| 206 |
+
"message": "The evidence map could not be built. "
|
| 207 |
+
"Please retry or inspect the backend logs."
|
| 208 |
+
},
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
task = asyncio.create_task(run())
|
| 213 |
+
try:
|
| 214 |
+
while True:
|
| 215 |
+
if await request.is_disconnected():
|
| 216 |
+
break
|
| 217 |
+
try:
|
| 218 |
+
event, payload = await asyncio.wait_for(queue.get(), timeout=10)
|
| 219 |
+
except TimeoutError:
|
| 220 |
+
yield ": keepalive\n\n"
|
| 221 |
+
continue
|
| 222 |
+
yield f"event: {event}\ndata: {json.dumps(payload, allow_nan=False)}\n\n"
|
| 223 |
+
if event in ("result", "error"):
|
| 224 |
+
break
|
| 225 |
+
finally:
|
| 226 |
+
task.cancel()
|
| 227 |
+
try:
|
| 228 |
+
await task
|
| 229 |
+
except asyncio.CancelledError:
|
| 230 |
+
pass
|
| 231 |
+
|
| 232 |
+
return StreamingResponse(
|
| 233 |
+
stream(),
|
| 234 |
+
media_type="text/event-stream",
|
| 235 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
@app.post("/concepts/search")
|
| 240 |
@app.post("/api/concepts/search", include_in_schema=False)
|
| 241 |
async def concept_search(body: ConceptSearchRequest, request: Request):
|
app/repository.py
CHANGED
|
@@ -2,9 +2,11 @@
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import calendar
|
|
|
|
| 5 |
import math
|
| 6 |
import re
|
| 7 |
import unicodedata
|
|
|
|
| 8 |
from datetime import UTC, date, datetime
|
| 9 |
from typing import Any
|
| 10 |
|
|
@@ -57,6 +59,34 @@ _PAPER_FIELDS = set(
|
|
| 57 |
)
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def index_mapping(dimensions: int = 384) -> dict:
|
| 61 |
properties: dict[str, Any] = {}
|
| 62 |
for name in (
|
|
@@ -849,11 +879,7 @@ class ElasticRepository:
|
|
| 849 |
sample so a caller can say which fraction of the corpus it describes.
|
| 850 |
"""
|
| 851 |
await self.ensure_index()
|
| 852 |
-
query =
|
| 853 |
-
"bool": {
|
| 854 |
-
"filter": [{"term": {"record_kind": "study"}}, {"exists": {"field": "embedding"}}],
|
| 855 |
-
}
|
| 856 |
-
}
|
| 857 |
total = await self.client.count(index=self.index, query=query)
|
| 858 |
body = {
|
| 859 |
"index": self.index,
|
|
@@ -870,20 +896,89 @@ class ElasticRepository:
|
|
| 870 |
"boost_mode": "replace",
|
| 871 |
}
|
| 872 |
},
|
| 873 |
-
"source_includes":
|
| 874 |
-
"embedding",
|
| 875 |
-
"title",
|
| 876 |
-
"year",
|
| 877 |
-
"bucket",
|
| 878 |
-
"record_kind",
|
| 879 |
-
"is_review",
|
| 880 |
-
"cited_by_count",
|
| 881 |
-
"source",
|
| 882 |
-
],
|
| 883 |
}
|
| 884 |
result = await self._search_with_vectors(body)
|
| 885 |
return {"documents": self.hits(result), "corpus": total["count"]}
|
| 886 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
async def _search_with_vectors(self, body: dict) -> Any:
|
| 888 |
"""Search keeping the stored vectors in `_source`, on 9.1 and on 9.2+."""
|
| 889 |
if not self._vector_source_filter:
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import calendar
|
| 5 |
+
import contextlib
|
| 6 |
import math
|
| 7 |
import re
|
| 8 |
import unicodedata
|
| 9 |
+
from collections.abc import AsyncIterator
|
| 10 |
from datetime import UTC, date, datetime
|
| 11 |
from typing import Any
|
| 12 |
|
|
|
|
| 59 |
)
|
| 60 |
|
| 61 |
|
| 62 |
+
# Embedded studies, the corpus the gap map is drawn from. Reviews stay in: they
|
| 63 |
+
# occupy the space too, and `gapmap` excludes them from a region's attempts.
|
| 64 |
+
_EMBEDDED_QUERY = {
|
| 65 |
+
"bool": {"filter": [{"term": {"record_kind": "study"}}, {"exists": {"field": "embedding"}}]}
|
| 66 |
+
}
|
| 67 |
+
# Everything the map needs per document and nothing else: abstracts would
|
| 68 |
+
# multiply the bytes on the wire without changing a single position.
|
| 69 |
+
_MAP_FIELDS = [
|
| 70 |
+
"embedding",
|
| 71 |
+
"title",
|
| 72 |
+
"year",
|
| 73 |
+
"bucket",
|
| 74 |
+
"record_kind",
|
| 75 |
+
"is_review",
|
| 76 |
+
"cited_by_count",
|
| 77 |
+
"source",
|
| 78 |
+
]
|
| 79 |
+
_PIT_KEEP_ALIVE = "10m"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _split_budget(limit: int, slices: int) -> list[int | None]:
|
| 83 |
+
"""Share a document budget between concurrent slices; no limit means no cap."""
|
| 84 |
+
if limit <= 0:
|
| 85 |
+
return [None] * slices
|
| 86 |
+
base, extra = divmod(limit, slices)
|
| 87 |
+
return [base + (1 if index < extra else 0) for index in range(slices)]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
def index_mapping(dimensions: int = 384) -> dict:
|
| 91 |
properties: dict[str, Any] = {}
|
| 92 |
for name in (
|
|
|
|
| 879 |
sample so a caller can say which fraction of the corpus it describes.
|
| 880 |
"""
|
| 881 |
await self.ensure_index()
|
| 882 |
+
query = _EMBEDDED_QUERY
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
total = await self.client.count(index=self.index, query=query)
|
| 884 |
body = {
|
| 885 |
"index": self.index,
|
|
|
|
| 896 |
"boost_mode": "replace",
|
| 897 |
}
|
| 898 |
},
|
| 899 |
+
"source_includes": _MAP_FIELDS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
}
|
| 901 |
result = await self._search_with_vectors(body)
|
| 902 |
return {"documents": self.hits(result), "corpus": total["count"]}
|
| 903 |
|
| 904 |
+
async def count_embedded(self) -> int:
|
| 905 |
+
"""How many embedded studies exist, so coverage can be stated as a fraction."""
|
| 906 |
+
await self.ensure_index()
|
| 907 |
+
result = await self.client.count(index=self.index, query=_EMBEDDED_QUERY)
|
| 908 |
+
return int(result["count"])
|
| 909 |
+
|
| 910 |
+
async def scan_embedded(
|
| 911 |
+
self,
|
| 912 |
+
batch_size: int = 2000,
|
| 913 |
+
slices: int = 8,
|
| 914 |
+
limit: int = 0,
|
| 915 |
+
) -> AsyncIterator[list[dict]]:
|
| 916 |
+
"""Every embedded study, in batches, over one point in time.
|
| 917 |
+
|
| 918 |
+
Paginated rather than fetched at once: the embedded corpus is millions of
|
| 919 |
+
384-float vectors, so a single oversized search would neither fit in one
|
| 920 |
+
response nor let a caller show anything before the last document arrived.
|
| 921 |
+
Slices run concurrently because a single `search_after` walk is limited by
|
| 922 |
+
round-trip latency, not by Elasticsearch — measured against the live index,
|
| 923 |
+
one walk moved ~3.2k documents/s and sixteen slices ~11k/s.
|
| 924 |
+
|
| 925 |
+
A point in time keeps the walk consistent, so the same document is never
|
| 926 |
+
yielded twice and none is missed while the index is written to.
|
| 927 |
+
"""
|
| 928 |
+
await self.ensure_index()
|
| 929 |
+
slices = max(1, slices)
|
| 930 |
+
pit = (await self.client.open_point_in_time(index=self.index, keep_alive=_PIT_KEEP_ALIVE))
|
| 931 |
+
pit_id = pit["id"]
|
| 932 |
+
queue: asyncio.Queue = asyncio.Queue(maxsize=slices * 2)
|
| 933 |
+
budgets = _split_budget(limit, slices)
|
| 934 |
+
|
| 935 |
+
async def walk(slice_id: int, budget: int | None) -> None:
|
| 936 |
+
after: list | None = None
|
| 937 |
+
taken = 0
|
| 938 |
+
while budget is None or taken < budget:
|
| 939 |
+
size = batch_size if budget is None else min(batch_size, budget - taken)
|
| 940 |
+
# No index: a point in time already names one, and Elasticsearch
|
| 941 |
+
# rejects a search that specifies both.
|
| 942 |
+
body: dict[str, Any] = {
|
| 943 |
+
"size": size,
|
| 944 |
+
"query": _EMBEDDED_QUERY,
|
| 945 |
+
"pit": {"id": pit_id, "keep_alive": _PIT_KEEP_ALIVE},
|
| 946 |
+
"sort": [{"_shard_doc": "asc"}],
|
| 947 |
+
"source_includes": _MAP_FIELDS,
|
| 948 |
+
}
|
| 949 |
+
if slices > 1:
|
| 950 |
+
body["slice"] = {"id": slice_id, "max": slices}
|
| 951 |
+
if after is not None:
|
| 952 |
+
body["search_after"] = after
|
| 953 |
+
response = await self._search_with_vectors(body)
|
| 954 |
+
hits = response["hits"]["hits"]
|
| 955 |
+
if not hits:
|
| 956 |
+
return
|
| 957 |
+
after = hits[-1]["sort"]
|
| 958 |
+
taken += len(hits)
|
| 959 |
+
await queue.put(self.hits(response))
|
| 960 |
+
|
| 961 |
+
async def produce() -> None:
|
| 962 |
+
try:
|
| 963 |
+
await asyncio.gather(*(walk(index, budgets[index]) for index in range(slices)))
|
| 964 |
+
finally:
|
| 965 |
+
await queue.put(None)
|
| 966 |
+
|
| 967 |
+
producer = asyncio.create_task(produce())
|
| 968 |
+
try:
|
| 969 |
+
while True:
|
| 970 |
+
batch = await queue.get()
|
| 971 |
+
if batch is None:
|
| 972 |
+
break
|
| 973 |
+
yield batch
|
| 974 |
+
await producer
|
| 975 |
+
finally:
|
| 976 |
+
producer.cancel()
|
| 977 |
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
| 978 |
+
await producer
|
| 979 |
+
with contextlib.suppress(Exception): # the walk is over either way
|
| 980 |
+
await self.client.close_point_in_time(id=pit_id)
|
| 981 |
+
|
| 982 |
async def _search_with_vectors(self, body: dict) -> Any:
|
| 983 |
"""Search keeping the stored vectors in `_source`, on 9.1 and on 9.2+."""
|
| 984 |
if not self._vector_source_filter:
|