Conradf commited on
Commit
0cb871f
·
verified ·
1 Parent(s): 9178ee1

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. app/config.py +24 -2
  2. app/gapmap.py +208 -57
  3. app/gapmap_service.py +404 -62
  4. app/main.py +64 -0
  5. 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: a deterministic random sample of embedded studies, 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_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 kmeans(vectors: np.ndarray, k: int, seed: int = 0, iterations: int = 40) -> np.ndarray:
79
- """Spherical k-means with k-means++ seeding, returning a label per row.
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
- labels = np.full(count, -1, dtype=np.int64)
97
- for _ in range(iterations):
98
- assigned = (vectors @ centroids.T).argmax(axis=1)
99
- if np.array_equal(assigned, labels):
100
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  labels = assigned
102
- for index in range(k):
103
- members = vectors[labels == index]
104
- if len(members):
105
- centroids[index] = members.mean(axis=0)
106
- centroids = normalize(centroids)
 
 
 
 
 
 
 
 
 
 
 
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 build_regions(documents: list[dict], regions: int = 24, seed: int = 0) -> dict:
211
- """Cluster embedded documents and describe each cluster by what happened in it.
 
 
 
 
 
212
 
213
- Documents without a stored vector are counted in ``skipped`` rather than
214
- dropped silently: a region map over a partly unembedded corpus describes the
215
- embedded part only, and saying so is the difference between a map and a claim.
 
 
 
 
 
 
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 sorted(set(labels.tolist())):
226
- members = [doc for doc, label in zip(corpus, labels) if label == index]
227
- centroid = normalize(vectors[labels == index].mean(axis=0, keepdims=True))[0]
228
- primary = [doc for doc in members if _is_primary(doc)]
 
 
 
229
  counts = Counter(_bucket(doc) for doc in primary)
230
- years = [doc["year"] for doc in members if isinstance(doc.get("year"), int)]
231
  citations = [doc.get("cited_by_count") or 0 for doc in primary]
232
  described.append(
233
  {
234
  "id": int(index),
235
- "size": len(members),
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((vectors[labels == index] @ centroid).mean()),
243
- "exemplars": _exemplars(members),
244
  }
245
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  return {
247
  "version": MAP_VERSION,
248
- "regions": described,
249
- "documents": len(embedded),
250
  "skipped": skipped,
251
  }
252
 
253
 
254
- def _corpus(documents: list[dict]) -> tuple[list[dict], np.ndarray]:
 
 
 
 
 
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
- order = np.argsort(-similarity)[: max(1, limit)]
 
 
 
 
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 @ centroids.T).argmax(axis=1)
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 = np.vstack(
345
- [
346
- vectors[members] @ centroids[left],
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
- past = [doc for doc in documents if isinstance(doc.get("year"), int) and doc["year"] < cutoff_year]
457
- future = [
458
- doc for doc in documents if isinstance(doc.get("year"), int) and doc["year"] >= cutoff_year
459
  ]
460
- built = build_regions(past, regions=regions, seed=seed)
 
 
 
 
 
 
 
 
 
 
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: sample the index once, describe it, place ideas on it.
2
 
3
- The map is a property of the corpus, not of a query, so it is computed from a
4
- deterministic sample and cached until asked to refresh. Placing an idea then
5
- costs one query embedding and two matrix products, which is what makes the
6
- "where does this land" answer instant while still being computed over thousands
7
- of papers rather than the ten a chat model can read.
 
 
 
 
 
 
 
 
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
- _corpus,
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 _layout(documents: list[dict], regions: list[dict]) -> dict | None:
39
- """2-D positions for the sampled documents and each region centroid.
40
 
41
- Membership is recomputed rather than returned by build_regions — k-means
42
- assignment is argmax over centroid cosines, so this is exact. The fitted
43
- basis is kept with the map so a placed idea lands in the same plane.
 
44
  """
45
- corpus, vectors = _corpus(documents)
46
- if not len(vectors) or not regions:
47
- return None
48
- mean, basis = fit_projection(vectors)
49
- coords = project_with(mean, basis, vectors)
50
- centroids = np.vstack([region["centroid"] for region in regions]).astype(np.float32)
51
- assigned = (vectors @ centroids.T).argmax(axis=1)
52
- for region, (x, y) in zip(regions, project_with(mean, basis, centroids)):
53
- region["x"], region["y"] = float(x), float(y)
54
- points = []
55
- for index, doc in enumerate(corpus):
56
- title = str(doc.get("title") or "")
57
- points.append(
58
- {
59
- "id": str(doc.get("id", "")),
60
- "x": float(coords[index, 0]),
61
- "y": float(coords[index, 1]),
62
- "region": regions[int(assigned[index])]["id"],
63
- "bucket": _bucket(doc),
64
- "title": title if len(title) <= 120 else f"{title[:119]}…",
65
- "year": doc.get("year") if isinstance(doc.get("year"), int) else None,
66
- }
67
- )
68
- return {"points": points, "mean": mean, "basis": basis}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- sample = await self.repo.sample_embedded(
87
- limit=self.config.gapmap_sample, seed=self.config.gapmap_seed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  )
89
- documents = sample["documents"]
90
- built = await asyncio.to_thread(
91
- build_regions,
92
- documents,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  self.config.gapmap_regions,
94
- self.config.gapmap_seed,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
96
- gaps = await asyncio.to_thread(find_gaps, documents, built["regions"])
97
- layout = await asyncio.to_thread(_layout, documents, built["regions"])
98
- self._built = {
99
- "documents": documents,
100
- "regions": built["regions"],
101
- "gaps": gaps,
102
- "points": layout["points"] if layout else [],
103
- "basis": (layout["mean"], layout["basis"]) if layout else None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  "coverage": {
105
- "sampled": built["documents"],
106
- "corpus": sample["corpus"],
107
- "regions": len(built["regions"]),
 
 
 
108
  },
109
- "version": built["version"],
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
- return self._built
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, combined, built["documents"], built["regions"], built["gaps"]
 
 
 
 
 
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["sampled"]:
 
 
 
 
 
170
  warnings.append(
171
- f"The map describes a random sample of {coverage['sampled']} of "
172
- f"{coverage['corpus']} embedded studies, not the whole index."
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, vector, built["documents"], built["regions"], built["gaps"]
 
 
 
 
 
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: