@optimystic/db-core 0.22.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.
- package/README.md +336 -336
- package/dist/src/cluster/structs.d.ts +39 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +24 -0
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +17 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +24 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +5 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +7 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-peer-network.d.ts +16 -0
- package/dist/src/network/i-peer-network.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +39 -2
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +18 -0
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +95 -8
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +121 -8
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +1 -1
- package/dist/src/transaction/transaction.js +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +48 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +25 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/membership.ts +85 -85
- package/src/cluster/structs.ts +43 -4
- package/src/cohort-topic/addressing.ts +120 -120
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
- package/src/cohort-topic/antidos/index.ts +5 -5
- package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
- package/src/cohort-topic/antidos/replay-guard.ts +146 -146
- package/src/cohort-topic/antidos/topic-budget.ts +160 -160
- package/src/cohort-topic/antiflood/index.ts +2 -2
- package/src/cohort-topic/antiflood/invariants.ts +108 -108
- package/src/cohort-topic/antiflood/jitter.ts +117 -117
- package/src/cohort-topic/coldstart.ts +237 -237
- package/src/cohort-topic/dmax.ts +88 -88
- package/src/cohort-topic/gossip/bus.ts +254 -254
- package/src/cohort-topic/gossip/index.ts +3 -3
- package/src/cohort-topic/gossip/records.ts +45 -45
- package/src/cohort-topic/gossip/view.ts +91 -91
- package/src/cohort-topic/index.ts +20 -20
- package/src/cohort-topic/load/barometer.ts +134 -134
- package/src/cohort-topic/load/index.ts +1 -1
- package/src/cohort-topic/member-engine.ts +430 -430
- package/src/cohort-topic/membership/index.ts +3 -3
- package/src/cohort-topic/membership/publisher.ts +163 -163
- package/src/cohort-topic/membership/source.ts +41 -41
- package/src/cohort-topic/membership/verifier.ts +461 -461
- package/src/cohort-topic/ports.ts +157 -157
- package/src/cohort-topic/promotion.ts +405 -405
- package/src/cohort-topic/registration/bytes.ts +37 -37
- package/src/cohort-topic/registration/handoff.ts +154 -154
- package/src/cohort-topic/registration/index.ts +6 -6
- package/src/cohort-topic/registration/renewal.ts +495 -495
- package/src/cohort-topic/registration/sharding.ts +61 -61
- package/src/cohort-topic/registration/store.ts +81 -81
- package/src/cohort-topic/registration/types.ts +91 -91
- package/src/cohort-topic/ring-hash.ts +50 -50
- package/src/cohort-topic/service.ts +416 -416
- package/src/cohort-topic/sig/index.ts +2 -2
- package/src/cohort-topic/sig/payloads.ts +59 -59
- package/src/cohort-topic/sig/threshold.ts +64 -64
- package/src/cohort-topic/tiers.ts +74 -74
- package/src/cohort-topic/traffic.ts +233 -233
- package/src/cohort-topic/walk.ts +326 -326
- package/src/cohort-topic/willingness.ts +237 -237
- package/src/cohort-topic/wire/codec.ts +216 -216
- package/src/cohort-topic/wire/index.ts +18 -18
- package/src/cohort-topic/wire/payloads.ts +126 -126
- package/src/cohort-topic/wire/primitives.ts +188 -188
- package/src/cohort-topic/wire/types.ts +475 -475
- package/src/cohort-topic/wire/validate.ts +512 -512
- package/src/collection/collection-type-registry.ts +37 -37
- package/src/collection/collection.ts +25 -2
- package/src/collections/diary/diary.ts +68 -68
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/tree.ts +320 -312
- package/src/matchmaking/capability-filter.ts +45 -45
- package/src/matchmaking/config.ts +98 -98
- package/src/matchmaking/index.ts +21 -21
- package/src/matchmaking/multi-cohort-seeker.ts +234 -234
- package/src/matchmaking/provider.ts +123 -123
- package/src/matchmaking/query-eval.ts +105 -105
- package/src/matchmaking/seeker-walk.ts +127 -127
- package/src/matchmaking/seeker.ts +86 -86
- package/src/matchmaking/topic-anchor.ts +90 -90
- package/src/matchmaking/voting-quorum.ts +394 -394
- package/src/matchmaking/wire.ts +603 -603
- package/src/network/i-peer-network.ts +17 -0
- package/src/network/stale-failure.ts +43 -43
- package/src/network/struct.ts +41 -2
- package/src/network/types.ts +37 -37
- package/src/reactivity/backfill.ts +220 -220
- package/src/reactivity/backpressure.ts +191 -191
- package/src/reactivity/checkpoint.ts +308 -308
- package/src/reactivity/config.ts +172 -172
- package/src/reactivity/dedupe.ts +132 -132
- package/src/reactivity/forwarder.ts +87 -87
- package/src/reactivity/index.ts +34 -34
- package/src/reactivity/notification.ts +123 -123
- package/src/reactivity/policy.ts +79 -79
- package/src/reactivity/push-state.ts +310 -310
- package/src/reactivity/recover.ts +153 -153
- package/src/reactivity/replay-buffer.ts +141 -141
- package/src/reactivity/resume.ts +549 -549
- package/src/reactivity/rotation.ts +415 -415
- package/src/reactivity/subscriber.ts +132 -132
- package/src/reactivity/subscription.ts +66 -66
- package/src/reactivity/topic-anchor.ts +71 -71
- package/src/reactivity/verify.ts +73 -73
- package/src/reactivity/wire-validate.ts +13 -13
- package/src/reactivity/wire.ts +224 -224
- package/src/testing/async-wait.ts +65 -65
- package/src/testing/index.ts +2 -2
- package/src/testing/test-transactor.ts +638 -502
- package/src/transaction/errors.ts +91 -91
- package/src/transaction/operations-hash.ts +196 -196
- package/src/transaction/read-dependency-collector.ts +78 -78
- package/src/transaction/transaction.ts +1 -1
- package/src/transactor/change-notifier.ts +80 -80
- package/src/transactor/index.ts +5 -5
- package/src/transactor/network-transactor.ts +49 -14
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/helpers.ts +159 -159
- package/src/utility/backoff.ts +95 -95
- package/src/utility/batch-coordinator.ts +191 -191
- package/dist/src/transaction/context.d.ts +0 -60
- package/dist/src/transaction/context.d.ts.map +0 -1
- package/dist/src/transaction/context.js +0 -91
- package/dist/src/transaction/context.js.map +0 -1
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — cold-start instantiation.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Cold-start instantiation and folded back from the
|
|
5
|
-
* simulator-validated `packages/substrate-simulator/src/walk.ts` (the `followOn` / `bootstrap`
|
|
6
|
-
* instantiation paths). A **cold** cohort — one holding no forwarder state for a topic — instantiates
|
|
7
|
-
* as a forwarder for `T` when:
|
|
8
|
-
*
|
|
9
|
-
* - it receives a `RegisterV1` for `T` it does not yet serve, AND
|
|
10
|
-
* - the registration is a legitimate growth point: its `bootstrap: true` flag is set (the root
|
|
11
|
-
* cold-start case) **or** it arrived as a follow-on to a parent cohort's `Promoted` redirect, AND
|
|
12
|
-
* - a quorum of cohort members is willing to serve `T` at the registration's tier.
|
|
13
|
-
*
|
|
14
|
-
* The `followOn` signal **is** carried on the wire (`RegisterV1.followOn`), set only on the dedicated
|
|
15
|
-
* re-issue a participant sends after a parent cohort's `Promoted` redirect target answered `NoState`
|
|
16
|
-
* (the deeper child is cold). A cold high-tier cohort still returns `NoState` to a *speculative* `d_max`
|
|
17
|
-
* probe (neither flag set), but the single-direction walk means the only registrations that reach a cold
|
|
18
|
-
* tier-`(d+1)` cohort carrying `followOn: true` are those that followed the parent's `Promoted` redirect.
|
|
19
|
-
* The db-p2p cohort host derives `ctx.followOn` from the wire flag (`followOn: reg.followOn === true`) and
|
|
20
|
-
* db-core takes it as an explicit input to {@link shouldInstantiate}, keeping this module FRET-free. Because
|
|
21
|
-
* the flag is participant-forgeable, a `followOn: true` register is gated by the **same** tier-dependent
|
|
22
|
-
* bootstrap-evidence policy a `bootstrap: true` cold-root register passes (§Anti-DoS), so an unbacked
|
|
23
|
-
* follow-on never reaches this instantiation decision.
|
|
24
|
-
*
|
|
25
|
-
* Once instantiated, the new forwarder **registers itself with its tier-`(d − 1)` parent at first
|
|
26
|
-
* opportunity** by sending a child-link the parent authenticates + records; until that link is acked
|
|
27
|
-
* (`linked`) it {@link Forwarder.acceptsParticipants | accepts participants} but
|
|
28
|
-
* {@link Forwarder.servesParentOps | holds} notifications/queries that need parent involvement. The root
|
|
29
|
-
* (tree tier 0) has no parent and is serving immediately.
|
|
30
|
-
*
|
|
31
|
-
* **Just-promoted burst (GROUNDING-resolved: bounce, don't buffer).** A cohort that has just promoted
|
|
32
|
-
* but whose tier-`(d+1)` isn't fully instantiated yet, on a burst of new same-tier registrations,
|
|
33
|
-
* replies `Promoted(d+1)` ({@link promotedRedirectReply}) — a cheap single RPC. It does **not** buffer
|
|
34
|
-
* the registrations and does **not** decline with `UnwillingCohort`; the promotion sticky window
|
|
35
|
-
* (`T_promote_sticky`) keeps it in promoted mode through the burst.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { attachTopicTraffic } from "./traffic.js";
|
|
39
|
-
import { bytesKey } from "./registration/bytes.js";
|
|
40
|
-
import type { RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
41
|
-
import { createLogger } from "../logger.js";
|
|
42
|
-
|
|
43
|
-
const log = createLogger('cohort-topic:coldstart');
|
|
44
|
-
|
|
45
|
-
/** Inputs to the cold-start admission gate. */
|
|
46
|
-
export interface ColdStartTrigger {
|
|
47
|
-
/** `RegisterV1.bootstrap` — the root cold-start request flag. */
|
|
48
|
-
readonly bootstrap: boolean;
|
|
49
|
-
/** This register arrived as a follow-on to a parent's `Promoted` redirect (db-p2p-determined). */
|
|
50
|
-
readonly followOn: boolean;
|
|
51
|
-
/** A quorum of cohort members is willing to serve the topic at this tier. */
|
|
52
|
-
readonly quorumWilling: boolean;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Whether a cold cohort should instantiate forwarder state for an inbound register: it must be a
|
|
57
|
-
* legitimate growth point (`bootstrap` root case **or** a `Promoted` follow-on) **and** have a willing
|
|
58
|
-
* quorum. A speculative `d_max` probe (neither flag set) yields `false`, so the walk gets `NoState`
|
|
59
|
-
* and steps toward the root instead of forking a parallel branch (§Cold-start; §Why the caller
|
|
60
|
-
* doesn't walk on UnwillingCohort).
|
|
61
|
-
*/
|
|
62
|
-
export function shouldInstantiate(trigger: ColdStartTrigger): boolean {
|
|
63
|
-
return (trigger.bootstrap || trigger.followOn) && trigger.quorumWilling;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Lifecycle phase of a freshly-instantiated forwarder. */
|
|
67
|
-
export type ForwarderPhase =
|
|
68
|
-
/** Instantiated, registering with the parent; accepts participants, holds parent-involving ops. */
|
|
69
|
-
| "awaiting_parent"
|
|
70
|
-
/** Fully linked (parent acked, or root with no parent); serves everything. */
|
|
71
|
-
| "serving";
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* A freshly cold-started forwarder for one topic at this cohort. Holds the "registered with parent
|
|
75
|
-
* yet?" state that gates parent-involving operations during the link-up window.
|
|
76
|
-
*/
|
|
77
|
-
export interface Forwarder {
|
|
78
|
-
/** Tree tier `d` this forwarder serves the topic at. */
|
|
79
|
-
readonly tier: number;
|
|
80
|
-
/** Current lifecycle phase. */
|
|
81
|
-
phase(): ForwarderPhase;
|
|
82
|
-
/** Accept new participants? True from instantiation onward (even before the parent ack). */
|
|
83
|
-
acceptsParticipants(): boolean;
|
|
84
|
-
/** Serve an operation needing parent involvement (notifications/queries)? Only once `serving`. */
|
|
85
|
-
servesParentOps(): boolean;
|
|
86
|
-
/** Mark the parent-registration RPC acked → transition to `serving`. Idempotent; no-op at the root. */
|
|
87
|
-
onParentAck(): void;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
class ForwarderState implements Forwarder {
|
|
91
|
-
private serving: boolean;
|
|
92
|
-
|
|
93
|
-
constructor(readonly tier: number, hasParent: boolean) {
|
|
94
|
-
// The root (tree tier 0 / no parent) has nothing to hand off to — it serves immediately.
|
|
95
|
-
this.serving = !hasParent;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
phase(): ForwarderPhase {
|
|
99
|
-
return this.serving ? "serving" : "awaiting_parent";
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
acceptsParticipants(): boolean {
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
servesParentOps(): boolean {
|
|
107
|
-
return this.serving;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
onParentAck(): void {
|
|
111
|
-
this.serving = true;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Construct a forwarder. A tier-`0` forwarder (the root) has no parent and starts `serving`; a deeper
|
|
117
|
-
* forwarder starts `awaiting_parent` until {@link Forwarder.onParentAck} fires.
|
|
118
|
-
*/
|
|
119
|
-
export function createForwarder(tier: number): Forwarder {
|
|
120
|
-
if (!Number.isInteger(tier) || tier < 0) {
|
|
121
|
-
throw new RangeError(`forwarder tier must be a non-negative integer, got ${tier}`);
|
|
122
|
-
}
|
|
123
|
-
return new ForwarderState(tier, tier > 0);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Build a `Promoted(targetTier)` redirect reply — used both for the normal promotion redirect and for
|
|
128
|
-
* the just-promoted-burst bounce (the registrations arriving at a promoted cohort while its child tier
|
|
129
|
-
* is still instantiating). The outgoing cohort's `topicTraffic` is attached when supplied, so the
|
|
130
|
-
* redirected participant can estimate whether the target tier is hot (§Topic traffic signal).
|
|
131
|
-
*/
|
|
132
|
-
export function promotedRedirectReply(targetTier: number, traffic?: TopicTrafficV1): RegisterReplyV1 {
|
|
133
|
-
if (!Number.isInteger(targetTier) || targetTier < 1) {
|
|
134
|
-
throw new RangeError(`promoted targetTier must be an integer ≥ 1, got ${targetTier}`);
|
|
135
|
-
}
|
|
136
|
-
const reply: RegisterReplyV1 = { v: 1, result: "promoted", targetTier };
|
|
137
|
-
return traffic !== undefined ? attachTopicTraffic(reply, traffic) : reply;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Registers a newly-instantiated forwarder with its tier-`(d − 1)` parent; resolves only when the parent
|
|
142
|
-
* authenticates + records the child and returns a `linked` ack. A `rejected` reply (bad coord binding /
|
|
143
|
-
* failed threshold verify) or an unreachable parent rejects the promise, so the forwarder stays
|
|
144
|
-
* `awaiting_parent` for a later retry.
|
|
145
|
-
*/
|
|
146
|
-
export interface ParentRegistrar {
|
|
147
|
-
/**
|
|
148
|
-
* Register the forwarder for `topicId` (served at tree tier `tier`, i.e. `d`) with the cohort at
|
|
149
|
-
* `parentCoord`; resolves on a `linked` ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so
|
|
150
|
-
* the transport can stamp a well-formed child-link frame; absent when the caller has no op-tier context
|
|
151
|
-
* (the cohort host always supplies it from the instantiating `RegisterV1`).
|
|
152
|
-
*/
|
|
153
|
-
registerWithParent(topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void>;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface ColdStartManagerDeps {
|
|
157
|
-
/** Drives the parent-registration RPC (db-p2p binds it to the router). */
|
|
158
|
-
parentRegistrar: ParentRegistrar;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Tracks cold-started forwarders for a cohort and drives each one's parent registration. Keyed by
|
|
163
|
-
* `topicId`; idempotent per topic (a second instantiate for an already-tracked topic returns the
|
|
164
|
-
* existing forwarder).
|
|
165
|
-
*/
|
|
166
|
-
export interface ColdStartManager {
|
|
167
|
-
/**
|
|
168
|
-
* Instantiate (or return the existing) forwarder for `topicId` at tree tier `tier` (`d`). For a
|
|
169
|
-
* deeper-than-root forwarder this kicks off parent registration in the background and flips the
|
|
170
|
-
* forwarder to `serving` on ack. The returned forwarder accepts participants immediately. `opTier`
|
|
171
|
-
* (the topic's T0–T3 capacity tier) is forwarded to the parent registrar for the link frame.
|
|
172
|
-
*/
|
|
173
|
-
instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder;
|
|
174
|
-
/** The tracked forwarder for `topicId`, or `undefined`. */
|
|
175
|
-
get(topicId: Uint8Array): Forwarder | undefined;
|
|
176
|
-
/**
|
|
177
|
-
* Drop the forwarder for `topicId` (budget eviction / teardown). Idempotent; a no-op if the topic is
|
|
178
|
-
* not tracked. After this, {@link get} returns `undefined` for `topicId`, so the cohort no longer
|
|
179
|
-
* reports serving it via cold-start state.
|
|
180
|
-
*/
|
|
181
|
-
remove(topicId: Uint8Array): void;
|
|
182
|
-
/**
|
|
183
|
-
* Whether this manager currently tracks any forwarder. The host's idle-engine eviction reads this to
|
|
184
|
-
* avoid reclaiming a cohort that holds a live (possibly `awaiting_parent`) cold-start forwarder.
|
|
185
|
-
*/
|
|
186
|
-
hasForwarders(): boolean;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
class TrackingColdStartManager implements ColdStartManager {
|
|
190
|
-
private readonly forwarders = new Map<string, Forwarder>();
|
|
191
|
-
|
|
192
|
-
constructor(private readonly deps: ColdStartManagerDeps) {}
|
|
193
|
-
|
|
194
|
-
instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder {
|
|
195
|
-
const key = bytesKey(topicId);
|
|
196
|
-
const existing = this.forwarders.get(key);
|
|
197
|
-
if (existing !== undefined) {
|
|
198
|
-
return existing;
|
|
199
|
-
}
|
|
200
|
-
const forwarder = createForwarder(tier);
|
|
201
|
-
this.forwarders.set(key, forwarder);
|
|
202
|
-
if (tier > 0) {
|
|
203
|
-
if (parentCoord === undefined) {
|
|
204
|
-
throw new Error(`cold-start of a tier-${tier} forwarder requires a parentCoord`);
|
|
205
|
-
}
|
|
206
|
-
// Register with the parent at first opportunity; flip to serving on ack. A failed parent
|
|
207
|
-
// registration leaves the forwarder accepting participants but holding parent-involving ops,
|
|
208
|
-
// so a later retry (driven by the host) can complete the link-up — surfaced, not swallowed.
|
|
209
|
-
void this.deps.parentRegistrar
|
|
210
|
-
.registerWithParent(topicId, parentCoord, tier, opTier)
|
|
211
|
-
.then(() => forwarder.onParentAck())
|
|
212
|
-
.catch((err: unknown) => {
|
|
213
|
-
log('WARN: parent registration for tier-%s forwarder failed %o', tier, err);
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
return forwarder;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
get(topicId: Uint8Array): Forwarder | undefined {
|
|
220
|
-
return this.forwarders.get(bytesKey(topicId));
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
remove(topicId: Uint8Array): void {
|
|
224
|
-
// Idempotent: `Map.delete` on an absent key is a safe no-op, so a double-remove (or a remove of a
|
|
225
|
-
// never-instantiated topic) does nothing.
|
|
226
|
-
this.forwarders.delete(bytesKey(topicId));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
hasForwarders(): boolean {
|
|
230
|
-
return this.forwarders.size > 0;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Build a {@link ColdStartManager} over the injected parent registrar. */
|
|
235
|
-
export function createColdStartManager(deps: ColdStartManagerDeps): ColdStartManager {
|
|
236
|
-
return new TrackingColdStartManager(deps);
|
|
237
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — cold-start instantiation.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Cold-start instantiation and folded back from the
|
|
5
|
+
* simulator-validated `packages/substrate-simulator/src/walk.ts` (the `followOn` / `bootstrap`
|
|
6
|
+
* instantiation paths). A **cold** cohort — one holding no forwarder state for a topic — instantiates
|
|
7
|
+
* as a forwarder for `T` when:
|
|
8
|
+
*
|
|
9
|
+
* - it receives a `RegisterV1` for `T` it does not yet serve, AND
|
|
10
|
+
* - the registration is a legitimate growth point: its `bootstrap: true` flag is set (the root
|
|
11
|
+
* cold-start case) **or** it arrived as a follow-on to a parent cohort's `Promoted` redirect, AND
|
|
12
|
+
* - a quorum of cohort members is willing to serve `T` at the registration's tier.
|
|
13
|
+
*
|
|
14
|
+
* The `followOn` signal **is** carried on the wire (`RegisterV1.followOn`), set only on the dedicated
|
|
15
|
+
* re-issue a participant sends after a parent cohort's `Promoted` redirect target answered `NoState`
|
|
16
|
+
* (the deeper child is cold). A cold high-tier cohort still returns `NoState` to a *speculative* `d_max`
|
|
17
|
+
* probe (neither flag set), but the single-direction walk means the only registrations that reach a cold
|
|
18
|
+
* tier-`(d+1)` cohort carrying `followOn: true` are those that followed the parent's `Promoted` redirect.
|
|
19
|
+
* The db-p2p cohort host derives `ctx.followOn` from the wire flag (`followOn: reg.followOn === true`) and
|
|
20
|
+
* db-core takes it as an explicit input to {@link shouldInstantiate}, keeping this module FRET-free. Because
|
|
21
|
+
* the flag is participant-forgeable, a `followOn: true` register is gated by the **same** tier-dependent
|
|
22
|
+
* bootstrap-evidence policy a `bootstrap: true` cold-root register passes (§Anti-DoS), so an unbacked
|
|
23
|
+
* follow-on never reaches this instantiation decision.
|
|
24
|
+
*
|
|
25
|
+
* Once instantiated, the new forwarder **registers itself with its tier-`(d − 1)` parent at first
|
|
26
|
+
* opportunity** by sending a child-link the parent authenticates + records; until that link is acked
|
|
27
|
+
* (`linked`) it {@link Forwarder.acceptsParticipants | accepts participants} but
|
|
28
|
+
* {@link Forwarder.servesParentOps | holds} notifications/queries that need parent involvement. The root
|
|
29
|
+
* (tree tier 0) has no parent and is serving immediately.
|
|
30
|
+
*
|
|
31
|
+
* **Just-promoted burst (GROUNDING-resolved: bounce, don't buffer).** A cohort that has just promoted
|
|
32
|
+
* but whose tier-`(d+1)` isn't fully instantiated yet, on a burst of new same-tier registrations,
|
|
33
|
+
* replies `Promoted(d+1)` ({@link promotedRedirectReply}) — a cheap single RPC. It does **not** buffer
|
|
34
|
+
* the registrations and does **not** decline with `UnwillingCohort`; the promotion sticky window
|
|
35
|
+
* (`T_promote_sticky`) keeps it in promoted mode through the burst.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { attachTopicTraffic } from "./traffic.js";
|
|
39
|
+
import { bytesKey } from "./registration/bytes.js";
|
|
40
|
+
import type { RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
41
|
+
import { createLogger } from "../logger.js";
|
|
42
|
+
|
|
43
|
+
const log = createLogger('cohort-topic:coldstart');
|
|
44
|
+
|
|
45
|
+
/** Inputs to the cold-start admission gate. */
|
|
46
|
+
export interface ColdStartTrigger {
|
|
47
|
+
/** `RegisterV1.bootstrap` — the root cold-start request flag. */
|
|
48
|
+
readonly bootstrap: boolean;
|
|
49
|
+
/** This register arrived as a follow-on to a parent's `Promoted` redirect (db-p2p-determined). */
|
|
50
|
+
readonly followOn: boolean;
|
|
51
|
+
/** A quorum of cohort members is willing to serve the topic at this tier. */
|
|
52
|
+
readonly quorumWilling: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a cold cohort should instantiate forwarder state for an inbound register: it must be a
|
|
57
|
+
* legitimate growth point (`bootstrap` root case **or** a `Promoted` follow-on) **and** have a willing
|
|
58
|
+
* quorum. A speculative `d_max` probe (neither flag set) yields `false`, so the walk gets `NoState`
|
|
59
|
+
* and steps toward the root instead of forking a parallel branch (§Cold-start; §Why the caller
|
|
60
|
+
* doesn't walk on UnwillingCohort).
|
|
61
|
+
*/
|
|
62
|
+
export function shouldInstantiate(trigger: ColdStartTrigger): boolean {
|
|
63
|
+
return (trigger.bootstrap || trigger.followOn) && trigger.quorumWilling;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lifecycle phase of a freshly-instantiated forwarder. */
|
|
67
|
+
export type ForwarderPhase =
|
|
68
|
+
/** Instantiated, registering with the parent; accepts participants, holds parent-involving ops. */
|
|
69
|
+
| "awaiting_parent"
|
|
70
|
+
/** Fully linked (parent acked, or root with no parent); serves everything. */
|
|
71
|
+
| "serving";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A freshly cold-started forwarder for one topic at this cohort. Holds the "registered with parent
|
|
75
|
+
* yet?" state that gates parent-involving operations during the link-up window.
|
|
76
|
+
*/
|
|
77
|
+
export interface Forwarder {
|
|
78
|
+
/** Tree tier `d` this forwarder serves the topic at. */
|
|
79
|
+
readonly tier: number;
|
|
80
|
+
/** Current lifecycle phase. */
|
|
81
|
+
phase(): ForwarderPhase;
|
|
82
|
+
/** Accept new participants? True from instantiation onward (even before the parent ack). */
|
|
83
|
+
acceptsParticipants(): boolean;
|
|
84
|
+
/** Serve an operation needing parent involvement (notifications/queries)? Only once `serving`. */
|
|
85
|
+
servesParentOps(): boolean;
|
|
86
|
+
/** Mark the parent-registration RPC acked → transition to `serving`. Idempotent; no-op at the root. */
|
|
87
|
+
onParentAck(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class ForwarderState implements Forwarder {
|
|
91
|
+
private serving: boolean;
|
|
92
|
+
|
|
93
|
+
constructor(readonly tier: number, hasParent: boolean) {
|
|
94
|
+
// The root (tree tier 0 / no parent) has nothing to hand off to — it serves immediately.
|
|
95
|
+
this.serving = !hasParent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
phase(): ForwarderPhase {
|
|
99
|
+
return this.serving ? "serving" : "awaiting_parent";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
acceptsParticipants(): boolean {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
servesParentOps(): boolean {
|
|
107
|
+
return this.serving;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
onParentAck(): void {
|
|
111
|
+
this.serving = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Construct a forwarder. A tier-`0` forwarder (the root) has no parent and starts `serving`; a deeper
|
|
117
|
+
* forwarder starts `awaiting_parent` until {@link Forwarder.onParentAck} fires.
|
|
118
|
+
*/
|
|
119
|
+
export function createForwarder(tier: number): Forwarder {
|
|
120
|
+
if (!Number.isInteger(tier) || tier < 0) {
|
|
121
|
+
throw new RangeError(`forwarder tier must be a non-negative integer, got ${tier}`);
|
|
122
|
+
}
|
|
123
|
+
return new ForwarderState(tier, tier > 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a `Promoted(targetTier)` redirect reply — used both for the normal promotion redirect and for
|
|
128
|
+
* the just-promoted-burst bounce (the registrations arriving at a promoted cohort while its child tier
|
|
129
|
+
* is still instantiating). The outgoing cohort's `topicTraffic` is attached when supplied, so the
|
|
130
|
+
* redirected participant can estimate whether the target tier is hot (§Topic traffic signal).
|
|
131
|
+
*/
|
|
132
|
+
export function promotedRedirectReply(targetTier: number, traffic?: TopicTrafficV1): RegisterReplyV1 {
|
|
133
|
+
if (!Number.isInteger(targetTier) || targetTier < 1) {
|
|
134
|
+
throw new RangeError(`promoted targetTier must be an integer ≥ 1, got ${targetTier}`);
|
|
135
|
+
}
|
|
136
|
+
const reply: RegisterReplyV1 = { v: 1, result: "promoted", targetTier };
|
|
137
|
+
return traffic !== undefined ? attachTopicTraffic(reply, traffic) : reply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Registers a newly-instantiated forwarder with its tier-`(d − 1)` parent; resolves only when the parent
|
|
142
|
+
* authenticates + records the child and returns a `linked` ack. A `rejected` reply (bad coord binding /
|
|
143
|
+
* failed threshold verify) or an unreachable parent rejects the promise, so the forwarder stays
|
|
144
|
+
* `awaiting_parent` for a later retry.
|
|
145
|
+
*/
|
|
146
|
+
export interface ParentRegistrar {
|
|
147
|
+
/**
|
|
148
|
+
* Register the forwarder for `topicId` (served at tree tier `tier`, i.e. `d`) with the cohort at
|
|
149
|
+
* `parentCoord`; resolves on a `linked` ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so
|
|
150
|
+
* the transport can stamp a well-formed child-link frame; absent when the caller has no op-tier context
|
|
151
|
+
* (the cohort host always supplies it from the instantiating `RegisterV1`).
|
|
152
|
+
*/
|
|
153
|
+
registerWithParent(topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ColdStartManagerDeps {
|
|
157
|
+
/** Drives the parent-registration RPC (db-p2p binds it to the router). */
|
|
158
|
+
parentRegistrar: ParentRegistrar;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Tracks cold-started forwarders for a cohort and drives each one's parent registration. Keyed by
|
|
163
|
+
* `topicId`; idempotent per topic (a second instantiate for an already-tracked topic returns the
|
|
164
|
+
* existing forwarder).
|
|
165
|
+
*/
|
|
166
|
+
export interface ColdStartManager {
|
|
167
|
+
/**
|
|
168
|
+
* Instantiate (or return the existing) forwarder for `topicId` at tree tier `tier` (`d`). For a
|
|
169
|
+
* deeper-than-root forwarder this kicks off parent registration in the background and flips the
|
|
170
|
+
* forwarder to `serving` on ack. The returned forwarder accepts participants immediately. `opTier`
|
|
171
|
+
* (the topic's T0–T3 capacity tier) is forwarded to the parent registrar for the link frame.
|
|
172
|
+
*/
|
|
173
|
+
instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder;
|
|
174
|
+
/** The tracked forwarder for `topicId`, or `undefined`. */
|
|
175
|
+
get(topicId: Uint8Array): Forwarder | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* Drop the forwarder for `topicId` (budget eviction / teardown). Idempotent; a no-op if the topic is
|
|
178
|
+
* not tracked. After this, {@link get} returns `undefined` for `topicId`, so the cohort no longer
|
|
179
|
+
* reports serving it via cold-start state.
|
|
180
|
+
*/
|
|
181
|
+
remove(topicId: Uint8Array): void;
|
|
182
|
+
/**
|
|
183
|
+
* Whether this manager currently tracks any forwarder. The host's idle-engine eviction reads this to
|
|
184
|
+
* avoid reclaiming a cohort that holds a live (possibly `awaiting_parent`) cold-start forwarder.
|
|
185
|
+
*/
|
|
186
|
+
hasForwarders(): boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class TrackingColdStartManager implements ColdStartManager {
|
|
190
|
+
private readonly forwarders = new Map<string, Forwarder>();
|
|
191
|
+
|
|
192
|
+
constructor(private readonly deps: ColdStartManagerDeps) {}
|
|
193
|
+
|
|
194
|
+
instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder {
|
|
195
|
+
const key = bytesKey(topicId);
|
|
196
|
+
const existing = this.forwarders.get(key);
|
|
197
|
+
if (existing !== undefined) {
|
|
198
|
+
return existing;
|
|
199
|
+
}
|
|
200
|
+
const forwarder = createForwarder(tier);
|
|
201
|
+
this.forwarders.set(key, forwarder);
|
|
202
|
+
if (tier > 0) {
|
|
203
|
+
if (parentCoord === undefined) {
|
|
204
|
+
throw new Error(`cold-start of a tier-${tier} forwarder requires a parentCoord`);
|
|
205
|
+
}
|
|
206
|
+
// Register with the parent at first opportunity; flip to serving on ack. A failed parent
|
|
207
|
+
// registration leaves the forwarder accepting participants but holding parent-involving ops,
|
|
208
|
+
// so a later retry (driven by the host) can complete the link-up — surfaced, not swallowed.
|
|
209
|
+
void this.deps.parentRegistrar
|
|
210
|
+
.registerWithParent(topicId, parentCoord, tier, opTier)
|
|
211
|
+
.then(() => forwarder.onParentAck())
|
|
212
|
+
.catch((err: unknown) => {
|
|
213
|
+
log('WARN: parent registration for tier-%s forwarder failed %o', tier, err);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return forwarder;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
get(topicId: Uint8Array): Forwarder | undefined {
|
|
220
|
+
return this.forwarders.get(bytesKey(topicId));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
remove(topicId: Uint8Array): void {
|
|
224
|
+
// Idempotent: `Map.delete` on an absent key is a safe no-op, so a double-remove (or a remove of a
|
|
225
|
+
// never-instantiated topic) does nothing.
|
|
226
|
+
this.forwarders.delete(bytesKey(topicId));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
hasForwarders(): boolean {
|
|
230
|
+
return this.forwarders.size > 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Build a {@link ColdStartManager} over the injected parent registrar. */
|
|
235
|
+
export function createColdStartManager(deps: ColdStartManagerDeps): ColdStartManager {
|
|
236
|
+
return new TrackingColdStartManager(deps);
|
|
237
|
+
}
|
package/src/cohort-topic/dmax.ts
CHANGED
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cohort-topic substrate — network-size-driven `d_max`.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Maximum useful depth:
|
|
5
|
-
*
|
|
6
|
-
* ```
|
|
7
|
-
* d_max = max(0, ⌊log_F(n_est)⌋ − 1)
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* If `n_est` confidence falls below `confidence_min` (default 0.3) the computation caps `d_max` at
|
|
11
|
-
* `⌊d_max_cap / 2⌋` (`d_max_cap` default 60) as an upper bound — i.e. `min(formula, ⌊d_max_cap / 2⌋)` —
|
|
12
|
-
* to avoid pathological deep probes from an over-estimated population. Small/sparse populations
|
|
13
|
-
* (where the formula already yields a small value) are unaffected.
|
|
14
|
-
*
|
|
15
|
-
* `d_max` is the start tier of the walk-toward-root and is recomputed **lazily** — participants
|
|
16
|
-
* don't need it precise. The size estimate is read through the injected {@link ISizeEstimator}
|
|
17
|
-
* (db-p2p wraps FRET's `estimateSizeAndConfidence`; db-core never imports FRET).
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { ISizeEstimator } from "./ports.js";
|
|
21
|
-
|
|
22
|
-
/** Lazily computes the walk start tier `d_max` from the current network-size estimate. */
|
|
23
|
-
export interface DMaxComputer {
|
|
24
|
-
/** Reads the size estimate, applies the low-confidence cap, returns `d_max`. */
|
|
25
|
-
dMax(): number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Default below which `n_est` confidence triggers the `d_max` cap. */
|
|
29
|
-
export const DEFAULT_CONFIDENCE_MIN = 0.3;
|
|
30
|
-
/** Default hard cap on the walk-toward-root start tier. */
|
|
31
|
-
export const DEFAULT_D_MAX_CAP = 60;
|
|
32
|
-
|
|
33
|
-
export interface DMaxConfig {
|
|
34
|
-
/** Network-size estimate source (db-p2p wraps FRET `estimateSizeAndConfidence`). */
|
|
35
|
-
estimator: ISizeEstimator;
|
|
36
|
-
/** Fan-out per tier (the `F` in `log_F`). */
|
|
37
|
-
F: number;
|
|
38
|
-
/** Confidence floor; below it `d_max` is capped at `⌊d_max_cap / 2⌋` (upper bound). Default 0.3. */
|
|
39
|
-
confidenceMin?: number;
|
|
40
|
-
/** Hard cap on `d_max`; also drives the low-confidence cap value. Default 60. */
|
|
41
|
-
dMaxCap?: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `⌊log_F(n)⌋` computed without floating-point drift near exact powers of `F`. Multiplies back up to
|
|
46
|
-
* confirm the floor (`Math.log` can return e.g. `2.9999999` for `log_16(4096)`), so the result is
|
|
47
|
-
* exact for all `n` representable as a JS integer.
|
|
48
|
-
*/
|
|
49
|
-
function floorLogF(n: number, F: number): number {
|
|
50
|
-
if (n < 1) return 0;
|
|
51
|
-
let d = Math.floor(Math.log(n) / Math.log(F));
|
|
52
|
-
if (d < 0) d = 0;
|
|
53
|
-
// Correct floating-point error at the boundary in both directions.
|
|
54
|
-
while (Math.pow(F, d + 1) <= n) d++;
|
|
55
|
-
while (d > 0 && Math.pow(F, d) > n) d--;
|
|
56
|
-
return d;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Build a {@link DMaxComputer}. The estimate is read on every {@link DMaxComputer.dMax} call so the
|
|
61
|
-
* value tracks the latest FRET sample; callers cache it themselves if they want stability.
|
|
62
|
-
*/
|
|
63
|
-
export function makeDMaxComputer(config: DMaxConfig): DMaxComputer {
|
|
64
|
-
const { estimator, F } = config;
|
|
65
|
-
if (!Number.isInteger(F) || F < 2) {
|
|
66
|
-
throw new RangeError(`fan-out F must be an integer ≥ 2, got ${F}`);
|
|
67
|
-
}
|
|
68
|
-
const confidenceMin = config.confidenceMin ?? DEFAULT_CONFIDENCE_MIN;
|
|
69
|
-
const dMaxCap = config.dMaxCap ?? DEFAULT_D_MAX_CAP;
|
|
70
|
-
if (!Number.isInteger(dMaxCap) || dMaxCap < 0) {
|
|
71
|
-
throw new RangeError(`d_max_cap must be a non-negative integer, got ${dMaxCap}`);
|
|
72
|
-
}
|
|
73
|
-
const capValue = Math.floor(dMaxCap / 2);
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
dMax(): number {
|
|
77
|
-
const { nEst, confidence } = estimator.estimate();
|
|
78
|
-
const formula = Math.min(Math.max(0, floorLogF(nEst, F) - 1), dMaxCap);
|
|
79
|
-
// Low confidence caps d_max as an upper bound (not a set-to): an over-estimated
|
|
80
|
-
// population can't push the walk deeper than ⌊d_max_cap / 2⌋, while small
|
|
81
|
-
// populations keep their (smaller) formula value.
|
|
82
|
-
if (confidence < confidenceMin) {
|
|
83
|
-
return Math.min(formula, capValue);
|
|
84
|
-
}
|
|
85
|
-
return formula;
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-topic substrate — network-size-driven `d_max`.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Maximum useful depth:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* d_max = max(0, ⌊log_F(n_est)⌋ − 1)
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* If `n_est` confidence falls below `confidence_min` (default 0.3) the computation caps `d_max` at
|
|
11
|
+
* `⌊d_max_cap / 2⌋` (`d_max_cap` default 60) as an upper bound — i.e. `min(formula, ⌊d_max_cap / 2⌋)` —
|
|
12
|
+
* to avoid pathological deep probes from an over-estimated population. Small/sparse populations
|
|
13
|
+
* (where the formula already yields a small value) are unaffected.
|
|
14
|
+
*
|
|
15
|
+
* `d_max` is the start tier of the walk-toward-root and is recomputed **lazily** — participants
|
|
16
|
+
* don't need it precise. The size estimate is read through the injected {@link ISizeEstimator}
|
|
17
|
+
* (db-p2p wraps FRET's `estimateSizeAndConfidence`; db-core never imports FRET).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ISizeEstimator } from "./ports.js";
|
|
21
|
+
|
|
22
|
+
/** Lazily computes the walk start tier `d_max` from the current network-size estimate. */
|
|
23
|
+
export interface DMaxComputer {
|
|
24
|
+
/** Reads the size estimate, applies the low-confidence cap, returns `d_max`. */
|
|
25
|
+
dMax(): number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Default below which `n_est` confidence triggers the `d_max` cap. */
|
|
29
|
+
export const DEFAULT_CONFIDENCE_MIN = 0.3;
|
|
30
|
+
/** Default hard cap on the walk-toward-root start tier. */
|
|
31
|
+
export const DEFAULT_D_MAX_CAP = 60;
|
|
32
|
+
|
|
33
|
+
export interface DMaxConfig {
|
|
34
|
+
/** Network-size estimate source (db-p2p wraps FRET `estimateSizeAndConfidence`). */
|
|
35
|
+
estimator: ISizeEstimator;
|
|
36
|
+
/** Fan-out per tier (the `F` in `log_F`). */
|
|
37
|
+
F: number;
|
|
38
|
+
/** Confidence floor; below it `d_max` is capped at `⌊d_max_cap / 2⌋` (upper bound). Default 0.3. */
|
|
39
|
+
confidenceMin?: number;
|
|
40
|
+
/** Hard cap on `d_max`; also drives the low-confidence cap value. Default 60. */
|
|
41
|
+
dMaxCap?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `⌊log_F(n)⌋` computed without floating-point drift near exact powers of `F`. Multiplies back up to
|
|
46
|
+
* confirm the floor (`Math.log` can return e.g. `2.9999999` for `log_16(4096)`), so the result is
|
|
47
|
+
* exact for all `n` representable as a JS integer.
|
|
48
|
+
*/
|
|
49
|
+
function floorLogF(n: number, F: number): number {
|
|
50
|
+
if (n < 1) return 0;
|
|
51
|
+
let d = Math.floor(Math.log(n) / Math.log(F));
|
|
52
|
+
if (d < 0) d = 0;
|
|
53
|
+
// Correct floating-point error at the boundary in both directions.
|
|
54
|
+
while (Math.pow(F, d + 1) <= n) d++;
|
|
55
|
+
while (d > 0 && Math.pow(F, d) > n) d--;
|
|
56
|
+
return d;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a {@link DMaxComputer}. The estimate is read on every {@link DMaxComputer.dMax} call so the
|
|
61
|
+
* value tracks the latest FRET sample; callers cache it themselves if they want stability.
|
|
62
|
+
*/
|
|
63
|
+
export function makeDMaxComputer(config: DMaxConfig): DMaxComputer {
|
|
64
|
+
const { estimator, F } = config;
|
|
65
|
+
if (!Number.isInteger(F) || F < 2) {
|
|
66
|
+
throw new RangeError(`fan-out F must be an integer ≥ 2, got ${F}`);
|
|
67
|
+
}
|
|
68
|
+
const confidenceMin = config.confidenceMin ?? DEFAULT_CONFIDENCE_MIN;
|
|
69
|
+
const dMaxCap = config.dMaxCap ?? DEFAULT_D_MAX_CAP;
|
|
70
|
+
if (!Number.isInteger(dMaxCap) || dMaxCap < 0) {
|
|
71
|
+
throw new RangeError(`d_max_cap must be a non-negative integer, got ${dMaxCap}`);
|
|
72
|
+
}
|
|
73
|
+
const capValue = Math.floor(dMaxCap / 2);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
dMax(): number {
|
|
77
|
+
const { nEst, confidence } = estimator.estimate();
|
|
78
|
+
const formula = Math.min(Math.max(0, floorLogF(nEst, F) - 1), dMaxCap);
|
|
79
|
+
// Low confidence caps d_max as an upper bound (not a set-to): an over-estimated
|
|
80
|
+
// population can't push the walk deeper than ⌊d_max_cap / 2⌋, while small
|
|
81
|
+
// populations keep their (smaller) formula value.
|
|
82
|
+
if (confidence < confidenceMin) {
|
|
83
|
+
return Math.min(formula, capValue);
|
|
84
|
+
}
|
|
85
|
+
return formula;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|