Conradf commited on
Commit
befe23e
·
verified ·
1 Parent(s): 285b041

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. app/config.py +12 -1
  2. app/gapmap.py +38 -0
  3. app/gapmap_service.py +216 -76
app/config.py CHANGED
@@ -47,7 +47,12 @@ 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: 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
@@ -68,6 +73,12 @@ class Settings(BaseSettings):
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
  # Nothing can be drawn before it exists, so it is fitted on the first pages
 
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: the studies nearest the question, fetched by kNN and clustered per
51
+ # question. A few hundred nodes is what a reader can take in and what a force
52
+ # layout can settle in a second; the full-index scan below only runs when
53
+ # there is no question to center on.
54
+ gapmap_neighborhood: int = Field(default=800, ge=50, le=5000)
55
+ # Whole-index build (no question): every embedded study, scanned in pages.
56
  # The region count is a resolution, and what counts as an empty band depends on
57
  # it: measured on 6000 documents of the live index, 24 regions leave the corpus
58
  # continuous (no band under the occupancy threshold) while 80 keep a median of
 
73
  # regions; a browser cannot paint two million marks, so the drawn subset is
74
  # thinned by a hash of the document id.
75
  gapmap_points: int = Field(default=6000, ge=100, le=50000)
76
+ # Edges the canvas draws between drawn studies: each study joins its most
77
+ # similar few, and only when the cosine reaches the floor. The cap is what
78
+ # keeps a dense literature from becoming one hub; the floor is what leaves an
79
+ # isolated paper isolated instead of tethered to its nearest stranger.
80
+ gapmap_edge_neighbors: int = Field(default=3, ge=1, le=12)
81
+ gapmap_edge_floor: float = Field(default=0.8, ge=-1.0, le=1.0)
82
  # Rows the 2-D projection is fitted on. The basis is a drawing choice, and a
83
  # full-corpus SVD would stall the build for minutes to move points by pixels.
84
  # Nothing can be drawn before it exists, so it is fitted on the first pages
app/gapmap.py CHANGED
@@ -233,6 +233,44 @@ def project_with(mean: np.ndarray, basis: np.ndarray, vectors: np.ndarray) -> np
233
  return (np.atleast_2d(np.asarray(vectors, dtype=np.float32)) - mean) @ basis.T
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  def project(vectors: np.ndarray) -> np.ndarray:
237
  """2-D coordinates for ``vectors`` under a basis fitted on themselves."""
238
  if not len(vectors):
 
233
  return (np.atleast_2d(np.asarray(vectors, dtype=np.float32)) - mean) @ basis.T
234
 
235
 
236
+ def neighbor_edges(
237
+ vectors: np.ndarray,
238
+ start: int = 0,
239
+ neighbors: int = 3,
240
+ floor: float = 0.0,
241
+ chunk: int = 1024,
242
+ ) -> list[tuple[int, int, float]]:
243
+ """Cosine-nearest-neighbor edges among unit ``vectors``.
244
+
245
+ Each row from ``start`` on is joined to its ``neighbors`` most similar rows
246
+ anywhere in the matrix, provided the cosine reaches ``floor``; rows before
247
+ ``start`` already had their turn on an earlier call, so a stream can call
248
+ this once per frame with only the new rows and never repeats an edge. Pairs
249
+ are returned once each as ``(i, j, cosine)`` with ``i < j``, indices being
250
+ positions in ``vectors``. The degree cap is what keeps a dense literature
251
+ from becoming one hub; the floor is what keeps an isolated paper isolated.
252
+ """
253
+ total = len(vectors)
254
+ if total < 2 or start >= total or neighbors < 1:
255
+ return []
256
+ matrix = np.asarray(vectors, dtype=np.float32)
257
+ edges: dict[tuple[int, int], float] = {}
258
+ for begin in range(start, total, chunk):
259
+ end = min(begin + chunk, total)
260
+ scores = matrix[begin:end] @ matrix.T
261
+ scores[np.arange(end - begin), np.arange(begin, end)] = -np.inf
262
+ top = min(neighbors, total - 1)
263
+ candidates = np.argpartition(-scores, top - 1, axis=1)[:, :top]
264
+ for offset, row in enumerate(range(begin, end)):
265
+ for column in candidates[offset].tolist():
266
+ cosine = float(scores[offset, column])
267
+ if cosine < floor:
268
+ continue
269
+ key = (row, column) if row < column else (column, row)
270
+ edges[key] = cosine
271
+ return [(i, j, cosine) for (i, j), cosine in edges.items()]
272
+
273
+
274
  def project(vectors: np.ndarray) -> np.ndarray:
275
  """2-D coordinates for ``vectors`` under a basis fitted on themselves."""
276
  if not len(vectors):
app/gapmap_service.py CHANGED
@@ -1,18 +1,21 @@
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
@@ -38,6 +41,7 @@ from app.gapmap import (
38
  find_gaps,
39
  fit_projection,
40
  kmeans_steps,
 
41
  normalize,
42
  place,
43
  project_with,
@@ -48,6 +52,9 @@ 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."""
@@ -143,10 +150,11 @@ class GapMapService:
143
  self.repo = repository
144
  self.config = config
145
  self.embedder = QueryEmbedder(config)
146
- self._built: dict | None = None
 
147
  self._lock = asyncio.Lock()
148
  self._subscribers: set[asyncio.Queue] = set()
149
- self._task: asyncio.Task | None = None
150
  # The plane the current build is drawing on, so a streamed question can be
151
  # placed on the same one before the build finishes.
152
  self._basis: tuple[np.ndarray, np.ndarray] | None = None
@@ -162,26 +170,94 @@ class GapMapService:
162
  continue
163
  queue.put_nowait(payload)
164
 
165
- async def build(self, refresh: bool = False) -> dict:
166
- """The cached map, building it (once, shared) if it is not there yet.
 
 
 
 
 
167
 
168
  The build is a task of its own rather than a coroutine of whichever
169
  request arrived first: a viewer who closes the tab after twenty seconds
170
- must not throw away a three-minute clustering run that other viewers,
171
- and the cache, are waiting for.
172
  """
173
- if self._built is not None and not refresh:
174
- return self._built
 
175
  async with self._lock:
176
- if self._built is not None and not refresh:
177
- return self._built
178
- if self._task is None or self._task.done():
179
- self._task = asyncio.create_task(self._build())
180
- task = self._task
181
- self._built = await asyncio.shield(task)
182
- return self._built
183
-
184
- async def _build(self) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  """Scan the embedded corpus, cluster it, and describe the result."""
186
  # Counted beside the scan rather than before it: the count is a query of
187
  # its own over two million documents, and nothing can be drawn while it
@@ -283,6 +359,22 @@ class GapMapService:
283
  labels=labels,
284
  )
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  clusters_used = len(centroids) if centroids is not None else 0
287
  regions = await asyncio.to_thread(
288
  describe_regions, build.documents, build.vectors, labels, clusters_used
@@ -290,7 +382,8 @@ class GapMapService:
290
  gaps = await asyncio.to_thread(
291
  find_gaps, build.documents, regions, vectors=build.vectors
292
  )
293
- points = self._points(build, regions, labels, basis, drawn)
 
294
  if basis is not None and regions:
295
  centre, plane = basis
296
  positions = project_with(
@@ -306,6 +399,7 @@ class GapMapService:
306
  "regions": regions,
307
  "gaps": gaps,
308
  "points": points,
 
309
  "basis": basis,
310
  "coverage": {
311
  "clustered": len(build),
@@ -313,11 +407,13 @@ class GapMapService:
313
  "regions": len(regions),
314
  "drawn": len(points),
315
  "complete": True,
 
316
  },
317
  "version": MAP_VERSION,
318
  }
319
  logger.info(
320
- "Gap map built over %s of %s embedded studies in %s regions after %s iterations",
 
321
  len(build),
322
  corpus,
323
  len(regions),
@@ -336,15 +432,18 @@ class GapMapService:
336
  target: int,
337
  iteration: int | None = None,
338
  labels: np.ndarray | None = None,
 
 
339
  ) -> int:
340
  """Publish the map as it stands: new points, where the regions are now.
341
 
342
  Only the points the canvas has not seen are sent in full; the ones it
343
  already holds are recoloured through ``pointRegions``, because a region
344
  a point belongs to changes on every k-means iteration while its position
345
- does not.
 
346
  """
347
- if not self._subscribers:
348
  return emitted
349
  centre, plane = basis
350
  rows = np.array(drawn, dtype=np.int64)
@@ -354,10 +453,12 @@ class GapMapService:
354
  member = assign(build.vectors[rows], centroids)
355
  else:
356
  member = labels[rows]
357
- fresh = []
 
358
  if len(rows) > emitted:
359
  new_rows = rows[emitted:]
360
  coords = project_with(centre, plane, build.vectors[new_rows])
 
361
  for offset, row in enumerate(new_rows.tolist()):
362
  document = build.documents[row]
363
  title = str(document.get("title") or "")
@@ -374,8 +475,9 @@ class GapMapService:
374
  )
375
  positions = project_with(centre, plane, centroids)
376
  counts = np.bincount(member, minlength=len(centroids))
377
- await self._publish(
378
  {
 
379
  "event": "progress",
380
  "stage": "clustering" if iteration else "scanning",
381
  "iteration": iteration,
@@ -386,8 +488,10 @@ class GapMapService:
386
  "drawn": len(rows),
387
  "target": target,
388
  "complete": False,
 
389
  },
390
  "points": fresh,
 
391
  "pointRegions": [int(value) for value in member.tolist()],
392
  "regions": [
393
  {
@@ -410,19 +514,22 @@ class GapMapService:
410
  labels: np.ndarray,
411
  basis: tuple[np.ndarray, np.ndarray] | None,
412
  drawn: list[int],
413
- ) -> list[dict]:
414
- """2-D positions for the drawn subset, each carrying its region."""
 
 
 
 
415
  if basis is None or not regions or not drawn:
416
- return []
417
  centre, plane = basis
418
- rows = np.array(drawn, dtype=np.int64)
419
- coords = project_with(centre, plane, build.vectors[rows])
420
  known = {region["id"] for region in regions}
 
 
 
421
  points = []
422
  for offset, row in enumerate(rows.tolist()):
423
  region = int(labels[row])
424
- if region not in known:
425
- continue
426
  document = build.documents[row]
427
  title = str(document.get("title") or "")
428
  points.append(
@@ -436,7 +543,23 @@ class GapMapService:
436
  "year": document.get("year") if isinstance(document.get("year"), int) else None,
437
  }
438
  )
439
- return points
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
 
441
  # ---------------------------------------------------------------- serving
442
 
@@ -453,15 +576,10 @@ class GapMapService:
453
  placement["point"] = {"x": float(coords[0, 0]), "y": float(coords[0, 1])}
454
  return placement
455
 
456
- async def _place_expression(
457
- self, expression: MapArithmetic, built: dict, warnings: list[str]
458
- ) -> dict | None:
459
- """Place ``start − remove + add``: embed each phrase, combine, locate.
460
-
461
- The combined vector is treated like any other point on the map — what
462
- comes back are the real papers nearest it, so the honesty of the answer
463
- does not depend on how cleanly the analogy worked.
464
- """
465
  terms = [expression.start, *expression.remove, *expression.add]
466
  vectors = await asyncio.gather(*(self.embed(term) for term in terms))
467
  if any(vector is None for vector in vectors):
@@ -475,7 +593,16 @@ class GapMapService:
475
  warnings.append(
476
  "The expression cancels itself out; the combined vector has no direction."
477
  )
478
- return None
 
 
 
 
 
 
 
 
 
479
  placement = await asyncio.to_thread(
480
  place,
481
  combined,
@@ -498,10 +625,23 @@ class GapMapService:
498
  cutoff_year: int | None = None,
499
  refresh: bool = False,
500
  ) -> dict:
501
- built = await self.build(refresh=refresh)
502
  warnings: list[str] = []
 
 
 
 
 
 
 
 
503
  coverage = built["coverage"]
504
- if coverage["corpus"] > coverage["clustered"]:
 
 
 
 
 
 
505
  warnings.append(
506
  f"The map clusters {coverage['clustered']} of {coverage['corpus']} "
507
  "embedded studies, not the whole index."
@@ -512,22 +652,18 @@ class GapMapService:
512
  f"draws {coverage['drawn']} of them."
513
  )
514
  placement = None
515
- if arithmetic is not None:
516
- placement = await self._place_expression(arithmetic, built, warnings)
517
- elif idea:
518
- vector = await self.embed(idea)
519
- if vector is None:
520
- warnings.append("Embeddings are disabled, so the idea could not be placed.")
521
- else:
522
- placement = await asyncio.to_thread(
523
- place,
524
- vector,
525
- built["documents"],
526
- built["regions"],
527
- built["gaps"],
528
- vectors=built["vectors"],
529
- )
530
- placement = self._decorate(placement, vector, built)
531
  calibration = None
532
  if cutoff_year is not None:
533
  calibration = await asyncio.to_thread(
@@ -544,6 +680,7 @@ class GapMapService:
544
  "regions": [_public(region) for region in built["regions"]],
545
  "gaps": built["gaps"],
546
  "points": built["points"],
 
547
  "placement": placement,
548
  "calibration": calibration,
549
  "warnings": warnings,
@@ -584,9 +721,12 @@ class GapMapService:
584
  self, idea: str | None, arithmetic: MapArithmetic | None
585
  ) -> np.ndarray | None:
586
  """The unit vector of this viewer's question, if there is one to embed."""
587
- if arithmetic is not None or not idea:
 
 
 
 
588
  return None
589
- vector = await self.embed(idea)
590
  if vector is None:
591
  return None
592
  return normalize(np.asarray(vector, dtype=np.float32)[None, :])[0]
 
1
+ """Serves the gap map: cluster the studies around a question, describe them, place the question.
2
+
3
+ A map with a question is built from that question's neighborhood: the
4
+ ``gapmap_neighborhood`` studies nearest its vector, fetched by kNN, clustered
5
+ and drawn. That is a few hundred documents, so the build takes about a second
6
+ and the picture is one a reader can take in. Its coverage says exactly that —
7
+ the N nearest studies out of the embedded index — and never more.
8
+
9
+ Without a question there is nothing to center on, so the map falls back to the
10
+ whole embedded index. Covering the whole index means the build is long enough
11
+ to be worth watching, so it is written as a stream: documents are scanned in
12
+ pages, provisional centroids move as pages arrive, and full-corpus k-means then
13
+ runs to convergence, emitting the real intermediate state after every iteration.
14
+ Nothing emitted is interpolated or replayed — a partial map is labelled partial,
15
+ and only the final event describes the whole corpus. Measured on the live index
16
+ (2,096,736 embedded studies): 264 s end to end — roughly 130 s to scan at sixteen
17
+ slices and 25 bounded k-means iterations over the whole corpus in the rest,
18
+ peaking at 6.9 GB.
19
  """
20
 
21
  from __future__ import annotations
 
41
  find_gaps,
42
  fit_projection,
43
  kmeans_steps,
44
+ neighbor_edges,
45
  normalize,
46
  place,
47
  project_with,
 
52
 
53
  Progress = Callable[[dict], Awaitable[None]]
54
 
55
+ # Question maps kept in memory beside the corpus map.
56
+ MAP_CACHE = 16
57
+
58
 
59
  def _public(region: dict) -> dict:
60
  """Region without its centroid: a 384-float vector is not UI payload."""
 
150
  self.repo = repository
151
  self.config = config
152
  self.embedder = QueryEmbedder(config)
153
+ # One cached map per question (keyed by its vector) plus the corpus map.
154
+ self._maps: dict[bytes | None, dict] = {}
155
  self._lock = asyncio.Lock()
156
  self._subscribers: set[asyncio.Queue] = set()
157
+ self._tasks: dict[bytes | None, asyncio.Task] = {}
158
  # The plane the current build is drawing on, so a streamed question can be
159
  # placed on the same one before the build finishes.
160
  self._basis: tuple[np.ndarray, np.ndarray] | None = None
 
170
  continue
171
  queue.put_nowait(payload)
172
 
173
+ @property
174
+ def _built(self) -> dict | None:
175
+ """The cached corpus map, if it has been built."""
176
+ return self._maps.get(None)
177
+
178
+ async def build(self, refresh: bool = False, vector: np.ndarray | None = None) -> dict:
179
+ """The cached map for this question (or the corpus), building it once if needed.
180
 
181
  The build is a task of its own rather than a coroutine of whichever
182
  request arrived first: a viewer who closes the tab after twenty seconds
183
+ must not throw away a clustering run that other viewers, and the cache,
184
+ are waiting for.
185
  """
186
+ key = None if vector is None else np.asarray(vector, dtype=np.float32).tobytes()
187
+ if key in self._maps and not refresh:
188
+ return self._maps[key]
189
  async with self._lock:
190
+ if key in self._maps and not refresh:
191
+ return self._maps[key]
192
+ task = self._tasks.get(key)
193
+ if task is None or task.done():
194
+ task = asyncio.create_task(self._build(vector))
195
+ self._tasks[key] = task
196
+ built = await asyncio.shield(task)
197
+ if len(self._maps) >= MAP_CACHE and key not in self._maps:
198
+ for stale in [k for k in self._maps if k is not None][: len(self._maps) - MAP_CACHE + 1]:
199
+ del self._maps[stale]
200
+ self._maps[key] = built
201
+ return built
202
+
203
+ async def _build(self, vector: np.ndarray | None) -> dict:
204
+ if vector is None:
205
+ return await self._build_corpus()
206
+ return await self._build_neighborhood(vector)
207
+
208
+ async def _build_neighborhood(self, vector: np.ndarray) -> dict:
209
+ """Fetch the studies nearest the question, cluster them, describe the result."""
210
+ counting = asyncio.create_task(self.repo.count_embedded())
211
+ size = self.config.gapmap_neighborhood
212
+ query = normalize(np.asarray(vector, dtype=np.float32)[None, :])[0]
213
+ documents = await self.repo.knn_studies(query.tolist(), size)
214
+ build = MapBuild(0, capacity=size)
215
+ build.add(documents)
216
+ stride = max(1, len(build) // max(1, self.config.gapmap_points))
217
+ drawn = [
218
+ row
219
+ for row in range(len(build))
220
+ if _drawn(str(build.documents[row].get("id", row)), stride)
221
+ ]
222
+ basis: tuple[np.ndarray, np.ndarray] | None = None
223
+ if len(build):
224
+ basis = await asyncio.to_thread(fit_projection, build.vectors)
225
+ self._basis = basis
226
+ corpus = max(0, await counting)
227
+ build.corpus = max(corpus, len(build))
228
+ scope = {"scope": "neighborhood", "neighborhood": size}
229
+ centroids = None
230
+ labels = np.zeros(len(build), dtype=np.int64)
231
+ iteration = 0
232
+ emitted = 0
233
+ if len(build):
234
+ steps = kmeans_steps(
235
+ build.vectors,
236
+ self.config.gapmap_regions,
237
+ seed=self.config.gapmap_seed,
238
+ iterations=self.config.gapmap_iterations,
239
+ )
240
+ while True:
241
+ step = await asyncio.to_thread(next, steps, None)
242
+ if step is None:
243
+ break
244
+ centroids, labels = step
245
+ iteration += 1
246
+ if basis is not None:
247
+ emitted = await self._emit_partial(
248
+ build,
249
+ centroids,
250
+ basis,
251
+ drawn,
252
+ emitted,
253
+ len(build),
254
+ iteration=iteration,
255
+ labels=labels,
256
+ extra={"coverage": scope},
257
+ )
258
+ return await self._finish(build, centroids, labels, basis, drawn, iteration, scope)
259
+
260
+ async def _build_corpus(self) -> dict:
261
  """Scan the embedded corpus, cluster it, and describe the result."""
262
  # Counted beside the scan rather than before it: the count is a query of
263
  # its own over two million documents, and nothing can be drawn while it
 
359
  labels=labels,
360
  )
361
 
362
+ return await self._finish(
363
+ build, centroids, labels, basis, drawn, iteration, {"scope": "corpus"}
364
+ )
365
+
366
+ async def _finish(
367
+ self,
368
+ build: MapBuild,
369
+ centroids: np.ndarray | None,
370
+ labels: np.ndarray,
371
+ basis: tuple[np.ndarray, np.ndarray] | None,
372
+ drawn: list[int],
373
+ iteration: int,
374
+ scope: dict,
375
+ ) -> dict:
376
+ """Describe a clustered build: regions, gaps, drawn points, edges, coverage."""
377
+ corpus = build.corpus
378
  clusters_used = len(centroids) if centroids is not None else 0
379
  regions = await asyncio.to_thread(
380
  describe_regions, build.documents, build.vectors, labels, clusters_used
 
382
  gaps = await asyncio.to_thread(
383
  find_gaps, build.documents, regions, vectors=build.vectors
384
  )
385
+ points, kept = self._points(build, regions, labels, basis, drawn)
386
+ edges = await asyncio.to_thread(self._edges, build, kept)
387
  if basis is not None and regions:
388
  centre, plane = basis
389
  positions = project_with(
 
399
  "regions": regions,
400
  "gaps": gaps,
401
  "points": points,
402
+ "edges": edges,
403
  "basis": basis,
404
  "coverage": {
405
  "clustered": len(build),
 
407
  "regions": len(regions),
408
  "drawn": len(points),
409
  "complete": True,
410
+ **scope,
411
  },
412
  "version": MAP_VERSION,
413
  }
414
  logger.info(
415
+ "Gap map (%s) built over %s of %s embedded studies in %s regions after %s iterations",
416
+ scope.get("scope"),
417
  len(build),
418
  corpus,
419
  len(regions),
 
432
  target: int,
433
  iteration: int | None = None,
434
  labels: np.ndarray | None = None,
435
+ publish: Progress | None = None,
436
+ extra: dict | None = None,
437
  ) -> int:
438
  """Publish the map as it stands: new points, where the regions are now.
439
 
440
  Only the points the canvas has not seen are sent in full; the ones it
441
  already holds are recoloured through ``pointRegions``, because a region
442
  a point belongs to changes on every k-means iteration while its position
443
+ does not. Frames go to ``publish`` when given (one viewer's own build)
444
+ and to every subscriber otherwise (the shared corpus build).
445
  """
446
+ if publish is None and not self._subscribers:
447
  return emitted
448
  centre, plane = basis
449
  rows = np.array(drawn, dtype=np.int64)
 
453
  member = assign(build.vectors[rows], centroids)
454
  else:
455
  member = labels[rows]
456
+ fresh: list[dict] = []
457
+ edges: list[list[float]] = []
458
  if len(rows) > emitted:
459
  new_rows = rows[emitted:]
460
  coords = project_with(centre, plane, build.vectors[new_rows])
461
+ edges = await asyncio.to_thread(self._edges, build, rows.tolist(), emitted)
462
  for offset, row in enumerate(new_rows.tolist()):
463
  document = build.documents[row]
464
  title = str(document.get("title") or "")
 
475
  )
476
  positions = project_with(centre, plane, centroids)
477
  counts = np.bincount(member, minlength=len(centroids))
478
+ await (publish or self._publish)(
479
  {
480
+ **(extra or {}),
481
  "event": "progress",
482
  "stage": "clustering" if iteration else "scanning",
483
  "iteration": iteration,
 
488
  "drawn": len(rows),
489
  "target": target,
490
  "complete": False,
491
+ **(extra or {}).get("coverage", {}),
492
  },
493
  "points": fresh,
494
+ "edges": edges,
495
  "pointRegions": [int(value) for value in member.tolist()],
496
  "regions": [
497
  {
 
514
  labels: np.ndarray,
515
  basis: tuple[np.ndarray, np.ndarray] | None,
516
  drawn: list[int],
517
+ ) -> tuple[list[dict], list[int]]:
518
+ """2-D positions for the drawn subset, each carrying its region.
519
+
520
+ Also returns the rows drawn, in point order, so edges can be indexed
521
+ the same way the canvas indexes points.
522
+ """
523
  if basis is None or not regions or not drawn:
524
+ return [], []
525
  centre, plane = basis
 
 
526
  known = {region["id"] for region in regions}
527
+ kept = [row for row in drawn if int(labels[row]) in known]
528
+ rows = np.array(kept, dtype=np.int64)
529
+ coords = project_with(centre, plane, build.vectors[rows])
530
  points = []
531
  for offset, row in enumerate(rows.tolist()):
532
  region = int(labels[row])
 
 
533
  document = build.documents[row]
534
  title = str(document.get("title") or "")
535
  points.append(
 
543
  "year": document.get("year") if isinstance(document.get("year"), int) else None,
544
  }
545
  )
546
+ return points, kept
547
+
548
+ def _edges(self, build: MapBuild, drawn: list[int], start: int = 0) -> list[list[float]]:
549
+ """Similarity edges among drawn points, indexed by position in ``drawn``.
550
+
551
+ ``start`` is how many of them already have their edges, so a streamed
552
+ frame only pays for the rows it adds. Each edge is ``[i, j, cosine]``.
553
+ """
554
+ if len(drawn) < 2:
555
+ return []
556
+ found = neighbor_edges(
557
+ build.vectors[np.array(drawn, dtype=np.int64)],
558
+ start=start,
559
+ neighbors=self.config.gapmap_edge_neighbors,
560
+ floor=self.config.gapmap_edge_floor,
561
+ )
562
+ return [[i, j, round(cosine, 3)] for i, j, cosine in found]
563
 
564
  # ---------------------------------------------------------------- serving
565
 
 
576
  placement["point"] = {"x": float(coords[0, 0]), "y": float(coords[0, 1])}
577
  return placement
578
 
579
+ async def _expression_vector(
580
+ self, expression: MapArithmetic, warnings: list[str]
581
+ ) -> np.ndarray | None:
582
+ """The unit vector of ``start − remove + add``: embed each phrase, combine."""
 
 
 
 
 
583
  terms = [expression.start, *expression.remove, *expression.add]
584
  vectors = await asyncio.gather(*(self.embed(term) for term in terms))
585
  if any(vector is None for vector in vectors):
 
593
  warnings.append(
594
  "The expression cancels itself out; the combined vector has no direction."
595
  )
596
+ return combined
597
+
598
+ async def _place_expression(
599
+ self, expression: MapArithmetic, combined: np.ndarray | list[float], built: dict
600
+ ) -> dict:
601
+ """Place the combined vector like any other point on the map.
602
+
603
+ What comes back are the real papers nearest it, so the honesty of the
604
+ answer does not depend on how cleanly the analogy worked.
605
+ """
606
  placement = await asyncio.to_thread(
607
  place,
608
  combined,
 
625
  cutoff_year: int | None = None,
626
  refresh: bool = False,
627
  ) -> dict:
 
628
  warnings: list[str] = []
629
+ vector: np.ndarray | list[float] | None = None
630
+ if arithmetic is not None:
631
+ vector = await self._expression_vector(arithmetic, warnings)
632
+ elif idea:
633
+ vector = await self.embed(idea)
634
+ if vector is None:
635
+ warnings.append("Embeddings are disabled, so the idea could not be placed.")
636
+ built = await self.build(refresh=refresh, vector=vector)
637
  coverage = built["coverage"]
638
+ if coverage.get("scope") == "neighborhood":
639
+ warnings.append(
640
+ f"The map shows the {coverage['clustered']} embedded studies nearest this "
641
+ f"question, out of {coverage['corpus']} in the index: the question's "
642
+ "neighborhood, not the whole index."
643
+ )
644
+ elif coverage["corpus"] > coverage["clustered"]:
645
  warnings.append(
646
  f"The map clusters {coverage['clustered']} of {coverage['corpus']} "
647
  "embedded studies, not the whole index."
 
652
  f"draws {coverage['drawn']} of them."
653
  )
654
  placement = None
655
+ if vector is not None and arithmetic is not None:
656
+ placement = await self._place_expression(arithmetic, vector, built)
657
+ elif vector is not None:
658
+ placement = await asyncio.to_thread(
659
+ place,
660
+ vector,
661
+ built["documents"],
662
+ built["regions"],
663
+ built["gaps"],
664
+ vectors=built["vectors"],
665
+ )
666
+ placement = self._decorate(placement, vector, built)
 
 
 
 
667
  calibration = None
668
  if cutoff_year is not None:
669
  calibration = await asyncio.to_thread(
 
680
  "regions": [_public(region) for region in built["regions"]],
681
  "gaps": built["gaps"],
682
  "points": built["points"],
683
+ "edges": built.get("edges", []),
684
  "placement": placement,
685
  "calibration": calibration,
686
  "warnings": warnings,
 
721
  self, idea: str | None, arithmetic: MapArithmetic | None
722
  ) -> np.ndarray | None:
723
  """The unit vector of this viewer's question, if there is one to embed."""
724
+ if arithmetic is not None:
725
+ vector = await self._expression_vector(arithmetic, [])
726
+ elif idea:
727
+ vector = await self.embed(idea)
728
+ else:
729
  return None
 
730
  if vector is None:
731
  return None
732
  return normalize(np.asarray(vector, dtype=np.float32)[None, :])[0]