@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.
- package/dist/src/cluster/client.d.ts +10 -0
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +30 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +39 -14
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +226 -119
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/service.d.ts +32 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +43 -2
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cohort-topic/host.js +34 -11
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +37 -7
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +77 -19
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +9 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +88 -2
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +134 -28
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +25 -1
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +17 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +19 -2
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/query-transport.js +3 -3
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +6 -3
- package/dist/src/owned-block-seed.d.ts.map +1 -1
- package/dist/src/owned-block-seed.js +16 -3
- package/dist/src/owned-block-seed.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +72 -0
- package/dist/src/peer-address-book.d.ts.map +1 -0
- package/dist/src/peer-address-book.js +123 -0
- package/dist/src/peer-address-book.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts +4 -4
- package/dist/src/reactivity/notify-transport.js +6 -6
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -2
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/reactivity/recover-transport.d.ts +6 -2
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
- package/dist/src/reactivity/recover-transport.js +7 -3
- package/dist/src/reactivity/recover-transport.js.map +1 -1
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +11 -2
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +30 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +95 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +62 -9
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +242 -73
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/rn.d.ts +3 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +3 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +83 -0
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/cached-raw-storage.js +152 -0
- package/dist/src/storage/cached-raw-storage.js.map +1 -0
- package/dist/src/storage/cached-store-driver.d.ts +186 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
- package/dist/src/storage/cached-store-driver.js +775 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -0
- package/dist/src/storage/i-raw-storage.d.ts +12 -5
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +234 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
- package/dist/src/storage/shared-cache-pool.js +354 -0
- package/dist/src/storage/shared-cache-pool.js.map +1 -0
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +35 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +668 -668
- package/src/cluster/block-transfer.ts +424 -424
- package/src/cluster/client.ts +119 -88
- package/src/cluster/cluster-error.ts +64 -64
- package/src/cluster/cluster-policy.ts +203 -203
- package/src/cluster/cluster-repo.ts +242 -122
- package/src/cluster/cluster-size-coupling.ts +45 -45
- package/src/cluster/commit-cert.ts +139 -139
- package/src/cluster/i-transaction-state-store.ts +43 -43
- package/src/cluster/memory-transaction-state-store.ts +56 -56
- package/src/cluster/peer-key-binding.ts +37 -37
- package/src/cluster/persistent-transaction-state-store.ts +92 -92
- package/src/cluster/quorum-restore.ts +223 -223
- package/src/cluster/reconcile-block.ts +203 -203
- package/src/cluster/service.ts +293 -241
- package/src/cluster/supermajority-coupling.ts +37 -37
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
- package/src/cohort-topic/change-bridge.ts +109 -109
- package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
- package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
- package/src/cohort-topic/fret-trust-anchor.ts +153 -153
- package/src/cohort-topic/host.ts +42 -11
- package/src/cohort-topic/index.ts +13 -13
- package/src/cohort-topic/membership-publish-sink.ts +20 -20
- package/src/cohort-topic/membership-source.ts +68 -68
- package/src/cohort-topic/peer-codec.ts +31 -31
- package/src/cohort-topic/peer-sig.ts +86 -86
- package/src/cohort-topic/protocols.ts +71 -71
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
- package/src/cohort-topic/size-estimator.ts +16 -16
- package/src/cohort-topic/stream-util.ts +79 -19
- package/src/cohort-topic/threshold-crypto.ts +239 -239
- package/src/cohort-topic/topic-router.ts +77 -77
- package/src/dispute/arbitrator-selection.ts +138 -138
- package/src/dispute/cascade.ts +524 -524
- package/src/dispute/dispute-service.ts +11 -5
- package/src/dispute/invalidation.ts +625 -625
- package/src/inbound-authorization.ts +190 -190
- package/src/index.ts +52 -49
- package/src/libp2p-key-network.ts +1120 -990
- package/src/libp2p-node-base.ts +1675 -1651
- package/src/libp2p-node-rn.ts +30 -30
- package/src/libp2p-node.ts +36 -36
- package/src/logger.ts +19 -2
- package/src/matchmaking/aggregate-counts.ts +104 -104
- package/src/matchmaking/index.ts +20 -20
- package/src/matchmaking/module.ts +363 -363
- package/src/matchmaking/protocols.ts +51 -51
- package/src/matchmaking/provider-manager.ts +95 -95
- package/src/matchmaking/query-handler.ts +88 -88
- package/src/matchmaking/query-transport.ts +3 -3
- package/src/matchmaking/seeker-manager.ts +64 -64
- package/src/matchmaking/seeker-walk-client.ts +293 -293
- package/src/matchmaking/traffic-validation.ts +195 -195
- package/src/optimystic-node.ts +36 -36
- package/src/owned-block-seed.ts +53 -40
- package/src/peer-address-book.ts +149 -0
- package/src/protocol-limits.ts +33 -33
- package/src/reactivity/forwarder-host.ts +438 -438
- package/src/reactivity/index.ts +19 -19
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/origination-manager.ts +192 -192
- package/src/reactivity/protocols.ts +61 -61
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +7 -3
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
- package/src/reactivity/subscriber-registry.ts +96 -96
- package/src/reactivity/subscription-manager.ts +450 -450
- package/src/reactivity/topic-bytes.ts +37 -37
- package/src/repo/client.ts +12 -2
- package/src/repo/cluster-coordinator.ts +99 -3
- package/src/repo/coordinator-repo.ts +281 -74
- package/src/repo/types.ts +7 -7
- package/src/rn.ts +39 -36
- package/src/rpc-deadline.ts +45 -45
- package/src/storage/arachnode-partition.ts +74 -74
- package/src/storage/cached-raw-storage.ts +180 -0
- package/src/storage/cached-store-driver.ts +859 -0
- package/src/storage/i-kv-store.ts +8 -8
- package/src/storage/i-raw-storage.ts +12 -5
- package/src/storage/kv-raw-storage.ts +135 -135
- package/src/storage/memory-kv-store.ts +28 -28
- package/src/storage/memory-storage.ts +25 -25
- package/src/storage/memory-store-driver.ts +157 -157
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +80 -80
- package/src/storage/ring-selector.ts +317 -317
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/shared-cache-pool.ts +452 -0
- package/src/storage/storage-repo.ts +1014 -1014
- package/src/testing/cohort-topic-mesh-harness.ts +673 -663
- package/src/testing/index.ts +8 -8
- package/src/testing/matchmaking-mesh-harness.ts +475 -475
- package/src/testing/raw-storage-conformance.ts +453 -417
- package/src/testing/reactivity-mesh-harness.ts +922 -922
- package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
- package/dist/src/storage/restoration-coordinator-v2.js +0 -172
- 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
|
+
}
|