@optimystic/db-p2p 0.22.0 → 0.24.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 (194) 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-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/host.js +34 -11
  14. package/dist/src/cohort-topic/host.js.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.d.ts +37 -7
  16. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  17. package/dist/src/cohort-topic/stream-util.js +77 -19
  18. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +9 -3
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +3 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +3 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts +88 -2
  27. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  28. package/dist/src/libp2p-key-network.js +134 -28
  29. package/dist/src/libp2p-key-network.js.map +1 -1
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +25 -1
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/logger.d.ts +17 -1
  34. package/dist/src/logger.d.ts.map +1 -1
  35. package/dist/src/logger.js +19 -2
  36. package/dist/src/logger.js.map +1 -1
  37. package/dist/src/matchmaking/query-transport.js +3 -3
  38. package/dist/src/matchmaking/query-transport.js.map +1 -1
  39. package/dist/src/owned-block-seed.d.ts +6 -3
  40. package/dist/src/owned-block-seed.d.ts.map +1 -1
  41. package/dist/src/owned-block-seed.js +16 -3
  42. package/dist/src/owned-block-seed.js.map +1 -1
  43. package/dist/src/peer-address-book.d.ts +72 -0
  44. package/dist/src/peer-address-book.d.ts.map +1 -0
  45. package/dist/src/peer-address-book.js +123 -0
  46. package/dist/src/peer-address-book.js.map +1 -0
  47. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  48. package/dist/src/reactivity/notify-transport.js +6 -6
  49. package/dist/src/reactivity/notify-transport.js.map +1 -1
  50. package/dist/src/reactivity/push-state-gossip.js +2 -2
  51. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  52. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  53. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  54. package/dist/src/reactivity/recover-transport.js +7 -3
  55. package/dist/src/reactivity/recover-transport.js.map +1 -1
  56. package/dist/src/repo/client.d.ts.map +1 -1
  57. package/dist/src/repo/client.js +11 -2
  58. package/dist/src/repo/client.js.map +1 -1
  59. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  60. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  61. package/dist/src/repo/cluster-coordinator.js +95 -3
  62. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  63. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  64. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  65. package/dist/src/repo/coordinator-repo.js +242 -73
  66. package/dist/src/repo/coordinator-repo.js.map +1 -1
  67. package/dist/src/rn.d.ts +3 -0
  68. package/dist/src/rn.d.ts.map +1 -1
  69. package/dist/src/rn.js +3 -0
  70. package/dist/src/rn.js.map +1 -1
  71. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  72. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  73. package/dist/src/storage/cached-raw-storage.js +152 -0
  74. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  75. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  76. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  77. package/dist/src/storage/cached-store-driver.js +775 -0
  78. package/dist/src/storage/cached-store-driver.js.map +1 -0
  79. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  80. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  81. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  82. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  83. package/dist/src/storage/shared-cache-pool.js +354 -0
  84. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  85. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  86. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  87. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  88. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  89. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  90. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  91. package/dist/src/testing/raw-storage-conformance.js +35 -2
  92. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  93. package/package.json +3 -3
  94. package/readme.md +668 -668
  95. package/src/cluster/block-transfer.ts +424 -424
  96. package/src/cluster/client.ts +119 -88
  97. package/src/cluster/cluster-error.ts +64 -64
  98. package/src/cluster/cluster-policy.ts +203 -203
  99. package/src/cluster/cluster-repo.ts +242 -122
  100. package/src/cluster/cluster-size-coupling.ts +45 -45
  101. package/src/cluster/commit-cert.ts +139 -139
  102. package/src/cluster/i-transaction-state-store.ts +43 -43
  103. package/src/cluster/memory-transaction-state-store.ts +56 -56
  104. package/src/cluster/peer-key-binding.ts +37 -37
  105. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  106. package/src/cluster/quorum-restore.ts +223 -223
  107. package/src/cluster/reconcile-block.ts +203 -203
  108. package/src/cluster/service.ts +293 -241
  109. package/src/cluster/supermajority-coupling.ts +37 -37
  110. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  111. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  112. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  113. package/src/cohort-topic/change-bridge.ts +109 -109
  114. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  115. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  116. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  117. package/src/cohort-topic/host.ts +42 -11
  118. package/src/cohort-topic/index.ts +13 -13
  119. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  120. package/src/cohort-topic/membership-source.ts +68 -68
  121. package/src/cohort-topic/peer-codec.ts +31 -31
  122. package/src/cohort-topic/peer-sig.ts +86 -86
  123. package/src/cohort-topic/protocols.ts +71 -71
  124. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  125. package/src/cohort-topic/size-estimator.ts +16 -16
  126. package/src/cohort-topic/stream-util.ts +79 -19
  127. package/src/cohort-topic/threshold-crypto.ts +239 -239
  128. package/src/cohort-topic/topic-router.ts +77 -77
  129. package/src/dispute/arbitrator-selection.ts +138 -138
  130. package/src/dispute/cascade.ts +524 -524
  131. package/src/dispute/dispute-service.ts +11 -5
  132. package/src/dispute/invalidation.ts +625 -625
  133. package/src/inbound-authorization.ts +190 -190
  134. package/src/index.ts +52 -49
  135. package/src/libp2p-key-network.ts +1120 -990
  136. package/src/libp2p-node-base.ts +1675 -1651
  137. package/src/libp2p-node-rn.ts +30 -30
  138. package/src/libp2p-node.ts +36 -36
  139. package/src/logger.ts +19 -2
  140. package/src/matchmaking/aggregate-counts.ts +104 -104
  141. package/src/matchmaking/index.ts +20 -20
  142. package/src/matchmaking/module.ts +363 -363
  143. package/src/matchmaking/protocols.ts +51 -51
  144. package/src/matchmaking/provider-manager.ts +95 -95
  145. package/src/matchmaking/query-handler.ts +88 -88
  146. package/src/matchmaking/query-transport.ts +3 -3
  147. package/src/matchmaking/seeker-manager.ts +64 -64
  148. package/src/matchmaking/seeker-walk-client.ts +293 -293
  149. package/src/matchmaking/traffic-validation.ts +195 -195
  150. package/src/optimystic-node.ts +36 -36
  151. package/src/owned-block-seed.ts +53 -40
  152. package/src/peer-address-book.ts +149 -0
  153. package/src/protocol-limits.ts +33 -33
  154. package/src/reactivity/forwarder-host.ts +438 -438
  155. package/src/reactivity/index.ts +19 -19
  156. package/src/reactivity/notify-transport.ts +144 -144
  157. package/src/reactivity/origination-manager.ts +192 -192
  158. package/src/reactivity/protocols.ts +61 -61
  159. package/src/reactivity/push-state-gossip.ts +291 -291
  160. package/src/reactivity/recover-transport.ts +7 -3
  161. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  162. package/src/reactivity/subscriber-registry.ts +96 -96
  163. package/src/reactivity/subscription-manager.ts +450 -450
  164. package/src/reactivity/topic-bytes.ts +37 -37
  165. package/src/repo/client.ts +12 -2
  166. package/src/repo/cluster-coordinator.ts +99 -3
  167. package/src/repo/coordinator-repo.ts +281 -74
  168. package/src/repo/types.ts +7 -7
  169. package/src/rn.ts +39 -36
  170. package/src/rpc-deadline.ts +45 -45
  171. package/src/storage/arachnode-partition.ts +74 -74
  172. package/src/storage/cached-raw-storage.ts +180 -0
  173. package/src/storage/cached-store-driver.ts +859 -0
  174. package/src/storage/i-kv-store.ts +8 -8
  175. package/src/storage/i-raw-storage.ts +12 -5
  176. package/src/storage/kv-raw-storage.ts +135 -135
  177. package/src/storage/memory-kv-store.ts +28 -28
  178. package/src/storage/memory-storage.ts +25 -25
  179. package/src/storage/memory-store-driver.ts +157 -157
  180. package/src/storage/raw-store-codec.ts +42 -42
  181. package/src/storage/raw-store-driver.ts +80 -80
  182. package/src/storage/ring-selector.ts +317 -317
  183. package/src/storage/ring-shift-coordinator.ts +271 -271
  184. package/src/storage/shared-cache-pool.ts +452 -0
  185. package/src/storage/storage-repo.ts +1014 -1014
  186. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
  187. package/src/testing/index.ts +8 -8
  188. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  189. package/src/testing/raw-storage-conformance.ts +453 -417
  190. package/src/testing/reactivity-mesh-harness.ts +922 -922
  191. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  192. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  193. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  194. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,317 +1,317 @@
1
- import { hashPeerId } from 'p2p-fret';
2
- import { peerIdFromString } from '@libp2p/peer-id';
3
- import type { StorageMonitor } from './storage-monitor.js';
4
- import type { ArachnodeInfo, ArachnodeFretAdapter } from './arachnode-fret-adapter.js';
5
- import { extractPrefix } from './arachnode-partition.js';
6
-
7
- /** EWMA weight for the demand inputs. Higher = more reactive, lower = smoother. */
8
- const DEFAULT_SMOOTHING_ALPHA = 0.2;
9
- /** Hysteresis half-width in rings. 0.5 puts a full ring between the move-out and move-in triggers. */
10
- const DEFAULT_DEADBAND = 0.5;
11
- /** Minimum time (ms) at a ring after a move is triggered before another may start. */
12
- const DEFAULT_MIN_DWELL_MS = 10 * 60 * 1000;
13
- /** Floor for smoothed coverage so `-log2` never sees 0; the [0,16] clamp bounds the result anyway. */
14
- const DEPTH_EPSILON = 1e-12;
15
-
16
- export interface RingSelectorConfig {
17
- /** Minimum storage capacity in bytes */
18
- minCapacity: number;
19
-
20
- /** Thresholds for ring transitions */
21
- thresholds: {
22
- /** Move to outer ring when used > this % */
23
- moveOut: number;
24
- /** Move to inner ring when used < this % */
25
- moveIn: number;
26
- };
27
-
28
- /**
29
- * EWMA weight (0..1) applied to the demand inputs (available capacity and estimated network
30
- * data) each sample tick. A single noisy sample only moves the smoothed depth by this fraction.
31
- * Default {@link DEFAULT_SMOOTHING_ALPHA} (0.2).
32
- */
33
- smoothingAlpha?: number;
34
-
35
- /**
36
- * Hysteresis dead-band half-width `h`, in rings. A move only fires when the smoothed depth is
37
- * past the ring boundary by `h`, so the region of width `2h` around each integer produces no
38
- * move. Default {@link DEFAULT_DEADBAND} (0.5) — a full ring between the move-out and move-in
39
- * triggers, so a node can never satisfy both at once.
40
- */
41
- deadband?: number;
42
-
43
- /**
44
- * Minimum dwell in ms: no new move may be triggered within this window of the last one. Bounds
45
- * shift frequency independent of signal noise. Default {@link DEFAULT_MIN_DWELL_MS} (10 min).
46
- */
47
- minDwellMs?: number;
48
-
49
- /**
50
- * Injectable clock (Unix ms), used only for the dwell timer. Defaults to `Date.now`; tests
51
- * inject a fake clock so dwell can be exercised without sleeping on wall time.
52
- */
53
- now?: () => number;
54
- }
55
-
56
- /**
57
- * Determines appropriate ring depth based on storage capacity and network demand.
58
- *
59
- * Ring depth represents keyspace partitioning:
60
- * - Ring 0: Full keyspace (1 partition)
61
- * - Ring N: 2^N partitions
62
- *
63
- * A node selects its ring based on: available_capacity / estimated_neighborhood_demand
64
- *
65
- * The transition decision ({@link shouldTransition}) is damped so it does not thrash near a ring
66
- * boundary: the demand signal is smoothed with an EWMA, a dead-band keeps a hovering ratio from
67
- * moving, and a minimum-dwell timer + single-step rule bound how often and how far it moves. See
68
- * `docs/arachnode-ring-handoff.md` § Part 1.
69
- */
70
- export class RingSelector {
71
- /** EWMA of available capacity (bytes). Undefined until the first sample seeds it. */
72
- private smoothedAvailable: number | undefined;
73
- /** EWMA of estimated total network data (bytes). Undefined until the first sample seeds it. */
74
- private smoothedTotalData: number | undefined;
75
- /** Clock time (ms) a move was last triggered; undefined = no move yet, so dwell does not block. */
76
- private lastMoveAt: number | undefined;
77
- private readonly now: () => number;
78
-
79
- constructor(
80
- private readonly fretAdapter: ArachnodeFretAdapter,
81
- private readonly storageMonitor: StorageMonitor,
82
- private readonly config: RingSelectorConfig
83
- ) {
84
- this.now = config.now ?? ((): number => Date.now());
85
- }
86
-
87
- /**
88
- * Determine appropriate ring depth from an instantaneous capacity + demand snapshot.
89
- *
90
- * This is the *undamped* reading used for the deterministic first-boot ring and anywhere a
91
- * one-shot estimate is wanted. The damped transition logic lives in {@link shouldTransition};
92
- * this method intentionally does not touch the EWMA state so its result is a pure function of
93
- * the current snapshot.
94
- */
95
- async determineRing(): Promise<number> {
96
- const capacity = await this.storageMonitor.getCapacity();
97
-
98
- if (capacity.available < this.config.minCapacity) {
99
- // Not enough capacity for any ring
100
- return -1;
101
- }
102
-
103
- const estimatedTotalData = this.estimateNetworkData(capacity.available);
104
-
105
- // Calculate what fraction of keyspace we can cover
106
- const coverage = capacity.available / estimatedTotalData;
107
-
108
- // Ring depth: 0 = full keyspace, N = 2^N partitions
109
- // If coverage = 0.01 (1%), we need ~100 partitions ≈ Ring 7
110
- // If coverage = 1.0 (100%), we can handle full keyspace = Ring 0
111
- const ringDepth = Math.max(0, Math.ceil(-Math.log2(Math.max(0.001, coverage))));
112
-
113
- return Math.min(ringDepth, 16); // Cap at Ring 16 (65536 partitions)
114
- }
115
-
116
- // NOTE: prefix extraction lives in `arachnode-partition.ts` (single source of truth shared with
117
- // the block-side responsibility derivation in RestorationCoordinator / RingShiftCoordinator).
118
-
119
- /**
120
- * Estimate total network spare capacity (bytes) by aggregating observed per-ring stats.
121
- *
122
- * See docs/arachnode.md §"Capacity Management and Ring Adjustment": ring selection should
123
- * reflect observed network demand, not a hard-coded constant. We treat Σ (peerCount ×
124
- * avgCapacity) over known rings as a proxy for total network spare capacity, so `coverage`
125
- * becomes our share of it. More peers (or richer peers) → smaller share → higher ring depth
126
- * (more specialization). On bootstrap, when no ring stats are observed yet, we fall back to a
127
- * conservative fixed estimate so first-boot behavior is deterministic.
128
- *
129
- * @param availableForFallback local available bytes, used only for the pathological
130
- * zero-aggregate case (e.g. all peers report 0 available) so coverage collapses to 1.0 (ring 0).
131
- */
132
- private estimateNetworkData(availableForFallback: number): number {
133
- const ringStats = this.fretAdapter.getRingStats();
134
- let estimatedTotalData: number;
135
- if (ringStats.length > 0) {
136
- estimatedTotalData = ringStats.reduce(
137
- (sum, stat) => sum + stat.peerCount * stat.avgCapacity,
138
- 0
139
- );
140
- } else {
141
- const avgBlockSize = 100 * 1024; // 100KB typical block
142
- const estimatedTotalBlocks = 1000; // Conservative estimate
143
- estimatedTotalData = estimatedTotalBlocks * avgBlockSize;
144
- }
145
-
146
- if (estimatedTotalData <= 0) {
147
- estimatedTotalData = availableForFallback;
148
- }
149
-
150
- return estimatedTotalData;
151
- }
152
-
153
- /**
154
- * Fold one sample into the EWMA of each demand input. Seeds from the first real sample rather
155
- * than from 0 — a 0 seed would drag the smoothed depth for many ticks.
156
- */
157
- private updateSmoothing(available: number, totalData: number): void {
158
- const alpha = this.config.smoothingAlpha ?? DEFAULT_SMOOTHING_ALPHA;
159
- this.smoothedAvailable = this.smoothedAvailable === undefined
160
- ? available
161
- : alpha * available + (1 - alpha) * this.smoothedAvailable;
162
- this.smoothedTotalData = this.smoothedTotalData === undefined
163
- ? totalData
164
- : alpha * totalData + (1 - alpha) * this.smoothedTotalData;
165
- }
166
-
167
- /**
168
- * Continuous smoothed ring depth `d = clamp(-log2(smoothedCoverage), 0, 16)` from the current
169
- * EWMA state. Returns 0 (ring 0) if no sample has been folded in yet.
170
- */
171
- private smoothedDepth(): number {
172
- const available = this.smoothedAvailable ?? 0;
173
- const totalData = this.smoothedTotalData ?? 0;
174
- const coverage = totalData > 0 ? available / totalData : 1;
175
- const depth = -Math.log2(Math.max(DEPTH_EPSILON, coverage));
176
- return Math.min(16, Math.max(0, depth));
177
- }
178
-
179
- /**
180
- * Calculate partition for a given ring depth and peer ID.
181
- */
182
- async calculatePartition(
183
- ringDepth: number,
184
- peerId: string
185
- ): Promise<{ prefixBits: number, prefixValue: number } | undefined> {
186
- if (ringDepth === 0) {
187
- return undefined; // Full keyspace, no partition
188
- }
189
-
190
- // Hash peer ID to get coordinate. hashPeerId reads `peerId.toMultihash().bytes`, so it
191
- // needs a real PeerId — reconstruct one from the string. Peers must occupy the SAME ring
192
- // coordinate FRET uses to place them (hashPeerId(peerId)), or restoration's block-prefix
193
- // vs peer-prefix comparison stops meaning "this peer owns this block's slice".
194
- const coord = await hashPeerId(peerIdFromString(peerId));
195
-
196
- // Extract prefix bits from coordinate (shared with block-side responsibility derivation).
197
- const prefixBits = ringDepth;
198
- const prefixValue = extractPrefix(coord, prefixBits);
199
-
200
- return { prefixBits, prefixValue };
201
- }
202
-
203
- /**
204
- * Create Arachnode info for this node.
205
- *
206
- * @param ringDepthOverride when supplied (e.g. the single-step target from
207
- * {@link shouldTransition}), the node advertises exactly this ring instead of a freshly
208
- * recomputed {@link determineRing}. The transition site passes it so the advertised ring can
209
- * never disagree with the ring the transition decision just chose.
210
- */
211
- async createArachnodeInfo(peerId: string, ringDepthOverride?: number): Promise<ArachnodeInfo> {
212
- const capacity = await this.storageMonitor.getCapacity();
213
- const ringDepth = ringDepthOverride ?? await this.determineRing();
214
- const partition = ringDepth >= 0
215
- ? await this.calculatePartition(ringDepth, peerId)
216
- : undefined;
217
-
218
- return {
219
- ringDepth: Math.max(0, ringDepth),
220
- partition,
221
- capacity: {
222
- total: capacity.total,
223
- used: capacity.used,
224
- available: capacity.available
225
- },
226
- status: 'active'
227
- };
228
- }
229
-
230
- /**
231
- * Decide whether a damped ring transition should start.
232
- *
233
- * Damping (see `docs/arachnode-ring-handoff.md` § Part 1):
234
- * - **Smoothing** — every call folds the current sample into an EWMA of the demand inputs and
235
- * works off the smoothed continuous depth `d`, so a single noisy sample cannot trigger a move.
236
- * - **Hysteresis** — anchored on the node's *currently-advertised* ring `R` (not a freshly
237
- * recomputed one). A move only fires when `d` is past the boundary by the dead-band `h`, so a
238
- * ratio hovering around an integer produces no move.
239
- * - **Dwell + single-step** — no new move within `minDwellMs` of the last, no move while a shift
240
- * is already in flight (`status === 'moving'`), and each move steps by exactly ±1 ring even
241
- * when `d` implies a larger jump.
242
- *
243
- * Return shape (`{ shouldMove, direction, newRingDepth }`) is the *trigger* the handoff protocol
244
- * consumes; it is kept stable so that consumer is unaffected.
245
- */
246
- async shouldTransition(): Promise<{
247
- shouldMove: boolean;
248
- direction?: 'in' | 'out';
249
- newRingDepth?: number
250
- }> {
251
- const capacity = await this.storageMonitor.getCapacity();
252
- const usedPercent = capacity.total > 0 ? capacity.used / capacity.total : 0;
253
-
254
- // Smoothing updates every tick, regardless of whether we act, so the EWMA keeps tracking
255
- // demand even while a candidate move is dwell-blocked or a shift is already in flight.
256
- const rawTotalData = this.estimateNetworkData(capacity.available);
257
- this.updateSmoothing(capacity.available, rawTotalData);
258
- const d = this.smoothedDepth();
259
-
260
- const info = this.fretAdapter.getMyArachnodeInfo();
261
- // Hysteresis anchor: the node's currently-advertised ring. Fall back to the smoothed depth
262
- // only in the pathological case where nothing has been advertised yet (production always
263
- // advertises at bootstrap before the first tick).
264
- const advertisedRing = info?.ringDepth ?? Math.min(16, Math.max(0, Math.round(d)));
265
-
266
- // No re-entrant shift: a move already in flight (advertise→confirm→release) owns the ring.
267
- if (info?.status === 'moving') {
268
- return { shouldMove: false };
269
- }
270
-
271
- // Minimum dwell: rate-limit so signal noise cannot drive rapid flips. `lastMoveAt` undefined
272
- // means no prior move, so the first genuine move is never blocked. The stamp here (at *trigger*)
273
- // keeps a move from immediately re-triggering before `status` flips to `moving`; the driver then
274
- // calls {@link recordShiftSettled} once the advertise→confirm→release handoff SETTLES, so the
275
- // effective dwell is measured from the *completed* shift (spec §1.3), not merely the trigger.
276
- const minDwellMs = this.config.minDwellMs ?? DEFAULT_MIN_DWELL_MS;
277
- if (this.lastMoveAt !== undefined && (this.now() - this.lastMoveAt) < minDwellMs) {
278
- return { shouldMove: false };
279
- }
280
-
281
- const h = this.config.deadband ?? DEFAULT_DEADBAND;
282
-
283
- // Move OUT (R → R+1): smoothed depth solidly past the outer boundary AND storage pressure high.
284
- if (d >= advertisedRing + 1 - h && usedPercent > this.config.thresholds.moveOut) {
285
- this.lastMoveAt = this.now();
286
- return {
287
- shouldMove: true,
288
- direction: 'out',
289
- newRingDepth: advertisedRing + 1 // single step, even if d implies a larger jump
290
- };
291
- }
292
-
293
- // Move IN (R → R-1): smoothed depth solidly past the inner boundary AND storage slack AND R>0.
294
- if (advertisedRing > 0 && d <= advertisedRing - 1 + h && usedPercent < this.config.thresholds.moveIn) {
295
- this.lastMoveAt = this.now();
296
- return {
297
- shouldMove: true,
298
- direction: 'in',
299
- newRingDepth: advertisedRing - 1 // single step
300
- };
301
- }
302
-
303
- return { shouldMove: false };
304
- }
305
-
306
- /**
307
- * Re-stamp the dwell timer at the moment a ring shift *settles* — the driver
308
- * (`RingShiftCoordinator` via `libp2p-node-base`) calls this once `executeShift` resolves, for a
309
- * completed OR a rolled-back move. So the minimum dwell is measured from the settled shift rather
310
- * than only from the trigger (`shouldTransition`): a completed shift dwells `minDwellMs` before the
311
- * next move (spec §1.3, "after a *completed* shift"), and a shift that keeps failing is rate-limited
312
- * rather than retried every tick. No-op-safe to call when no shift ran.
313
- */
314
- recordShiftSettled(): void {
315
- this.lastMoveAt = this.now();
316
- }
317
- }
1
+ import { hashPeerId } from 'p2p-fret';
2
+ import { peerIdFromString } from '@libp2p/peer-id';
3
+ import type { StorageMonitor } from './storage-monitor.js';
4
+ import type { ArachnodeInfo, ArachnodeFretAdapter } from './arachnode-fret-adapter.js';
5
+ import { extractPrefix } from './arachnode-partition.js';
6
+
7
+ /** EWMA weight for the demand inputs. Higher = more reactive, lower = smoother. */
8
+ const DEFAULT_SMOOTHING_ALPHA = 0.2;
9
+ /** Hysteresis half-width in rings. 0.5 puts a full ring between the move-out and move-in triggers. */
10
+ const DEFAULT_DEADBAND = 0.5;
11
+ /** Minimum time (ms) at a ring after a move is triggered before another may start. */
12
+ const DEFAULT_MIN_DWELL_MS = 10 * 60 * 1000;
13
+ /** Floor for smoothed coverage so `-log2` never sees 0; the [0,16] clamp bounds the result anyway. */
14
+ const DEPTH_EPSILON = 1e-12;
15
+
16
+ export interface RingSelectorConfig {
17
+ /** Minimum storage capacity in bytes */
18
+ minCapacity: number;
19
+
20
+ /** Thresholds for ring transitions */
21
+ thresholds: {
22
+ /** Move to outer ring when used > this % */
23
+ moveOut: number;
24
+ /** Move to inner ring when used < this % */
25
+ moveIn: number;
26
+ };
27
+
28
+ /**
29
+ * EWMA weight (0..1) applied to the demand inputs (available capacity and estimated network
30
+ * data) each sample tick. A single noisy sample only moves the smoothed depth by this fraction.
31
+ * Default {@link DEFAULT_SMOOTHING_ALPHA} (0.2).
32
+ */
33
+ smoothingAlpha?: number;
34
+
35
+ /**
36
+ * Hysteresis dead-band half-width `h`, in rings. A move only fires when the smoothed depth is
37
+ * past the ring boundary by `h`, so the region of width `2h` around each integer produces no
38
+ * move. Default {@link DEFAULT_DEADBAND} (0.5) — a full ring between the move-out and move-in
39
+ * triggers, so a node can never satisfy both at once.
40
+ */
41
+ deadband?: number;
42
+
43
+ /**
44
+ * Minimum dwell in ms: no new move may be triggered within this window of the last one. Bounds
45
+ * shift frequency independent of signal noise. Default {@link DEFAULT_MIN_DWELL_MS} (10 min).
46
+ */
47
+ minDwellMs?: number;
48
+
49
+ /**
50
+ * Injectable clock (Unix ms), used only for the dwell timer. Defaults to `Date.now`; tests
51
+ * inject a fake clock so dwell can be exercised without sleeping on wall time.
52
+ */
53
+ now?: () => number;
54
+ }
55
+
56
+ /**
57
+ * Determines appropriate ring depth based on storage capacity and network demand.
58
+ *
59
+ * Ring depth represents keyspace partitioning:
60
+ * - Ring 0: Full keyspace (1 partition)
61
+ * - Ring N: 2^N partitions
62
+ *
63
+ * A node selects its ring based on: available_capacity / estimated_neighborhood_demand
64
+ *
65
+ * The transition decision ({@link shouldTransition}) is damped so it does not thrash near a ring
66
+ * boundary: the demand signal is smoothed with an EWMA, a dead-band keeps a hovering ratio from
67
+ * moving, and a minimum-dwell timer + single-step rule bound how often and how far it moves. See
68
+ * `docs/arachnode-ring-handoff.md` § Part 1.
69
+ */
70
+ export class RingSelector {
71
+ /** EWMA of available capacity (bytes). Undefined until the first sample seeds it. */
72
+ private smoothedAvailable: number | undefined;
73
+ /** EWMA of estimated total network data (bytes). Undefined until the first sample seeds it. */
74
+ private smoothedTotalData: number | undefined;
75
+ /** Clock time (ms) a move was last triggered; undefined = no move yet, so dwell does not block. */
76
+ private lastMoveAt: number | undefined;
77
+ private readonly now: () => number;
78
+
79
+ constructor(
80
+ private readonly fretAdapter: ArachnodeFretAdapter,
81
+ private readonly storageMonitor: StorageMonitor,
82
+ private readonly config: RingSelectorConfig
83
+ ) {
84
+ this.now = config.now ?? ((): number => Date.now());
85
+ }
86
+
87
+ /**
88
+ * Determine appropriate ring depth from an instantaneous capacity + demand snapshot.
89
+ *
90
+ * This is the *undamped* reading used for the deterministic first-boot ring and anywhere a
91
+ * one-shot estimate is wanted. The damped transition logic lives in {@link shouldTransition};
92
+ * this method intentionally does not touch the EWMA state so its result is a pure function of
93
+ * the current snapshot.
94
+ */
95
+ async determineRing(): Promise<number> {
96
+ const capacity = await this.storageMonitor.getCapacity();
97
+
98
+ if (capacity.available < this.config.minCapacity) {
99
+ // Not enough capacity for any ring
100
+ return -1;
101
+ }
102
+
103
+ const estimatedTotalData = this.estimateNetworkData(capacity.available);
104
+
105
+ // Calculate what fraction of keyspace we can cover
106
+ const coverage = capacity.available / estimatedTotalData;
107
+
108
+ // Ring depth: 0 = full keyspace, N = 2^N partitions
109
+ // If coverage = 0.01 (1%), we need ~100 partitions ≈ Ring 7
110
+ // If coverage = 1.0 (100%), we can handle full keyspace = Ring 0
111
+ const ringDepth = Math.max(0, Math.ceil(-Math.log2(Math.max(0.001, coverage))));
112
+
113
+ return Math.min(ringDepth, 16); // Cap at Ring 16 (65536 partitions)
114
+ }
115
+
116
+ // NOTE: prefix extraction lives in `arachnode-partition.ts` (single source of truth shared with
117
+ // the block-side responsibility derivation in RestorationCoordinator / RingShiftCoordinator).
118
+
119
+ /**
120
+ * Estimate total network spare capacity (bytes) by aggregating observed per-ring stats.
121
+ *
122
+ * See docs/arachnode.md §"Capacity Management and Ring Adjustment": ring selection should
123
+ * reflect observed network demand, not a hard-coded constant. We treat Σ (peerCount ×
124
+ * avgCapacity) over known rings as a proxy for total network spare capacity, so `coverage`
125
+ * becomes our share of it. More peers (or richer peers) → smaller share → higher ring depth
126
+ * (more specialization). On bootstrap, when no ring stats are observed yet, we fall back to a
127
+ * conservative fixed estimate so first-boot behavior is deterministic.
128
+ *
129
+ * @param availableForFallback local available bytes, used only for the pathological
130
+ * zero-aggregate case (e.g. all peers report 0 available) so coverage collapses to 1.0 (ring 0).
131
+ */
132
+ private estimateNetworkData(availableForFallback: number): number {
133
+ const ringStats = this.fretAdapter.getRingStats();
134
+ let estimatedTotalData: number;
135
+ if (ringStats.length > 0) {
136
+ estimatedTotalData = ringStats.reduce(
137
+ (sum, stat) => sum + stat.peerCount * stat.avgCapacity,
138
+ 0
139
+ );
140
+ } else {
141
+ const avgBlockSize = 100 * 1024; // 100KB typical block
142
+ const estimatedTotalBlocks = 1000; // Conservative estimate
143
+ estimatedTotalData = estimatedTotalBlocks * avgBlockSize;
144
+ }
145
+
146
+ if (estimatedTotalData <= 0) {
147
+ estimatedTotalData = availableForFallback;
148
+ }
149
+
150
+ return estimatedTotalData;
151
+ }
152
+
153
+ /**
154
+ * Fold one sample into the EWMA of each demand input. Seeds from the first real sample rather
155
+ * than from 0 — a 0 seed would drag the smoothed depth for many ticks.
156
+ */
157
+ private updateSmoothing(available: number, totalData: number): void {
158
+ const alpha = this.config.smoothingAlpha ?? DEFAULT_SMOOTHING_ALPHA;
159
+ this.smoothedAvailable = this.smoothedAvailable === undefined
160
+ ? available
161
+ : alpha * available + (1 - alpha) * this.smoothedAvailable;
162
+ this.smoothedTotalData = this.smoothedTotalData === undefined
163
+ ? totalData
164
+ : alpha * totalData + (1 - alpha) * this.smoothedTotalData;
165
+ }
166
+
167
+ /**
168
+ * Continuous smoothed ring depth `d = clamp(-log2(smoothedCoverage), 0, 16)` from the current
169
+ * EWMA state. Returns 0 (ring 0) if no sample has been folded in yet.
170
+ */
171
+ private smoothedDepth(): number {
172
+ const available = this.smoothedAvailable ?? 0;
173
+ const totalData = this.smoothedTotalData ?? 0;
174
+ const coverage = totalData > 0 ? available / totalData : 1;
175
+ const depth = -Math.log2(Math.max(DEPTH_EPSILON, coverage));
176
+ return Math.min(16, Math.max(0, depth));
177
+ }
178
+
179
+ /**
180
+ * Calculate partition for a given ring depth and peer ID.
181
+ */
182
+ async calculatePartition(
183
+ ringDepth: number,
184
+ peerId: string
185
+ ): Promise<{ prefixBits: number, prefixValue: number } | undefined> {
186
+ if (ringDepth === 0) {
187
+ return undefined; // Full keyspace, no partition
188
+ }
189
+
190
+ // Hash peer ID to get coordinate. hashPeerId reads `peerId.toMultihash().bytes`, so it
191
+ // needs a real PeerId — reconstruct one from the string. Peers must occupy the SAME ring
192
+ // coordinate FRET uses to place them (hashPeerId(peerId)), or restoration's block-prefix
193
+ // vs peer-prefix comparison stops meaning "this peer owns this block's slice".
194
+ const coord = await hashPeerId(peerIdFromString(peerId));
195
+
196
+ // Extract prefix bits from coordinate (shared with block-side responsibility derivation).
197
+ const prefixBits = ringDepth;
198
+ const prefixValue = extractPrefix(coord, prefixBits);
199
+
200
+ return { prefixBits, prefixValue };
201
+ }
202
+
203
+ /**
204
+ * Create Arachnode info for this node.
205
+ *
206
+ * @param ringDepthOverride when supplied (e.g. the single-step target from
207
+ * {@link shouldTransition}), the node advertises exactly this ring instead of a freshly
208
+ * recomputed {@link determineRing}. The transition site passes it so the advertised ring can
209
+ * never disagree with the ring the transition decision just chose.
210
+ */
211
+ async createArachnodeInfo(peerId: string, ringDepthOverride?: number): Promise<ArachnodeInfo> {
212
+ const capacity = await this.storageMonitor.getCapacity();
213
+ const ringDepth = ringDepthOverride ?? await this.determineRing();
214
+ const partition = ringDepth >= 0
215
+ ? await this.calculatePartition(ringDepth, peerId)
216
+ : undefined;
217
+
218
+ return {
219
+ ringDepth: Math.max(0, ringDepth),
220
+ partition,
221
+ capacity: {
222
+ total: capacity.total,
223
+ used: capacity.used,
224
+ available: capacity.available
225
+ },
226
+ status: 'active'
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Decide whether a damped ring transition should start.
232
+ *
233
+ * Damping (see `docs/arachnode-ring-handoff.md` § Part 1):
234
+ * - **Smoothing** — every call folds the current sample into an EWMA of the demand inputs and
235
+ * works off the smoothed continuous depth `d`, so a single noisy sample cannot trigger a move.
236
+ * - **Hysteresis** — anchored on the node's *currently-advertised* ring `R` (not a freshly
237
+ * recomputed one). A move only fires when `d` is past the boundary by the dead-band `h`, so a
238
+ * ratio hovering around an integer produces no move.
239
+ * - **Dwell + single-step** — no new move within `minDwellMs` of the last, no move while a shift
240
+ * is already in flight (`status === 'moving'`), and each move steps by exactly ±1 ring even
241
+ * when `d` implies a larger jump.
242
+ *
243
+ * Return shape (`{ shouldMove, direction, newRingDepth }`) is the *trigger* the handoff protocol
244
+ * consumes; it is kept stable so that consumer is unaffected.
245
+ */
246
+ async shouldTransition(): Promise<{
247
+ shouldMove: boolean;
248
+ direction?: 'in' | 'out';
249
+ newRingDepth?: number
250
+ }> {
251
+ const capacity = await this.storageMonitor.getCapacity();
252
+ const usedPercent = capacity.total > 0 ? capacity.used / capacity.total : 0;
253
+
254
+ // Smoothing updates every tick, regardless of whether we act, so the EWMA keeps tracking
255
+ // demand even while a candidate move is dwell-blocked or a shift is already in flight.
256
+ const rawTotalData = this.estimateNetworkData(capacity.available);
257
+ this.updateSmoothing(capacity.available, rawTotalData);
258
+ const d = this.smoothedDepth();
259
+
260
+ const info = this.fretAdapter.getMyArachnodeInfo();
261
+ // Hysteresis anchor: the node's currently-advertised ring. Fall back to the smoothed depth
262
+ // only in the pathological case where nothing has been advertised yet (production always
263
+ // advertises at bootstrap before the first tick).
264
+ const advertisedRing = info?.ringDepth ?? Math.min(16, Math.max(0, Math.round(d)));
265
+
266
+ // No re-entrant shift: a move already in flight (advertise→confirm→release) owns the ring.
267
+ if (info?.status === 'moving') {
268
+ return { shouldMove: false };
269
+ }
270
+
271
+ // Minimum dwell: rate-limit so signal noise cannot drive rapid flips. `lastMoveAt` undefined
272
+ // means no prior move, so the first genuine move is never blocked. The stamp here (at *trigger*)
273
+ // keeps a move from immediately re-triggering before `status` flips to `moving`; the driver then
274
+ // calls {@link recordShiftSettled} once the advertise→confirm→release handoff SETTLES, so the
275
+ // effective dwell is measured from the *completed* shift (spec §1.3), not merely the trigger.
276
+ const minDwellMs = this.config.minDwellMs ?? DEFAULT_MIN_DWELL_MS;
277
+ if (this.lastMoveAt !== undefined && (this.now() - this.lastMoveAt) < minDwellMs) {
278
+ return { shouldMove: false };
279
+ }
280
+
281
+ const h = this.config.deadband ?? DEFAULT_DEADBAND;
282
+
283
+ // Move OUT (R → R+1): smoothed depth solidly past the outer boundary AND storage pressure high.
284
+ if (d >= advertisedRing + 1 - h && usedPercent > this.config.thresholds.moveOut) {
285
+ this.lastMoveAt = this.now();
286
+ return {
287
+ shouldMove: true,
288
+ direction: 'out',
289
+ newRingDepth: advertisedRing + 1 // single step, even if d implies a larger jump
290
+ };
291
+ }
292
+
293
+ // Move IN (R → R-1): smoothed depth solidly past the inner boundary AND storage slack AND R>0.
294
+ if (advertisedRing > 0 && d <= advertisedRing - 1 + h && usedPercent < this.config.thresholds.moveIn) {
295
+ this.lastMoveAt = this.now();
296
+ return {
297
+ shouldMove: true,
298
+ direction: 'in',
299
+ newRingDepth: advertisedRing - 1 // single step
300
+ };
301
+ }
302
+
303
+ return { shouldMove: false };
304
+ }
305
+
306
+ /**
307
+ * Re-stamp the dwell timer at the moment a ring shift *settles* — the driver
308
+ * (`RingShiftCoordinator` via `libp2p-node-base`) calls this once `executeShift` resolves, for a
309
+ * completed OR a rolled-back move. So the minimum dwell is measured from the settled shift rather
310
+ * than only from the trigger (`shouldTransition`): a completed shift dwells `minDwellMs` before the
311
+ * next move (spec §1.3, "after a *completed* shift"), and a shift that keeps failing is rate-limited
312
+ * rather than retried every tick. No-op-safe to call when no shift ran.
313
+ */
314
+ recordShiftSettled(): void {
315
+ this.lastMoveAt = this.now();
316
+ }
317
+ }