@harperfast/harper 5.3.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
@@ -13,15 +13,14 @@ Index of every design note: [DESIGN.md](../../DESIGN.md).
13
13
  The ef auto-scale needs to know how big the graph is, on every query. Two sources that look right
14
14
  are not.
15
15
 
16
- `getKeysCount()` on a RocksDB store is an exact key scan, so it is O(N): measured at 13 ms per call
17
- at 10K keys, 128 ms at 100K, ~1 s at 500K. Calling it per query puts a linear-in-corpus-size term in
18
- front of every vector search — 34% of query latency at 20K vectors on the real table stack.
16
+ `getKeysCount()` on a RocksDB store is an exact key scan, so it is O(N); calling it per query puts a
17
+ linear-in-corpus-size term in front of every vector search (measured in #2125).
19
18
 
20
19
  RocksDB's `rocksdb.estimate-num-keys` property is O(1) and looks like the obvious replacement, but it
21
20
  counts entries across memtable and SST files without reconciling overwrites. Building an HNSW graph
22
- rewrites each node many times as its neighbours change, so on a real index it reads far high: 37,775
23
- for a 2,000-record table whose exact key count is 4,001, and worse after deletes. It reads exact on a
24
- fresh store with simple puts, so it validates clean in isolation and only misleads on a real index.
21
+ rewrites each node many times as its neighbours change, so on a real index it reads far high (#2125),
22
+ and worse after deletes. It reads exact on a fresh store with simple puts, so it validates clean in
23
+ isolation and only misleads on a real index.
25
24
 
26
25
  Node ids are the sound source. They are allocated monotonically from a `getUserSharedBuffer` counter,
27
26
  so the counter (or one reverse seek to the largest id) gives the node count in O(1), unaffected by
@@ -38,24 +37,16 @@ reason, and any change between the two units has to move it to keep the resolved
38
37
 
39
38
  Each layer above 0 exists to hand the next layer down an entry point: `search()` and `index()` both
40
39
  take `results[0]` and discard the rest. Searching them at the full `ef` therefore buys nothing and
41
- costs work proportional to the layer's population rather than to ef — layer 1 holds ~N/M nodes, and
42
- at ef 512 a query visited ~95% of it. That is a second linear-in-N term: upper-layer visits per query
43
- grew 342 → 2,421 across 5K → 41K vectors on real embeddings, and reached 75% of query time at 100K. Greedy descent
44
- (`ROUTING_EF`) is what standard HNSW does. Measured against the same graphs searched at the full `ef`
45
- on every layer, across 16 (size, `ef`) points on a held-out real-embedding corpus, the worst
46
- recall@10 change was -0.002 — one displaced neighbour at a single point — and 0.000 everywhere else.
40
+ costs work proportional to the layer's population rather than to ef — layer 1 holds ~N/M nodes, a
41
+ second linear-in-N term. Greedy descent (`ROUTING_EF`) is what standard HNSW does; against the same
42
+ graphs searched at the full `ef` on every layer it changed recall@10 at only one of 16 measured
43
+ (size, `ef`) points, by one displaced neighbour (#2125).
47
44
 
48
45
  The connection-building pass in `index()` is not routing — it selects the edges that get stored — so
49
- it keeps `efConstruction`.
50
-
51
- The insert-side change is the one that alters stored graphs, recoverable only by a reindex, so it was
52
- measured separately (`benchmarks/hnsw-scale.js --build-upper-ef=100` restores the previous
53
- index-time descent). At 20,000 real 768-dim embeddings with identical corpus and level assignments,
54
- the two builds were indistinguishable on every metric measured — same recall at each `ef`, same visit
55
- counts, same mean layer-0 degree — and the greedy build was 1.28x faster. That is consistent with the
56
- graphs being identical, though equal metrics do not prove it. It is the expected result either way:
57
- the upper layers are sparse enough that a greedy walk reaches the same entry point, which is why
58
- standard HNSW descends this way.
46
+ it keeps `efConstruction`. The insert-side descent does alter stored graphs, recoverable only by a
47
+ reindex; its A/B (`benchmarks/hnsw-scale.js --build-upper-ef=100` restores the previous index-time
48
+ descent) found the two builds indistinguishable on every measured metric (#2125), as expected: the
49
+ upper layers are sparse enough that a greedy walk reaches the same entry point.
59
50
 
60
51
  Greedy-equals-full is statistical, not per-graph: rare level layouts route to a different layer-0
61
52
  entry point and displace the tail of the top-k (~2-3% of random 600-node graphs in the unit test's
@@ -70,13 +61,12 @@ necessarily a routing regression.
70
61
 
71
62
  The connection-building pass selects each node's stored edges from a candidate list of
72
63
  `efConstruction` entries. Held at a constant (100) while the corpus grows, edge quality erodes in a
73
- way no search-side setting can compensate: at 1M nodes (768-dim, int8, calibrated hard corpus)
74
- recall@10 fell to 0.935 and sweeping the search `ef` from 512 to 1536 only reached 0.957 raw / 0.967
75
- set at 4.7x the latency — the missing neighbours were not deep in the candidate list, they were
76
- unreachable. Rebuilding the identical corpus (same seed, same level assignments) with
77
- `efConstruction` 200 restored 0.985/0.997 and made queries _faster_ at the same `ef` (3,110 nodes
78
- visited vs 3,948 — better-selected edges route more directly). Quantization contributed ~1.5 points
79
- (float32 rebuild: 0.952); construction quality was the dominant term. Full sweep in #2180.
64
+ way no search-side setting can compensate: at 1M nodes, sweeping the search `ef` from 512 to 1536
65
+ moved recall@10 only from 0.935 to 0.957 (0.967 set) at 4.7x the latency — the missing neighbours
66
+ were not deep in the candidate list, they were unreachable. Rebuilding the identical corpus with
67
+ `efConstruction` 200 restored recall and made queries _faster_ at the same `ef` (better-selected
68
+ edges route more directly); quantization was a minor term beside construction quality. Full sweep in
69
+ #2180.
80
70
 
81
71
  So when the schema does not configure `efConstruction`, it scales as `AUTO_EF_BASE * sqrt(nodes /
82
72
  AUTO_EFC_REF)`, capped at `AUTO_EFC_MAX`. The healthy write path reads the count directly from the
@@ -94,24 +84,19 @@ to 100. Retaining the former large-graph search default while opting out of buil
94
84
  an explicit `efConstructionSearch` as well (512 after the former auto-scale reached its plateau).
95
85
  There is currently no "pinned build, auto search" combination.
96
86
 
97
- The search side scales past its old plateau for the same reason. `AUTO_EF_MAX` (512, pinned from
98
- ~13K nodes) was calibrated when layers above 0 were searched at the full `ef`, which made large efs
99
- cost seconds; after the greedy-descent fix the same headroom costs tens of milliseconds (ef 1024 at
100
- 5M nodes: ~45ms p50), and holding the pin leaves measured recall on the table — set-recall at a
101
- pinned 512 on well-built graphs decays 0.997 → 0.955 → 0.935 across 1M/2M/5M. So past
102
- `AUTO_EF_LARGE_REF` (1M nodes, where 512 was last measured sufficient) the scale resumes from the
103
- plateau — `512 * sqrt(nodes / 1M)` — up to `AUTO_EF_CEILING` (2048, binding at ~16M). The 5M point
104
- resolves 1,145, bracketed by the measured ef-1024 sweep there (0.985 set). The default's query
105
- latency therefore grows as sqrt(N) on large tables; that is the recall-first trade chosen here, and
106
- apps preferring latency pin `efConstructionSearch` or a per-query `ef`. The filtered-traversal
107
- budget (`maxVisits`, #1241) deliberately does not follow the second regime: each budgeted visit is
108
- a synchronous record load plus predicate evaluation, so an auto-scaled ef's budget contribution
109
- stays capped at `AUTO_EF_MAX` — the recall decision and the filtered-scan bound are separate
110
- decisions, and an explicit ef (per-query or schema) still raises the budget for callers who own
111
- the cost. Both ceilings are finite on
112
- purpose: total build work grows as N^1.5 under sqrt scaling, and past roughly tens of millions of
113
- nodes per graph, sharded medium graphs beat one huge graph on build and query cost alike — scaling
114
- the constants further is the wrong tool there.
87
+ The search side scales past its old plateau for the same reason: past `AUTO_EF_LARGE_REF` (1M
88
+ nodes) the scale resumes from `AUTO_EF_MAX` (512) — `512 * sqrt(nodes / 1M)` — up to
89
+ `AUTO_EF_CEILING` (2048, binding at ~16M). Why the plateau was calibrated where it was, and the
90
+ recall a pinned 512 leaves on the table past 1M, are in the comment above `AUTO_EF_BASE` (#2181).
91
+ The default's query latency therefore grows as sqrt(N) on large tables; that is the recall-first
92
+ trade chosen here, and apps preferring latency pin `efConstructionSearch` or a per-query `ef`. The
93
+ filtered-traversal budget (`maxVisits`, #1241) deliberately does not follow the second regime: each
94
+ budgeted visit is a synchronous record load plus predicate evaluation, so an auto-scaled ef's budget
95
+ contribution stays capped at `AUTO_EF_MAX` — the recall decision and the filtered-scan bound are
96
+ separate decisions, and an explicit ef (per-query or schema) still raises the budget for callers who
97
+ own the cost. Both ceilings are finite on purpose: total build work grows as N^1.5 under sqrt
98
+ scaling, and past roughly tens of millions of nodes per graph, sharded medium graphs beat one huge
99
+ graph on build and query cost alike — scaling the constants further is the wrong tool there.
115
100
 
116
101
  Two caveats are accepted deliberately, both inherited from the count being a lifetime high-water
117
102
  mark of allocated node ids rather than a live count. First, churn: a table that deletes heavily
@@ -127,9 +112,8 @@ that point on. A reindex in a live process rebuilds roughly uniformly (the id co
127
112
  high-water mark), but a reindex after a restart re-seeds the counter from the largest id in the
128
113
  rebuilding store and therefore repeats the ramp — its first 250K nodes rebuild at the base efC.
129
114
  Later inserts add reverse edges to older nodes, but a default-ramp 1M build has not been compared
130
- directly with the uniform-200 A/B. The larger default-ramp runs reached 0.988 set-recall at 2M and
131
- 0.985 at 5M when searched at ef 1024, which shows that the measured neighbours remained reachable
132
- at those sizes without proving uniform convergence.
115
+ directly with the uniform-200 A/B; the larger default-ramp runs (#2181) kept the measured neighbours
116
+ reachable at 2M and 5M without proving uniform convergence.
133
117
 
134
118
  Deletes have a separate tail-latency cost: connectivity repair can synchronously reinsert an orphan
135
119
  and up to 256 nodes from a severed island. Those reinserts use the current auto-scaled efC, so the
@@ -205,6 +189,14 @@ a configured `filterExpansion` stays authoritative. `CandidateKeySet` is the one
205
189
  the scan's intersection and admission. A node-id bitset for the native plane is not built: the
206
190
  pk → node-id lookup costs more than the record load it saves.
207
191
 
192
+ ## Filtered full-text search reuses companion secondary indexes before loading source records
193
+
194
+ Full-text search uses the same `planCandidateKeys` contract as HNSW and chooses between two existing RocksDB access patterns. A selective range becomes a bounded key set; a broad equality condition uses one point read of the existing `[indexed value, primary key]` secondary-index entry per Tantivy hit. This avoids retaining a large set only to reject non-matching hits. Materialization has a 4,096-key budget. HNSW keeps the planner's synchronous collection path; Fulltext requests its cooperative path, which yields every 256 scanned entries to observe cancellation, transaction state and the query deadline.
195
+
196
+ Point probes run only when their estimated index-read cost does not exceed the source loads they should avoid. The estimate uses the covered terms' selectivity and the same eight-key-reads-per-source-read ratio as materialization. Candidate-set budgets use the expected rejection fraction and the native reported hit count, including unbounded queries whose target fills the native window.
197
+
198
+ The planner marks a gate complete only when its indexed terms cover every companion condition, the index encoding preserves the predicate's equality semantics and no opaque record guard remains. An indexed numeric equality on an `Any` field is incomplete because storage can encode an equivalent `number` and `bigint` together while the record predicate compares them strictly. An incomplete gate rejects definite misses, but admitted records still run the residual predicate. Both paths retain the normal post-filter, source-version, expiry and current-entry checks. A budget overrun or point-read failure falls back to the record predicate, briefly backs off the failing gate and emits one warning per failure episode. Unless the lead index requests point probes, the planner does not inspect probe capabilities or allocate probe state; ordinary HNSW traversal stays unchanged.
199
+
208
200
  ## Derived-index runtime: committed-log delivery to native index backends (`resources/derivedIndexRuntime.ts`)
209
201
 
210
202
  A derived index (the native HNSW or Tantivy full-text plane) is a materialized view
@@ -248,15 +240,16 @@ turn. Ownership is sticky: the lock is not one record writers take, so holding i
248
240
  delays no commit, and it is released only after an idle grace period once durable progress equals
249
241
  offered progress. The intended wake for a waiting runner is the lock's own unlock callback
250
242
  (`tryLock(key, onUnlocked)`): one primitive, and a holder that dies releases natively and wakes the
251
- waiters the same way. **Temporarily** the lock is taken without one: the pinned rocksdb-js queues
243
+ waiters the same way. **Temporarily** the lock is taken without one: rocksdb-js before 2.9.1 queued
252
244
  that callback as a thread-safe function of the caller's env, and on Node 22 one left behind by a
253
245
  worker that was `terminate()`d aborts the process when another thread unlocks (Harper's thread
254
- manager terminates workers on restart). Until the pin includes the fix (HarperFast/rocksdb-js#849),
255
- successors are woken by the releasing owner's `notify()` on the readiness buffer and, for an owner
256
- that died without releasing, by a retry timer (`lockRetryMilliseconds`, 5 s by default); the
257
- releasing runner ignores the one notification it caused, and a runner already parked on that timer
258
- does not re-probe the lock on commit wakes. This is a workaround with a tracked revert — the callback
259
- path is simpler and picks up a dead owner immediately.
246
+ manager terminates workers on restart). The pinned 2.10.0 includes the fix
247
+ (HarperFast/rocksdb-js#849, released in 2.9.1), but the workaround is not yet reverted: successors
248
+ are woken by the releasing owner's `notify()` on the `derived-index:<id>:wake` buffer (the readiness
249
+ buffer when the registration has no generation-specific readiness id) and, for an owner that died
250
+ without releasing, by a retry timer (`lockRetryMilliseconds`, 5 s by default); the releasing runner
251
+ ignores the one notification it caused, and a runner already parked on that timer does not re-probe
252
+ the lock on commit wakes. The callback path is simpler and picks up a dead owner immediately.
260
253
 
261
254
  Configuration-level database aliases can load several table classes over the same physical audit
262
255
  store, index column family and plane file. They must not run that backend more than once. Registration
@@ -308,28 +301,11 @@ record values. Any other projection or primary-read failure is fail-closed.
308
301
 
309
302
  ### Backend contract
310
303
 
311
- ```ts
312
- interface DerivedIndexBackend {
313
- readonly id: string;
314
- attach(host: { isOwnerEpoch(epoch: bigint): boolean; getReadiness(): DerivedIndexReadiness }): void;
315
- getDurableCursor(): DerivedIndexCursor | undefined;
316
- deliver(batch: DerivedIndexBatch): DERIVED_INDEX_ACCEPTED | DERIVED_INDEX_DEFERRED | DERIVED_INDEX_FAILED;
317
- flush(reason: 'age' | 'threshold' | 'shutdown'): void | Promise<void>; // barrier request
318
- shutdown(ownerEpoch: bigint): void | Promise<void>; // quiescence: nothing further applies or publishes for the epoch
319
- onStateChange(wake: (change?: 'changed' | 'accepted-work-lost' | 'failed') => void): () => void;
320
- reset?(ownerEpoch: bigint): void | Promise<void>; // destroy state and cursor; first durable action invalidates the cursor
321
- }
322
- type DerivedIndexBatch = {
323
- ownerEpoch: bigint;
324
- transactions: { logName; timestamp; mutations }[];
325
- records: DerivedIndexMutation[]; // last-write-wins per (tableId, writeKeyId(recordId)); non-enumerable
326
- through?: DerivedIndexCursor; // absent on a rebuild scan chunk and while a transaction is still open
327
- bytes: number; // estimate; non-enumerable
328
- rebuild?: true;
329
- };
330
- ```
331
-
332
- There is one contract and every hook is required. What an ownership handoff has to fence is work
304
+ The contract is `DerivedIndexBackend` and `DerivedIndexBatch` in `derivedIndexRuntime.ts`; their doc
305
+ comments state each hook's obligation. Three facts they do not: `records` and `bytes` are
306
+ non-enumerable (a backend that spreads or clones the batch loses them), `bytes` is an estimate, and
307
+ `through` is also absent while a transaction is still open (see Bounded delivery). What an ownership
308
+ handoff has to fence is work
333
309
  that survives a method return — a queued apply, a barrier that completes later, a cursor that
334
310
  trails delivery — so every backend supplies the epoch fence, the barrier request and the quiescence
335
311
  handshake; one that completes inside `deliver()` implements them trivially. `deliver()` is not
@@ -533,19 +509,15 @@ plaintext, so Harper authorizes every highlighted source field before search; se
533
509
  by both record count and encoded source bytes and sends only the fields used by that query leaf. A
534
510
  single record larger than the native trace limit is rejected before crossing the binding.
535
511
 
536
- Search hits carry the source record version. Harper loads the authoritative record through its read
537
- transaction and omits a hit when that version no longer matches; it never attaches an old score or
538
- highlight to new content. Bounded searches over-fetch at least 32 and at most 256 native hits per
539
- page, growing through the native result window only when version checks or structured filters reject
540
- candidates. Exhausting that window because native versions are stale is retryable index lag; filter-
541
- only exhaustion remains a client error asking for a narrower query. During bounded lag a smaller
542
- result can still temporarily omit a recently changed record when the native result set ends before
543
- the search window is exhausted.
544
- REST exposes the index coverage header, and callers that require current coverage use
545
- `maxIndexLagMilliseconds: 0` or `waitForIndexMilliseconds`.
546
- Full-text score descending is the only supported ordering in this release. Count requests return
547
- `recordCount: null` and `recordCountExact: false`; the native candidate total cannot become an exact
548
- Harper count after authorization, structured filtering and source-version checks.
512
+ Search hits carry the source record version. Harper reads the authoritative record in its transaction and omits a hit when the version no longer matches; it never combines an old score or highlight with new content. Bounded searches begin by over-fetching 32–256 native hits. When version checks or structured filters under-fill a page, Harper sizes the next request from observed yield. Pages grow within the native response envelope, with 4,096 as an absolute ceiling, then shrink as selectivity improves; a zero-yield page grows geometrically because it provides no selectivity estimate.
513
+
514
+ Each page yields after at most 256 estimated source and companion-index point reads to check cancellation and the execution deadline. Equality point probes are limited to four index reads per candidate; wider equality plans use the authoritative record predicate. The native result window remains the hard bound, and one reader lease keeps every page on one native snapshot.
515
+
516
+ Harper does not configure native `filterFields` or send native `filter` or `candidateIds` payloads. Candidate planning, companion-index probes and the authoritative record predicate remain Harper-owned; the wrapper's candidate limits do not cap those mechanisms.
517
+
518
+ A lower-bound total must describe retrievable hits: while more hits remain, a page must return its requested limit. Fulltext 0.5.0 checks its deadline around the complete Tantivy collection and returns `E_TIMEOUT` instead of a partial page. Harper fails closed on a short page claiming more hits. Stale-version exhaustion observed in loaded records or a bounded sample of companion-index rejections is retryable index lag; filter-only exhaustion asks the client to narrow the query. Stale rejections outside that sample can still be classified as filter exhaustion; the bounded diagnostic avoids unbounded source reads. During bounded lag, results can temporarily omit a recent change when native hits end before the window.
519
+
520
+ Harper clamps autocomplete to the maximum-size hits that fit one native response without reducing ordinary search. The response-capacity calculation is pinned to the Fulltext 0.5.0 Query API v3 framing: 13 response bytes plus 13 bytes per versioned hit before string data. A later query API must advertise or version its framing overhead before Harper accepts it. REST exposes the index coverage header; callers requiring current coverage use `maxIndexLagMilliseconds: 0` or `waitForIndexMilliseconds`. Full-text score descending is the only supported ordering in this release. Count requests return `recordCount: null` and `recordCountExact: false`; the native candidate total cannot become an exact Harper count after authorization, structured filtering and source-version checks.
549
521
 
550
522
  A declared Blob source is part of one index document. An oversized, invalid UTF-8 or otherwise
551
523
  permanently unusable Blob makes that whole document unindexable rather than publishing a partial
@@ -612,24 +584,13 @@ consumes at its next wake.
612
584
  ### Native HNSW query coverage
613
585
 
614
586
  Generation readiness and read freshness are separate. A `ready` native index can still be applying
615
- committed mutations. Native vector sort/threshold conditions accept `maxIndexLagMilliseconds`:
616
- **3000 ms by default**, `0` for strict coverage, or an explicit finite nonnegative number. This default
617
- allows three ordinary 1000 ms flush ages; it does not change the separate writer backpressure budget.
618
- Synchronous/non-native indexes ignore the native coverage options. For example, an HTTP QUERY body can contain:
619
-
620
- ```json
621
- {
622
- "sort": {
623
- "attribute": "vector",
624
- "target": [1, 0, 0, 0],
625
- "distance": "cosine",
626
- "maxIndexLagMilliseconds": 0
627
- },
628
- "limit": 10
629
- }
630
- ```
587
+ committed mutations. Native vector sort/threshold conditions accept `maxIndexLagMilliseconds` on the
588
+ sort object or the vector condition: **3000 ms by default** (`DEFAULT_MAX_INDEX_LAG_MILLISECONDS`),
589
+ `0` for strict coverage, or an explicit finite nonnegative number. This default allows three ordinary
590
+ 1000 ms flush ages; it does not change the separate writer backpressure budget. Synchronous/non-native
591
+ indexes ignore the native coverage options.
631
592
 
632
- For read-after-write, add `waitForIndexMilliseconds: 10000` to that sort (or vector condition).
593
+ For read-after-write, the same sort or condition takes `waitForIndexMilliseconds`.
633
594
  Omitted or `0` preserves immediate admission; a positive finite number, capped at **30,000 ms**,
634
595
  opts into a bounded wait for coverage of writes committed before the native search begins on first iteration.
635
596
  The wait takes precedence over lag tolerance: a recent but stale proof cannot satisfy it. An already
@@ -637,7 +598,8 @@ physically current index proceeds immediately. Otherwise the query captures one
637
598
  waits for the owner's certified time to reach it; later writes never reset the target. Each consumed
638
599
  waiting branch has its own budget; sequential OR or concatenated branches can take longer in total.
639
600
  A timeout throws retryable `DERIVED_INDEX_LAGGING`. On an already-started HTTP stream this is an error
640
- record, not a new HTTP status. Request cancellation and iterator closure also end pending waits.
601
+ record, not a new HTTP status. Request cancellation and iterator closure also end pending waits and
602
+ skip late result materialization.
641
603
 
642
604
  Waiting preserves the normal 1000 ms flush-age schedule: a small write followed by a search commonly
643
605
  waits about a second plus barrier time. Shorter deadlines are valid but may expire. Replay must reach
@@ -847,45 +809,35 @@ per-query distance overrides are rejected; the default capacity is 16M nodes; an
847
809
  to a populated table keeps searches unavailable for the rebuild, which can take hours at large
848
810
  sizes. `nativePlane: false` is the durable per-index opt-out.
849
811
 
850
- Why the whole search loop is native and not just the distance kernel: at 5M nodes / ef 512, ~85% of
851
- a warm JS visit is object bookkeeping (candidate heap, visited `Set`, property access, GC), the int8
852
- cosine is 10%, and a warm RocksDB `Get` per visit (~1–2 µs) is 20–40× the SIMD distance it feeds. A
853
- native loop over direct-addressed slots (`base + id × slot_size`, ~100–200 ns) with one NAPI crossing
854
- per query is the only shape that reaches the ceiling; measured 7.2 ms → 0.75 ms p50 at 1M × 768-d,
855
- recall@10 0.997 → 0.999.
812
+ The plane's own design — why the whole search loop is native rather than just the distance kernel,
813
+ the file format, per-slot locking and dead-writer takeover, `msync` durability, the search path and
814
+ the platform policy — belongs to the crate and lives in the `DESIGN.md` shipped with
815
+ `@harperfast/hnsw` (HarperFast/hnsw); Harper's measured result is in #2430. What Harper owns:
856
816
 
857
817
  ### File format
858
818
 
859
- One file per index, `<index store path>/<store name>.hnsw`, created sparse at `nativePlaneMaxNodes`
860
- slots (16M default; a structural, create-time header field — exhausting it makes the index
861
- unavailable until the value is raised and the index rebuilt). Header page: magic + format version
862
- (mismatch → rebuild, by contract), dims, quantization mode, `slot_size`/`layer0_cap`/`upper_cap`
863
- (`layer0_cap` from `nativePlaneLayer0Cap` at creation; `upper_cap` fixed at 64 by the crate), entry point, atomic `id_high_water`, tag-guarded
864
- freelist head, a transaction watermark advanced only after an `msync` barrier, and a clean-shutdown
865
- flag. Layer-0 slot: seqlock word, flags + level, `scale`/`invMag`, degree, int8 vector padded to a
866
- 4-byte boundary, `u32` neighbour ids, then the record's msgpack-encoded primary key (format v8;
867
- `nativePlaneKeyCap` inline bytes, default 40; a longer key spills to an overflow arena after the
868
- upper region, reserved at max(128, 4 × keyCap) bytes per node — a table whose keys are mostly
869
- longer than 40 encoded bytes should raise `nativePlaneKeyCap` rather than live in the arena) — at the default degree cap of 64, 1,088 B at 768-d and 448 B at 128-d, the key fitting the cache-line padding.
870
- Upper layers (~6% of nodes) live in a fixed-entry region in the same file, per-entry seqlocked.
871
- Per-edge cached distances are dropped: recomputing costs ~50 ns natively, storing costs 8 B and
872
- ~40% of a node. Searches and predicate batches return each hit's key with it, so no lookup by node
873
- id remains on the query path.
874
-
875
- Degree cap is the per-index `nativePlaneLayer0Cap`, **default 64** (supersedes the fixed 128 of
876
- 2026-08-31, re-measured in [hnsw#14](https://github.com/HarperFast/hnsw/pull/14)): at 128-d and
877
- 768-d int8, cap 64 holds recall@10 within ~0.5 pt of cap 128 at every ef ≥ 128 at 1M and 4M, and
878
- within ~0.3 pt at 768-d, at the same resident latency — while cutting the 128-d slot 704 → 448 B and
879
- the 768-d slot 1,344 → 1,088 B, which keeps a 4M-node plane resident under a 2 GB limit that makes
880
- the cap-128 plane thrash. A live 7.7M-node plane carries a mean layer-0 degree of 29
819
+ One file per index, `<index store path>/<URI-encoded store name>.hnsw` (`hnswPlaneBinding.ts`),
820
+ created sparse at `nativePlaneMaxNodes` slots (16M default; a structural, create-time header field —
821
+ exhausting it makes the index unavailable until the value is raised and the index rebuilt). A
822
+ format-version mismatch is a rebuild, by contract. Each slot ends with the record's msgpack-encoded
823
+ primary key in `nativePlaneKeyCap` inline bytes (default 40, which fits a UUID in the slot padding);
824
+ a longer key spills to the crate's overflow arena, so a table whose keys are mostly longer than 40
825
+ encoded bytes should raise `nativePlaneKeyCap` rather than live in the arena. Searches and predicate
826
+ batches return each hit's key with it, so no lookup by node id remains on the query path.
827
+
828
+ Degree cap is the per-index `nativePlaneLayer0Cap`, **default 64** (supersedes the fixed 128;
829
+ measured in [hnsw#14](https://github.com/HarperFast/hnsw/pull/14) and #2701): cap 64 holds recall@10
830
+ within ~0.5 pt of cap 128 at every ef ≥ 128 (at 1M and 4M nodes) while cutting the 128-d slot 704 →
831
+ 448 B and the 768-d slot 1,344 → 1,088 B, which keeps a 4M-node plane resident under a 2 GB limit
832
+ that makes the cap-128 plane thrash; a live plane's mean layer-0 degree is ~29
881
833
  ([hnsw#7](https://github.com/HarperFast/hnsw/issues/7)), so the reserved slot was mostly padding.
882
- Cap 32 halves the slot again but trails cap 128 by 1.3–2.2 pts below ef 1024 at 4M and by 1.7 pts
883
- at 1M for 768-d vectors, so it is a declaration for narrow vectors on a plane that outgrows RAM,
884
- not a default. The cap is a create-time header field: `getPlane` compares it with the index's
885
- value on attach and invalidates a plane that disagrees rather than reusing or truncating it, so
886
- revising it is a rebuild, not a format change. A file-primary index builds no JS graph, so this is
887
- the only layer-0 maximum it has; the JS graph's own cap in `addConnection` governs
888
- non-`nativePlane` indexes only. A binary-code v2 slot reopens the question.
834
+ Cap 32 halves the slot again but trails cap 128 by 1.3–2.2 pts below ef 1024 at 4M and by 1.7 pts at
835
+ 1M for 768-d vectors, so it is a declaration for narrow vectors on a plane that outgrows RAM, not a
836
+ default. The cap is a create-time header field: `getPlane` compares it with the index's value on
837
+ attach and invalidates a plane that disagrees rather than reusing or truncating it, so revising it
838
+ is a rebuild, not a format change. A file-primary index builds no JS graph, so this is the only
839
+ layer-0 maximum it has; the JS graph's own cap in `addConnection` governs non-`nativePlane` indexes
840
+ only. A binary-code v2 slot reopens the question.
889
841
 
890
842
  Upgrading a plane built at 128 costs one rebuild per node, the first time a process opens it under
891
843
  the new default; no GA release line carries plane files, so this reaches 5.3 pre-releases only.
@@ -896,40 +848,31 @@ it is already the persisted declaration. The default is not written into a descr
896
848
  the option, so nodes upgrade independently: a mixed-version cluster has each node rebuild its own
897
849
  file as it reaches the new code, and no node invalidates another's.
898
850
 
899
- ### Concurrency
900
-
901
- Per-slot lock word: bit 31 locked, low bits the owner's pid; unlocked values are generations that
902
- readers validate seqlock-style. A lock unchanged for 20 ms whose owner pid is dead is taken over and
903
- the slot sanitized (marked invalid — a dead writer's payload is half-written; invisible until
904
- rewritten, never spliced-but-valid). Elapsed time alone never robs a live writer. There is no
905
- cross-slot atomicity: an insert writes its slot plus ~M neighbours' back-edges independently, and a
906
- traversal may see a half-linked state — a missing edge or a just-deleted neighbour is skipped. That
907
- relaxed adherence is safe _here_ because the read path loads the record and rescores exactly, which
908
- rejects a wrong candidate; it is not a general storage pattern. Fields a reader acts on are read
909
- through aligned `read_volatile`; the stored vector is an ordinary load so the int8 kernel keeps
910
- autovectorizing, and a torn vector only perturbs a distance the generation check discards.
911
-
912
- ### Durability
851
+ ### Concurrency and durability
913
852
 
914
- `msync` on a cadence, not per commit; the header watermark advances after a completed barrier. The
915
- graph therefore has bounded-lag durability with deterministic catch-up, while the source of truth
916
- (records, mappings, cursor) stays transactional. Backup treats the file as node-local derived state:
917
- include it after a barrier, or rebuild on restore. A file whose format or checksum does not validate
918
- is rebuilt from records. macOS `msync` is a weaker barrier than Linux (an `F_FULLFSYNC` pass is a
919
- known follow-up); Windows is supported through the prebuild; performance is a Linux target.
853
+ The plane has no cross-slot atomicity: a traversal may see a half-linked graph, or a slot reused
854
+ since it was reached. That relaxed contract is safe only because Harper's read path loads the record
855
+ and rescores exactly, which rejects a wrong candidate; it is not a general storage pattern. The file
856
+ is `msync`ed on a cadence, not per commit, so the graph has bounded-lag durability with
857
+ deterministic catch-up while the source of truth (records, mappings, cursor) stays transactional.
858
+ Backup treats the file as node-local derived state: include it after a barrier, or rebuild on
859
+ restore. A file whose format or checksum does not validate is rebuilt from records. macOS `msync` is a
860
+ weaker barrier than Linux — the crate's `msync()` is a plain mapping flush with no `F_FULLFSYNC` pass —
861
+ so on macOS a power loss after `flushAsync()` resolves and the cursor is written can leave the cursor
862
+ past graph writes the file lost. Those vectors stay missing until a rebuild — nothing compares the
863
+ plane's `getWatermark()` with the cursor on attach — so the Delivery crash contract does not hold there.
920
864
 
921
865
  ### Search
922
866
 
923
- One crossing per query: `plane.search(query, k, ef, filter?)` runs on the module's thread pool with
924
- an epoch-stamped visited array and a fixed-capacity heap, asymmetric int8 distance with SIMD
925
- (AVX2/VNNI, NEON) and a scalar fallback. Filtering has two paths: a bitset over node ids for
926
- allow-lists and companion-condition candidate sets (zero callbacks), and a pipelined
927
- `ThreadsafeFunction` batch path for arbitrary JS predicates that keeps expanding in distance order
928
- while verdicts are in flight, bounded by the same `filterExpansion` visit budget as the JS path.
929
- Traversal never blocks on the event loop. A plane-backed `customIndex.search()` returns a
930
- promise-backed, async-only iterable (`resources/search.ts` wraps it); a synchronous consumer throws.
931
- Auto-ef reads the node count from the plane's `id_high_water` (freed ids are reused, so it stays
932
- close to the live count; deletes leave it generous until a rebuild, as with the RocksDB graph). Every vector reaching the plane — a
867
+ A search is one crossing per query, run off the event loop. An unfiltered search calls
868
+ `plane.search()`; a filtered one always goes through `plane.searchWithPredicate()` — the crate hands
869
+ back batches of candidate keys for `admit()` while traversal keeps expanding — bounded by
870
+ `filterState.maxVisits`, the same `filterExpansion` budget as the JS path, and never passes the crate's
871
+ node-id bitset (see the filtered vector search note above).
872
+ A plane-backed `customIndex.search()` returns a promise-backed, async-only iterable
873
+ (`resources/search.ts` wraps it); a synchronous consumer throws. Auto-ef reads the node count from
874
+ the plane's `idHighWater()` (freed ids are reused, so it stays close to the live count; deletes leave
875
+ it generous until a rebuild, as with the RocksDB graph). Every vector reaching the plane — a
933
876
  committed projection or a query target — passes one invariant (`assertPlaneVector`): array-like of
934
877
  positive length, every component a finite f32, a magnitude representable in f32, and the plane's
935
878
  `dims` once known. What fails it is the client's 400, never a plane failure; a query the plane
@@ -992,5 +935,5 @@ Not promised: a single total order across concurrent CRDT/source-resolution arri
992
935
  and replay re-read the authoritative record, so the index converges on what the primary store
993
936
  resolved; divergence is bounded by candidate selection, which the exact rescore filters), byte-identical
994
937
  graphs across nodes or rebuilds, or in-place format upgrades. Rebuild rate falls with graph size
995
- (≈4,700 inserts/s at 100k, 1,242/s at 1M measured), so a 16M rebuild is hours of 503; a native batch
996
- insert is the phase-3 follow-up.
938
+ (measured in #2430), so a 16M rebuild is hours of 503; the pinned `@harperfast/hnsw` (0.4.0) exports
939
+ a native `insertBatch`, but the rebuild does not use it yet.
@@ -2,8 +2,8 @@ import type { FullTextDerivedIndexEngine } from './fullTextDerivedIndex.ts';
2
2
  import { loggerWithTag } from '../../utility/logging/logger.ts';
3
3
 
4
4
  const FULLTEXT_LIFECYCLE_API_VERSION = 1;
5
- const FULLTEXT_MUTATION_BATCH_API_VERSION = 4;
6
- const FULLTEXT_QUERY_API_VERSION = 2;
5
+ const FULLTEXT_MUTATION_BATCH_API_VERSION = 5;
6
+ const FULLTEXT_QUERY_API_VERSION = 3;
7
7
  const logger = loggerWithTag('fulltext-derived-index');
8
8
 
9
9
  export interface NativeFullTextIndexConfiguration {
@@ -38,7 +38,6 @@ export type NativeFullTextSearchRequest = {
38
38
  mode?: NativeFullTextSearchMode;
39
39
  operator?: 'any' | 'all';
40
40
  fields?: string[];
41
- candidateIds?: string[];
42
41
  offset?: number;
43
42
  limit?: number;
44
43
  exactTotal?: boolean;
@@ -94,8 +93,13 @@ export type NativeFullTextRuntimeInfo = {
94
93
  storageBackends: ReadonlyArray<'native'>;
95
94
  limits: {
96
95
  maxCommitPayloadBytes: number;
96
+ maxRecordIdBytes: number;
97
+ maxRecordVersionBytes: number;
98
+ maxCandidateIds: number;
99
+ maxCandidateBytes: number;
97
100
  maxSearchWindow: number;
98
101
  maxAutocompleteResults: number;
102
+ maxSearchResponseBytes: number;
99
103
  maxSearchBudgetMilliseconds: number;
100
104
  maxTraceRecords: number;
101
105
  maxTraceSourceBytes: number;
@@ -110,6 +114,11 @@ export type NativeFullTextQueryRuntimeInfo = Pick<
110
114
  NativeFullTextRuntimeInfo['limits'],
111
115
  | 'maxSearchWindow'
112
116
  | 'maxAutocompleteResults'
117
+ | 'maxRecordIdBytes'
118
+ | 'maxRecordVersionBytes'
119
+ | 'maxCandidateIds'
120
+ | 'maxCandidateBytes'
121
+ | 'maxSearchResponseBytes'
113
122
  | 'maxSearchBudgetMilliseconds'
114
123
  | 'maxTraceRecords'
115
124
  | 'maxTraceSourceBytes'
@@ -259,11 +268,21 @@ export function validateFullTextQueryRuntimeInfo(info: unknown): NativeFullTextQ
259
268
  !Number.isSafeInteger(runtime.queryClassIsolationMinimumSearchThreads) ||
260
269
  runtime.queryClassIsolationMinimumSearchThreads <= 0 ||
261
270
  !runtime.limits ||
271
+ !Number.isSafeInteger(runtime.limits.maxRecordIdBytes) ||
272
+ runtime.limits.maxRecordIdBytes <= 0 ||
273
+ !Number.isSafeInteger(runtime.limits.maxRecordVersionBytes) ||
274
+ runtime.limits.maxRecordVersionBytes <= 0 ||
275
+ !Number.isSafeInteger(runtime.limits.maxCandidateIds) ||
276
+ runtime.limits.maxCandidateIds <= 0 ||
277
+ !Number.isSafeInteger(runtime.limits.maxCandidateBytes) ||
278
+ runtime.limits.maxCandidateBytes <= 0 ||
262
279
  !Number.isSafeInteger(runtime.limits.maxSearchWindow) ||
263
280
  runtime.limits.maxSearchWindow <= 0 ||
264
281
  !Number.isSafeInteger(runtime.limits.maxAutocompleteResults) ||
265
282
  runtime.limits.maxAutocompleteResults <= 0 ||
266
283
  runtime.limits.maxAutocompleteResults > runtime.limits.maxSearchWindow ||
284
+ !Number.isSafeInteger(runtime.limits.maxSearchResponseBytes) ||
285
+ runtime.limits.maxSearchResponseBytes <= 0 ||
267
286
  !Number.isSafeInteger(runtime.limits.maxSearchBudgetMilliseconds) ||
268
287
  runtime.limits.maxSearchBudgetMilliseconds <= 0 ||
269
288
  !Number.isSafeInteger(runtime.limits.maxTraceRecords) ||