@optimystic/db-p2p 0.21.0 → 0.24.0

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 (219) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +13 -2
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-policy.js +51 -4
  8. package/dist/src/cluster/cluster-policy.js.map +1 -1
  9. package/dist/src/cluster/cluster-repo.d.ts +42 -17
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +229 -122
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
  14. package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
  15. package/dist/src/cluster/cluster-size-coupling.js +35 -0
  16. package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
  17. package/dist/src/cluster/quorum-restore.d.ts +6 -0
  18. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  19. package/dist/src/cluster/quorum-restore.js +1 -1
  20. package/dist/src/cluster/quorum-restore.js.map +1 -1
  21. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  22. package/dist/src/cluster/reconcile-block.js +15 -3
  23. package/dist/src/cluster/reconcile-block.js.map +1 -1
  24. package/dist/src/cluster/service.d.ts +32 -1
  25. package/dist/src/cluster/service.d.ts.map +1 -1
  26. package/dist/src/cluster/service.js +43 -2
  27. package/dist/src/cluster/service.js.map +1 -1
  28. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  29. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  30. package/dist/src/cohort-topic/stream-util.js +56 -10
  31. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  32. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  33. package/dist/src/dispute/dispute-service.js +9 -3
  34. package/dist/src/dispute/dispute-service.js.map +1 -1
  35. package/dist/src/index.d.ts +5 -0
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -0
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/libp2p-key-network.d.ts +134 -7
  40. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  41. package/dist/src/libp2p-key-network.js +174 -37
  42. package/dist/src/libp2p-key-network.js.map +1 -1
  43. package/dist/src/libp2p-node-base.d.ts +3 -2
  44. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  45. package/dist/src/libp2p-node-base.js +859 -778
  46. package/dist/src/libp2p-node-base.js.map +1 -1
  47. package/dist/src/libp2p-node-rn.d.ts +2 -2
  48. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  49. package/dist/src/libp2p-node-rn.js.map +1 -1
  50. package/dist/src/libp2p-node.d.ts +2 -2
  51. package/dist/src/libp2p-node.d.ts.map +1 -1
  52. package/dist/src/libp2p-node.js.map +1 -1
  53. package/dist/src/logger.d.ts +17 -1
  54. package/dist/src/logger.d.ts.map +1 -1
  55. package/dist/src/logger.js +19 -2
  56. package/dist/src/logger.js.map +1 -1
  57. package/dist/src/network/network-manager-service.d.ts +2 -0
  58. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  59. package/dist/src/network/network-manager-service.js +4 -0
  60. package/dist/src/network/network-manager-service.js.map +1 -1
  61. package/dist/src/optimystic-node.d.ts +35 -0
  62. package/dist/src/optimystic-node.d.ts.map +1 -0
  63. package/dist/src/optimystic-node.js +2 -0
  64. package/dist/src/optimystic-node.js.map +1 -0
  65. package/dist/src/owned-block-seed.d.ts +6 -3
  66. package/dist/src/owned-block-seed.d.ts.map +1 -1
  67. package/dist/src/owned-block-seed.js +16 -3
  68. package/dist/src/owned-block-seed.js.map +1 -1
  69. package/dist/src/peer-address-book.d.ts +72 -0
  70. package/dist/src/peer-address-book.d.ts.map +1 -0
  71. package/dist/src/peer-address-book.js +123 -0
  72. package/dist/src/peer-address-book.js.map +1 -0
  73. package/dist/src/repo/client.d.ts.map +1 -1
  74. package/dist/src/repo/client.js +11 -2
  75. package/dist/src/repo/client.js.map +1 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  77. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  78. package/dist/src/repo/cluster-coordinator.js +95 -3
  79. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  80. package/dist/src/repo/coordinator-repo.d.ts +78 -14
  81. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  82. package/dist/src/repo/coordinator-repo.js +266 -81
  83. package/dist/src/repo/coordinator-repo.js.map +1 -1
  84. package/dist/src/rn.d.ts +5 -0
  85. package/dist/src/rn.d.ts.map +1 -1
  86. package/dist/src/rn.js +5 -0
  87. package/dist/src/rn.js.map +1 -1
  88. package/dist/src/storage/block-storage.d.ts.map +1 -1
  89. package/dist/src/storage/block-storage.js +57 -5
  90. package/dist/src/storage/block-storage.js.map +1 -1
  91. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  92. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  93. package/dist/src/storage/cached-raw-storage.js +152 -0
  94. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  95. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  96. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  97. package/dist/src/storage/cached-store-driver.js +775 -0
  98. package/dist/src/storage/cached-store-driver.js.map +1 -0
  99. package/dist/src/storage/i-block-storage.d.ts +20 -1
  100. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  101. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  102. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  103. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  104. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  105. package/dist/src/storage/shared-cache-pool.js +354 -0
  106. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  107. package/dist/src/storage/storage-repo.d.ts +56 -3
  108. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  109. package/dist/src/storage/storage-repo.js +124 -18
  110. package/dist/src/storage/storage-repo.js.map +1 -1
  111. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  112. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  113. package/dist/src/testing/raw-storage-conformance.js +52 -2
  114. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  115. package/package.json +3 -3
  116. package/readme.md +668 -653
  117. package/src/cluster/block-transfer.ts +424 -424
  118. package/src/cluster/client.ts +119 -88
  119. package/src/cluster/cluster-error.ts +64 -64
  120. package/src/cluster/cluster-policy.ts +203 -152
  121. package/src/cluster/cluster-repo.ts +245 -125
  122. package/src/cluster/cluster-size-coupling.ts +45 -0
  123. package/src/cluster/commit-cert.ts +139 -139
  124. package/src/cluster/i-transaction-state-store.ts +43 -43
  125. package/src/cluster/memory-transaction-state-store.ts +56 -56
  126. package/src/cluster/peer-key-binding.ts +37 -37
  127. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  128. package/src/cluster/quorum-restore.ts +223 -223
  129. package/src/cluster/reconcile-block.ts +203 -191
  130. package/src/cluster/service.ts +293 -241
  131. package/src/cluster/supermajority-coupling.ts +37 -37
  132. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  133. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  134. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  135. package/src/cohort-topic/change-bridge.ts +109 -109
  136. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  137. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  138. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  139. package/src/cohort-topic/host.ts +2901 -2901
  140. package/src/cohort-topic/index.ts +13 -13
  141. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  142. package/src/cohort-topic/membership-source.ts +68 -68
  143. package/src/cohort-topic/peer-codec.ts +31 -31
  144. package/src/cohort-topic/peer-sig.ts +86 -86
  145. package/src/cohort-topic/protocols.ts +71 -71
  146. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  147. package/src/cohort-topic/size-estimator.ts +16 -16
  148. package/src/cohort-topic/stream-util.ts +135 -87
  149. package/src/cohort-topic/threshold-crypto.ts +239 -239
  150. package/src/cohort-topic/topic-router.ts +77 -77
  151. package/src/dispute/arbitrator-selection.ts +138 -138
  152. package/src/dispute/cascade.ts +524 -524
  153. package/src/dispute/dispute-service.ts +11 -5
  154. package/src/dispute/invalidation.ts +625 -625
  155. package/src/inbound-authorization.ts +190 -190
  156. package/src/index.ts +52 -47
  157. package/src/libp2p-key-network.ts +1120 -958
  158. package/src/libp2p-node-base.ts +1675 -1591
  159. package/src/libp2p-node-rn.ts +30 -30
  160. package/src/libp2p-node.ts +36 -36
  161. package/src/logger.ts +19 -2
  162. package/src/matchmaking/aggregate-counts.ts +104 -104
  163. package/src/matchmaking/index.ts +20 -20
  164. package/src/matchmaking/module.ts +363 -363
  165. package/src/matchmaking/protocols.ts +51 -51
  166. package/src/matchmaking/provider-manager.ts +95 -95
  167. package/src/matchmaking/query-handler.ts +88 -88
  168. package/src/matchmaking/query-transport.ts +492 -492
  169. package/src/matchmaking/seeker-manager.ts +64 -64
  170. package/src/matchmaking/seeker-walk-client.ts +293 -293
  171. package/src/matchmaking/traffic-validation.ts +195 -195
  172. package/src/network/network-manager-service.ts +5 -0
  173. package/src/optimystic-node.ts +36 -0
  174. package/src/owned-block-seed.ts +53 -40
  175. package/src/peer-address-book.ts +149 -0
  176. package/src/protocol-limits.ts +33 -33
  177. package/src/reactivity/forwarder-host.ts +438 -438
  178. package/src/reactivity/index.ts +19 -19
  179. package/src/reactivity/notify-transport.ts +144 -144
  180. package/src/reactivity/origination-manager.ts +192 -192
  181. package/src/reactivity/protocols.ts +61 -61
  182. package/src/reactivity/push-state-gossip.ts +291 -291
  183. package/src/reactivity/recover-transport.ts +408 -408
  184. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  185. package/src/reactivity/subscriber-registry.ts +96 -96
  186. package/src/reactivity/subscription-manager.ts +450 -450
  187. package/src/reactivity/topic-bytes.ts +37 -37
  188. package/src/repo/client.ts +12 -2
  189. package/src/repo/cluster-coordinator.ts +99 -3
  190. package/src/repo/coordinator-repo.ts +305 -82
  191. package/src/repo/types.ts +7 -7
  192. package/src/rn.ts +39 -34
  193. package/src/rpc-deadline.ts +45 -45
  194. package/src/storage/arachnode-partition.ts +74 -74
  195. package/src/storage/block-storage.ts +59 -6
  196. package/src/storage/cached-raw-storage.ts +180 -0
  197. package/src/storage/cached-store-driver.ts +859 -0
  198. package/src/storage/i-block-storage.ts +20 -1
  199. package/src/storage/i-kv-store.ts +8 -8
  200. package/src/storage/i-raw-storage.ts +12 -5
  201. package/src/storage/kv-raw-storage.ts +135 -135
  202. package/src/storage/memory-kv-store.ts +28 -28
  203. package/src/storage/memory-storage.ts +25 -25
  204. package/src/storage/memory-store-driver.ts +157 -157
  205. package/src/storage/raw-store-codec.ts +42 -42
  206. package/src/storage/raw-store-driver.ts +80 -80
  207. package/src/storage/ring-selector.ts +317 -317
  208. package/src/storage/ring-shift-coordinator.ts +271 -271
  209. package/src/storage/shared-cache-pool.ts +452 -0
  210. package/src/storage/storage-repo.ts +1014 -903
  211. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  212. package/src/testing/index.ts +8 -8
  213. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  214. package/src/testing/raw-storage-conformance.ts +453 -397
  215. package/src/testing/reactivity-mesh-harness.ts +922 -922
  216. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  217. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  218. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  219. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,293 +1,293 @@
1
- /**
2
- * Matchmaking — seeker walk client (db-p2p, drives the hang-out-vs-continue walk over the substrate).
3
- *
4
- * `docs/matchmaking.md` §Hang-out vs. continue. This is the RPC orchestration on top of the pure
5
- * db-core {@link decide} engine: starting at `d_max`, it registers the seeker, issues `QueryV1`,
6
- * re-validates and dedupes returned providers, and on each `Accepted` reply asks {@link decide} whether
7
- * to finish, hang out (renew + requery on the `requery_interval_ms` poll cadence), or escalate (withdraw
8
- * + re-register one tier toward the root). At `d = 0` there is nowhere left to walk, so it hangs out for
9
- * the remaining patience and returns whatever matched (possibly `< wantCount`).
10
- *
11
- * Patience is a single budget that **drains across hops** (`docs/matchmaking.md` §Patience budgeting):
12
- * a wall-clock deadline is fixed at the start, so every walked tier and every hang-out poll consumes
13
- * the same `patienceMs` — escalation makes the next tier's hang-out progressively less attractive, which
14
- * is correct since the root is terminal.
15
- *
16
- * The transport, clock, sleep, and per-entry verifier are all injected, so the walk unit-tests without a
17
- * live libp2p stack (mock-tier e2e is a documented follow-on). This client implements the **poll path**
18
- * only; the arrival-push path (`pushOnArrival`) is a separate slice.
19
- *
20
- * Edge cases encoded here (`docs/matchmaking.md` §Edge cases — the rest live in {@link decide}):
21
- * - **`topicTraffic` absent (case 1):** the cohort is treated as zero-rate; the seeker issues one query
22
- * (immediate-match) and walks one tier toward the root **without** hanging out.
23
- * - **Stale `arrivalsPerMin = 0` after an epoch change (case 2):** never withdraw on a single zero
24
- * reading — the immediate `QueryV1` runs first; a cohort that actually holds `>= wantCount` providers
25
- * resolves to `done`, and only a query that also yields below threshold escalates.
26
- * - **`UnwillingCohort`/`UnwillingMember` (case 3):** the hang-out decision is never entered (no
27
- * `Accepted`); the walk terminates with whatever matched and standard substrate back-off applies.
28
- */
29
-
30
- import {
31
- decide,
32
- filterAcceptRatio,
33
- matchesFilter,
34
- newFilterAcceptRatioState,
35
- observeYield,
36
- verifyProviderEntry,
37
- DEFAULT_HANG_OUT_CONFIG,
38
- DEFAULT_MEAN_WANT_COUNT,
39
- FILTER_ACCEPT_RATIO_INITIAL,
40
- type CapabilityFilter,
41
- type EntrySigVerifier,
42
- type FilterAcceptRatioState,
43
- type HangOutConfig,
44
- type ProviderEntryV1,
45
- type QueryReplyV1,
46
- } from "@optimystic/db-core";
47
-
48
- /** A seeker register/probe reply at a tree tier (the matchmaking-relevant subset of `RegisterReplyV1`). */
49
- export interface SeekerProbeReply {
50
- readonly result: "accepted" | "no_state" | "promoted" | "unwilling_member" | "unwilling_cohort";
51
- /** Present on `accepted` and `promoted` (the substrate's barometer); absent triggers edge case 1. */
52
- readonly topicTraffic?: QueryReplyV1["topicTraffic"];
53
- /** Present on `promoted` — the tier to descend to. */
54
- readonly targetTier?: number;
55
- }
56
-
57
- /** The substrate seam the walk drives: register/query/renew/withdraw against a tree tier. */
58
- export interface SeekerWalkTransport {
59
- /** Register (or re-register) the seeker at tree tier `treeTier`; resolves the probe reply. */
60
- register(treeTier: number): Promise<SeekerProbeReply>;
61
- /** Issue a `QueryV1` against the cohort the seeker is currently registered with. */
62
- query(treeTier: number): Promise<QueryReplyV1>;
63
- /** Renew the live seeker registration (hang-out keep-alive via TTL renewal). */
64
- renew(): Promise<void>;
65
- /** Withdraw the seeker registration before escalating (polite `RenewV1` TTL = 0; optional). */
66
- withdraw(): Promise<void>;
67
- }
68
-
69
- /** Construction inputs for {@link SeekerWalkClient}. */
70
- export interface SeekerWalkClientDeps {
71
- readonly transport: SeekerWalkTransport;
72
- /** The matchmaking topic id (used to re-validate each forwarded entry's `registrationSig`). */
73
- readonly topicId: Uint8Array;
74
- /** Providers the seeker needs. */
75
- readonly wantCount: number;
76
- /** The starting tree tier `d_max`. */
77
- readonly dMax: number;
78
- /** Total patience budget (ms); drains across hops + hang-out. */
79
- readonly patienceMs: number;
80
- /** Optional capability filter (re-applied seeker-side over the returned set). */
81
- readonly filter?: CapabilityFilter;
82
- /** Per-entry signature verifier (db-p2p binds `verifyPeerSig`). */
83
- readonly verifyEntry: EntrySigVerifier;
84
- /** Hang-out decision config. Default {@link DEFAULT_HANG_OUT_CONFIG}. */
85
- readonly config?: HangOutConfig;
86
- /** Assumed competing-seeker mean `wantCount`. Default {@link DEFAULT_MEAN_WANT_COUNT}. */
87
- readonly meanWantCount?: number;
88
- /** Starting `filterAcceptRatio` (refined per walk). Default {@link FILTER_ACCEPT_RATIO_INITIAL}. */
89
- readonly filterAcceptRatioInitial?: number;
90
- /** Wall clock (unix ms); injectable for tests. Default `Date.now`. */
91
- readonly clock?: () => number;
92
- /** Sleep for the requery cadence; injectable for tests. Default a real timer. */
93
- readonly sleep?: (ms: number) => Promise<void>;
94
- }
95
-
96
- /** The result of a completed walk. */
97
- export interface SeekerWalkResult {
98
- /** Matched, re-validated, deduped providers (up to whatever accumulated; may be `< wantCount`). */
99
- readonly providers: ProviderEntryV1[];
100
- /** Whether `wantCount` was met. */
101
- readonly metWantCount: boolean;
102
- /** The tier the walk terminated at. */
103
- readonly terminalTier: number;
104
- /** Total register hops issued (probes + escalations + descends). */
105
- readonly hops: number;
106
- /**
107
- * Max `topicTraffic.childCohortCount` observed across every `Accepted` reply this walk saw. `> 0`
108
- * means the topic has promoted (it is hot), so the single-cohort sample is unrepresentative — the
109
- * public seeker session / voting `QuorumDiscovery` binding uses this to decide whether to escalate to
110
- * the multi-cohort sweep (`docs/matchmaking.md` §Multi-cohort sweep).
111
- */
112
- readonly maxChildCohortCount: number;
113
- }
114
-
115
- /** Outcome of evaluating one `Accepted` tier. */
116
- type AcceptedOutcome = "done" | "escalate" | "terminal";
117
-
118
- /** Drives the seeker hang-out-vs-continue walk for one matchmaking topic. See the module header. */
119
- export class SeekerWalkClient {
120
- private readonly transport: SeekerWalkTransport;
121
- private readonly topicId: Uint8Array;
122
- private readonly wantCount: number;
123
- private readonly dMax: number;
124
- private readonly patienceMs: number;
125
- private readonly filter?: CapabilityFilter;
126
- private readonly verifyEntry: EntrySigVerifier;
127
- private readonly config: HangOutConfig;
128
- private readonly meanWantCount: number;
129
- private readonly filterAcceptRatioInitial: number;
130
- private readonly clock: () => number;
131
- private readonly sleep: (ms: number) => Promise<void>;
132
-
133
- /** Matched providers, deduped by `participantId` (a provider seen via two queries counts once). */
134
- private readonly matched = new Map<string, ProviderEntryV1>();
135
- private ratioState: FilterAcceptRatioState = newFilterAcceptRatioState();
136
- private deadline = 0;
137
- private hops = 0;
138
- /** Max `childCohortCount` seen across `Accepted` replies — the hot-topic / sweep-escalation signal. */
139
- private maxChildCohortCount = 0;
140
-
141
- constructor(deps: SeekerWalkClientDeps) {
142
- this.transport = deps.transport;
143
- this.topicId = deps.topicId;
144
- this.wantCount = deps.wantCount;
145
- this.dMax = deps.dMax;
146
- this.patienceMs = deps.patienceMs;
147
- this.filter = deps.filter;
148
- this.verifyEntry = deps.verifyEntry;
149
- this.config = deps.config ?? DEFAULT_HANG_OUT_CONFIG;
150
- this.meanWantCount = deps.meanWantCount ?? DEFAULT_MEAN_WANT_COUNT;
151
- this.filterAcceptRatioInitial = deps.filterAcceptRatioInitial ?? FILTER_ACCEPT_RATIO_INITIAL;
152
- this.clock = deps.clock ?? ((): number => Date.now());
153
- this.sleep = deps.sleep ?? ((ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)));
154
- }
155
-
156
- /** Run the walk from `d_max` toward the root; resolves the matched providers + termination info. */
157
- async run(): Promise<SeekerWalkResult> {
158
- this.deadline = this.clock() + this.patienceMs;
159
- let d = this.dMax;
160
-
161
- while (true) {
162
- this.hops++;
163
- const reply = await this.transport.register(d);
164
- switch (reply.result) {
165
- case "no_state": {
166
- if (d <= 0) {
167
- return this.finish(d);
168
- }
169
- d -= 1;
170
- continue;
171
- }
172
- case "unwilling_member":
173
- case "unwilling_cohort": {
174
- // Substrate-level refusal — never received Accepted, so hang-out is not entered (case 3).
175
- return this.finish(d);
176
- }
177
- case "promoted": {
178
- const target = reply.targetTier ?? d + 1;
179
- if (target <= d) {
180
- return this.finish(d);
181
- }
182
- d = target;
183
- continue;
184
- }
185
- case "accepted": {
186
- const outcome = await this.handleAccepted(d, reply.topicTraffic);
187
- if (outcome === "done" || outcome === "terminal") {
188
- return this.finish(d);
189
- }
190
- await this.transport.withdraw();
191
- d -= 1;
192
- continue;
193
- }
194
- }
195
- }
196
- }
197
-
198
- /** Remaining patience (ms); the wall-clock deadline drains uniformly across hops + hang-out. */
199
- private remaining(): number {
200
- return Math.max(0, this.deadline - this.clock());
201
- }
202
-
203
- /** Filter, re-validate (`registrationSig`), and dedupe a query reply's providers into {@link matched}. */
204
- private collect(reply: QueryReplyV1): void {
205
- const returned = reply.providers ?? [];
206
- let matchedThisQuery = 0;
207
- for (const entry of returned) {
208
- if (!matchesFilter(entry, this.filter)) {
209
- continue;
210
- }
211
- if (!verifyProviderEntry(this.topicId, entry, this.verifyEntry)) {
212
- continue;
213
- }
214
- matchedThisQuery++;
215
- this.matched.set(entry.participantId, entry);
216
- }
217
- this.ratioState = observeYield(this.ratioState, matchedThisQuery, returned.length);
218
- }
219
-
220
- /** Evaluate one `Accepted` tier: immediate query, then {@link decide} → done / hangOut / escalate. */
221
- private async handleAccepted(d: number, traffic: SeekerProbeReply["topicTraffic"]): Promise<AcceptedOutcome> {
222
- // Record the hottest tier seen as soon as this Accepted reply's traffic is available — *before* the
223
- // immediate-match/done short-circuit below, so the single-cohort-vs-sweep decision (public session /
224
- // voting QuorumDiscovery binding) still sees a hot topic even when one cohort's query already met
225
- // wantCount. Folding it only on the `decide` path would drop the signal for a small quorum that a
226
- // single hot cohort satisfies, leaving the assembler with a prefix-biased sample it should sweep.
227
- if (traffic !== undefined) {
228
- this.maxChildCohortCount = Math.max(this.maxChildCohortCount, traffic.childCohortCount);
229
- }
230
-
231
- // Immediate-match check runs in every case — also satisfies edge case 2 (a stale arrivalsPerMin=0
232
- // cohort that actually holds enough providers resolves here rather than escalating spuriously).
233
- this.collect(await this.transport.query(d));
234
- if (this.matched.size >= this.wantCount) {
235
- return "done";
236
- }
237
-
238
- // Edge case 1: a reply without topicTraffic is treated as zero-rate — walk one tier toward the
239
- // root without hanging out (no estimation against absent inputs).
240
- if (traffic === undefined) {
241
- return d <= 0 ? "terminal" : "escalate";
242
- }
243
-
244
- const decision = decide(
245
- {
246
- currentMatches: this.matched.size,
247
- directParticipants: traffic.directParticipants,
248
- arrivalsPerMin: traffic.arrivalsPerMin,
249
- queriesPerMin: traffic.queriesPerMin,
250
- childCohortCount: traffic.childCohortCount,
251
- wantCount: this.wantCount,
252
- patienceMsRemaining: this.remaining(),
253
- filterAcceptRatio: filterAcceptRatio(this.ratioState, this.filterAcceptRatioInitial),
254
- meanWantCount: this.meanWantCount,
255
- },
256
- this.config,
257
- );
258
-
259
- if (decision.action === "hangOut") {
260
- await this.hangOut(d, decision.requeryIntervalMs);
261
- if (this.matched.size >= this.wantCount) {
262
- return "done";
263
- }
264
- return d <= 0 ? "terminal" : "escalate";
265
- }
266
-
267
- // escalate — but at the root there is nowhere to walk: hang out the remaining patience, then end.
268
- if (d <= 0) {
269
- await this.hangOut(d, this.config.requeryIntervalMs);
270
- return "terminal";
271
- }
272
- return "escalate";
273
- }
274
-
275
- /** Keep the registration alive and re-query on the poll cadence until `wantCount` met or patience drains. */
276
- private async hangOut(d: number, requeryIntervalMs: number): Promise<void> {
277
- while (this.remaining() > 0 && this.matched.size < this.wantCount) {
278
- await this.sleep(Math.min(requeryIntervalMs, this.remaining()));
279
- await this.transport.renew();
280
- this.collect(await this.transport.query(d));
281
- }
282
- }
283
-
284
- private finish(terminalTier: number): SeekerWalkResult {
285
- return {
286
- providers: [...this.matched.values()],
287
- metWantCount: this.matched.size >= this.wantCount,
288
- terminalTier,
289
- hops: this.hops,
290
- maxChildCohortCount: this.maxChildCohortCount,
291
- };
292
- }
293
- }
1
+ /**
2
+ * Matchmaking — seeker walk client (db-p2p, drives the hang-out-vs-continue walk over the substrate).
3
+ *
4
+ * `docs/matchmaking.md` §Hang-out vs. continue. This is the RPC orchestration on top of the pure
5
+ * db-core {@link decide} engine: starting at `d_max`, it registers the seeker, issues `QueryV1`,
6
+ * re-validates and dedupes returned providers, and on each `Accepted` reply asks {@link decide} whether
7
+ * to finish, hang out (renew + requery on the `requery_interval_ms` poll cadence), or escalate (withdraw
8
+ * + re-register one tier toward the root). At `d = 0` there is nowhere left to walk, so it hangs out for
9
+ * the remaining patience and returns whatever matched (possibly `< wantCount`).
10
+ *
11
+ * Patience is a single budget that **drains across hops** (`docs/matchmaking.md` §Patience budgeting):
12
+ * a wall-clock deadline is fixed at the start, so every walked tier and every hang-out poll consumes
13
+ * the same `patienceMs` — escalation makes the next tier's hang-out progressively less attractive, which
14
+ * is correct since the root is terminal.
15
+ *
16
+ * The transport, clock, sleep, and per-entry verifier are all injected, so the walk unit-tests without a
17
+ * live libp2p stack (mock-tier e2e is a documented follow-on). This client implements the **poll path**
18
+ * only; the arrival-push path (`pushOnArrival`) is a separate slice.
19
+ *
20
+ * Edge cases encoded here (`docs/matchmaking.md` §Edge cases — the rest live in {@link decide}):
21
+ * - **`topicTraffic` absent (case 1):** the cohort is treated as zero-rate; the seeker issues one query
22
+ * (immediate-match) and walks one tier toward the root **without** hanging out.
23
+ * - **Stale `arrivalsPerMin = 0` after an epoch change (case 2):** never withdraw on a single zero
24
+ * reading — the immediate `QueryV1` runs first; a cohort that actually holds `>= wantCount` providers
25
+ * resolves to `done`, and only a query that also yields below threshold escalates.
26
+ * - **`UnwillingCohort`/`UnwillingMember` (case 3):** the hang-out decision is never entered (no
27
+ * `Accepted`); the walk terminates with whatever matched and standard substrate back-off applies.
28
+ */
29
+
30
+ import {
31
+ decide,
32
+ filterAcceptRatio,
33
+ matchesFilter,
34
+ newFilterAcceptRatioState,
35
+ observeYield,
36
+ verifyProviderEntry,
37
+ DEFAULT_HANG_OUT_CONFIG,
38
+ DEFAULT_MEAN_WANT_COUNT,
39
+ FILTER_ACCEPT_RATIO_INITIAL,
40
+ type CapabilityFilter,
41
+ type EntrySigVerifier,
42
+ type FilterAcceptRatioState,
43
+ type HangOutConfig,
44
+ type ProviderEntryV1,
45
+ type QueryReplyV1,
46
+ } from "@optimystic/db-core";
47
+
48
+ /** A seeker register/probe reply at a tree tier (the matchmaking-relevant subset of `RegisterReplyV1`). */
49
+ export interface SeekerProbeReply {
50
+ readonly result: "accepted" | "no_state" | "promoted" | "unwilling_member" | "unwilling_cohort";
51
+ /** Present on `accepted` and `promoted` (the substrate's barometer); absent triggers edge case 1. */
52
+ readonly topicTraffic?: QueryReplyV1["topicTraffic"];
53
+ /** Present on `promoted` — the tier to descend to. */
54
+ readonly targetTier?: number;
55
+ }
56
+
57
+ /** The substrate seam the walk drives: register/query/renew/withdraw against a tree tier. */
58
+ export interface SeekerWalkTransport {
59
+ /** Register (or re-register) the seeker at tree tier `treeTier`; resolves the probe reply. */
60
+ register(treeTier: number): Promise<SeekerProbeReply>;
61
+ /** Issue a `QueryV1` against the cohort the seeker is currently registered with. */
62
+ query(treeTier: number): Promise<QueryReplyV1>;
63
+ /** Renew the live seeker registration (hang-out keep-alive via TTL renewal). */
64
+ renew(): Promise<void>;
65
+ /** Withdraw the seeker registration before escalating (polite `RenewV1` TTL = 0; optional). */
66
+ withdraw(): Promise<void>;
67
+ }
68
+
69
+ /** Construction inputs for {@link SeekerWalkClient}. */
70
+ export interface SeekerWalkClientDeps {
71
+ readonly transport: SeekerWalkTransport;
72
+ /** The matchmaking topic id (used to re-validate each forwarded entry's `registrationSig`). */
73
+ readonly topicId: Uint8Array;
74
+ /** Providers the seeker needs. */
75
+ readonly wantCount: number;
76
+ /** The starting tree tier `d_max`. */
77
+ readonly dMax: number;
78
+ /** Total patience budget (ms); drains across hops + hang-out. */
79
+ readonly patienceMs: number;
80
+ /** Optional capability filter (re-applied seeker-side over the returned set). */
81
+ readonly filter?: CapabilityFilter;
82
+ /** Per-entry signature verifier (db-p2p binds `verifyPeerSig`). */
83
+ readonly verifyEntry: EntrySigVerifier;
84
+ /** Hang-out decision config. Default {@link DEFAULT_HANG_OUT_CONFIG}. */
85
+ readonly config?: HangOutConfig;
86
+ /** Assumed competing-seeker mean `wantCount`. Default {@link DEFAULT_MEAN_WANT_COUNT}. */
87
+ readonly meanWantCount?: number;
88
+ /** Starting `filterAcceptRatio` (refined per walk). Default {@link FILTER_ACCEPT_RATIO_INITIAL}. */
89
+ readonly filterAcceptRatioInitial?: number;
90
+ /** Wall clock (unix ms); injectable for tests. Default `Date.now`. */
91
+ readonly clock?: () => number;
92
+ /** Sleep for the requery cadence; injectable for tests. Default a real timer. */
93
+ readonly sleep?: (ms: number) => Promise<void>;
94
+ }
95
+
96
+ /** The result of a completed walk. */
97
+ export interface SeekerWalkResult {
98
+ /** Matched, re-validated, deduped providers (up to whatever accumulated; may be `< wantCount`). */
99
+ readonly providers: ProviderEntryV1[];
100
+ /** Whether `wantCount` was met. */
101
+ readonly metWantCount: boolean;
102
+ /** The tier the walk terminated at. */
103
+ readonly terminalTier: number;
104
+ /** Total register hops issued (probes + escalations + descends). */
105
+ readonly hops: number;
106
+ /**
107
+ * Max `topicTraffic.childCohortCount` observed across every `Accepted` reply this walk saw. `> 0`
108
+ * means the topic has promoted (it is hot), so the single-cohort sample is unrepresentative — the
109
+ * public seeker session / voting `QuorumDiscovery` binding uses this to decide whether to escalate to
110
+ * the multi-cohort sweep (`docs/matchmaking.md` §Multi-cohort sweep).
111
+ */
112
+ readonly maxChildCohortCount: number;
113
+ }
114
+
115
+ /** Outcome of evaluating one `Accepted` tier. */
116
+ type AcceptedOutcome = "done" | "escalate" | "terminal";
117
+
118
+ /** Drives the seeker hang-out-vs-continue walk for one matchmaking topic. See the module header. */
119
+ export class SeekerWalkClient {
120
+ private readonly transport: SeekerWalkTransport;
121
+ private readonly topicId: Uint8Array;
122
+ private readonly wantCount: number;
123
+ private readonly dMax: number;
124
+ private readonly patienceMs: number;
125
+ private readonly filter?: CapabilityFilter;
126
+ private readonly verifyEntry: EntrySigVerifier;
127
+ private readonly config: HangOutConfig;
128
+ private readonly meanWantCount: number;
129
+ private readonly filterAcceptRatioInitial: number;
130
+ private readonly clock: () => number;
131
+ private readonly sleep: (ms: number) => Promise<void>;
132
+
133
+ /** Matched providers, deduped by `participantId` (a provider seen via two queries counts once). */
134
+ private readonly matched = new Map<string, ProviderEntryV1>();
135
+ private ratioState: FilterAcceptRatioState = newFilterAcceptRatioState();
136
+ private deadline = 0;
137
+ private hops = 0;
138
+ /** Max `childCohortCount` seen across `Accepted` replies — the hot-topic / sweep-escalation signal. */
139
+ private maxChildCohortCount = 0;
140
+
141
+ constructor(deps: SeekerWalkClientDeps) {
142
+ this.transport = deps.transport;
143
+ this.topicId = deps.topicId;
144
+ this.wantCount = deps.wantCount;
145
+ this.dMax = deps.dMax;
146
+ this.patienceMs = deps.patienceMs;
147
+ this.filter = deps.filter;
148
+ this.verifyEntry = deps.verifyEntry;
149
+ this.config = deps.config ?? DEFAULT_HANG_OUT_CONFIG;
150
+ this.meanWantCount = deps.meanWantCount ?? DEFAULT_MEAN_WANT_COUNT;
151
+ this.filterAcceptRatioInitial = deps.filterAcceptRatioInitial ?? FILTER_ACCEPT_RATIO_INITIAL;
152
+ this.clock = deps.clock ?? ((): number => Date.now());
153
+ this.sleep = deps.sleep ?? ((ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)));
154
+ }
155
+
156
+ /** Run the walk from `d_max` toward the root; resolves the matched providers + termination info. */
157
+ async run(): Promise<SeekerWalkResult> {
158
+ this.deadline = this.clock() + this.patienceMs;
159
+ let d = this.dMax;
160
+
161
+ while (true) {
162
+ this.hops++;
163
+ const reply = await this.transport.register(d);
164
+ switch (reply.result) {
165
+ case "no_state": {
166
+ if (d <= 0) {
167
+ return this.finish(d);
168
+ }
169
+ d -= 1;
170
+ continue;
171
+ }
172
+ case "unwilling_member":
173
+ case "unwilling_cohort": {
174
+ // Substrate-level refusal — never received Accepted, so hang-out is not entered (case 3).
175
+ return this.finish(d);
176
+ }
177
+ case "promoted": {
178
+ const target = reply.targetTier ?? d + 1;
179
+ if (target <= d) {
180
+ return this.finish(d);
181
+ }
182
+ d = target;
183
+ continue;
184
+ }
185
+ case "accepted": {
186
+ const outcome = await this.handleAccepted(d, reply.topicTraffic);
187
+ if (outcome === "done" || outcome === "terminal") {
188
+ return this.finish(d);
189
+ }
190
+ await this.transport.withdraw();
191
+ d -= 1;
192
+ continue;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Remaining patience (ms); the wall-clock deadline drains uniformly across hops + hang-out. */
199
+ private remaining(): number {
200
+ return Math.max(0, this.deadline - this.clock());
201
+ }
202
+
203
+ /** Filter, re-validate (`registrationSig`), and dedupe a query reply's providers into {@link matched}. */
204
+ private collect(reply: QueryReplyV1): void {
205
+ const returned = reply.providers ?? [];
206
+ let matchedThisQuery = 0;
207
+ for (const entry of returned) {
208
+ if (!matchesFilter(entry, this.filter)) {
209
+ continue;
210
+ }
211
+ if (!verifyProviderEntry(this.topicId, entry, this.verifyEntry)) {
212
+ continue;
213
+ }
214
+ matchedThisQuery++;
215
+ this.matched.set(entry.participantId, entry);
216
+ }
217
+ this.ratioState = observeYield(this.ratioState, matchedThisQuery, returned.length);
218
+ }
219
+
220
+ /** Evaluate one `Accepted` tier: immediate query, then {@link decide} → done / hangOut / escalate. */
221
+ private async handleAccepted(d: number, traffic: SeekerProbeReply["topicTraffic"]): Promise<AcceptedOutcome> {
222
+ // Record the hottest tier seen as soon as this Accepted reply's traffic is available — *before* the
223
+ // immediate-match/done short-circuit below, so the single-cohort-vs-sweep decision (public session /
224
+ // voting QuorumDiscovery binding) still sees a hot topic even when one cohort's query already met
225
+ // wantCount. Folding it only on the `decide` path would drop the signal for a small quorum that a
226
+ // single hot cohort satisfies, leaving the assembler with a prefix-biased sample it should sweep.
227
+ if (traffic !== undefined) {
228
+ this.maxChildCohortCount = Math.max(this.maxChildCohortCount, traffic.childCohortCount);
229
+ }
230
+
231
+ // Immediate-match check runs in every case — also satisfies edge case 2 (a stale arrivalsPerMin=0
232
+ // cohort that actually holds enough providers resolves here rather than escalating spuriously).
233
+ this.collect(await this.transport.query(d));
234
+ if (this.matched.size >= this.wantCount) {
235
+ return "done";
236
+ }
237
+
238
+ // Edge case 1: a reply without topicTraffic is treated as zero-rate — walk one tier toward the
239
+ // root without hanging out (no estimation against absent inputs).
240
+ if (traffic === undefined) {
241
+ return d <= 0 ? "terminal" : "escalate";
242
+ }
243
+
244
+ const decision = decide(
245
+ {
246
+ currentMatches: this.matched.size,
247
+ directParticipants: traffic.directParticipants,
248
+ arrivalsPerMin: traffic.arrivalsPerMin,
249
+ queriesPerMin: traffic.queriesPerMin,
250
+ childCohortCount: traffic.childCohortCount,
251
+ wantCount: this.wantCount,
252
+ patienceMsRemaining: this.remaining(),
253
+ filterAcceptRatio: filterAcceptRatio(this.ratioState, this.filterAcceptRatioInitial),
254
+ meanWantCount: this.meanWantCount,
255
+ },
256
+ this.config,
257
+ );
258
+
259
+ if (decision.action === "hangOut") {
260
+ await this.hangOut(d, decision.requeryIntervalMs);
261
+ if (this.matched.size >= this.wantCount) {
262
+ return "done";
263
+ }
264
+ return d <= 0 ? "terminal" : "escalate";
265
+ }
266
+
267
+ // escalate — but at the root there is nowhere to walk: hang out the remaining patience, then end.
268
+ if (d <= 0) {
269
+ await this.hangOut(d, this.config.requeryIntervalMs);
270
+ return "terminal";
271
+ }
272
+ return "escalate";
273
+ }
274
+
275
+ /** Keep the registration alive and re-query on the poll cadence until `wantCount` met or patience drains. */
276
+ private async hangOut(d: number, requeryIntervalMs: number): Promise<void> {
277
+ while (this.remaining() > 0 && this.matched.size < this.wantCount) {
278
+ await this.sleep(Math.min(requeryIntervalMs, this.remaining()));
279
+ await this.transport.renew();
280
+ this.collect(await this.transport.query(d));
281
+ }
282
+ }
283
+
284
+ private finish(terminalTier: number): SeekerWalkResult {
285
+ return {
286
+ providers: [...this.matched.values()],
287
+ metWantCount: this.matched.size >= this.wantCount,
288
+ terminalTier,
289
+ hops: this.hops,
290
+ maxChildCohortCount: this.maxChildCohortCount,
291
+ };
292
+ }
293
+ }