@optimystic/db-core 0.21.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -336
- package/dist/src/btree/btree.d.ts +2 -1
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +1 -1
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/chain/chain.d.ts +1 -1
- package/dist/src/chain/chain.d.ts.map +1 -1
- package/dist/src/chain/chain.js +1 -1
- package/dist/src/chain/chain.js.map +1 -1
- 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 +20 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +31 -4
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +2 -1
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/diary/struct.js +1 -1
- package/dist/src/collections/diary/struct.js.map +1 -1
- package/dist/src/collections/tree/collection-trunk.js +1 -1
- package/dist/src/collections/tree/collection-trunk.js.map +1 -1
- package/dist/src/collections/tree/struct.d.ts +1 -1
- package/dist/src/collections/tree/struct.d.ts.map +1 -1
- package/dist/src/collections/tree/struct.js +2 -1
- package/dist/src/collections/tree/struct.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/log/log.d.ts +1 -1
- package/dist/src/log/log.d.ts.map +1 -1
- package/dist/src/log/log.js +2 -2
- package/dist/src/log/log.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 +133 -12
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +2 -1
- package/dist/src/transaction/coordinator.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 +57 -18
- 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/dist/src/transform/cache-source.js +1 -1
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/helpers.d.ts +6 -1
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +7 -6
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +2 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/btree/btree.ts +2 -1
- package/src/chain/chain.ts +2 -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 +32 -4
- package/src/collections/diary/diary.ts +68 -67
- package/src/collections/diary/struct.ts +1 -1
- package/src/collections/tree/collection-trunk.ts +1 -1
- package/src/collections/tree/readme.md +4 -0
- package/src/collections/tree/struct.ts +3 -1
- package/src/collections/tree/tree.ts +320 -312
- package/src/log/log.ts +2 -2
- 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 -489
- package/src/transaction/coordinator.ts +2 -1
- 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 +58 -19
- package/src/transactor/transactor-source.ts +25 -2
- package/src/transform/atomic-proxy.ts +92 -92
- package/src/transform/cache-source.ts +1 -1
- package/src/transform/helpers.ts +159 -158
- package/src/transform/tracker.ts +2 -1
- 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,127 +1,127 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matchmaking — seeker hang-out-vs-continue decision engine (db-core, pure).
|
|
3
|
-
*
|
|
4
|
-
* Per `docs/matchmaking.md` §Hang-out vs. continue. After a seeker registers at a cohort and receives
|
|
5
|
-
* `Accepted` with `topicTraffic` at tree tier `d`, it must decide: is *this* tier the right place to
|
|
6
|
-
* wait, or should it walk one tier toward the root? This module is the pure arithmetic of that choice
|
|
7
|
-
* — no I/O, no clock — so it is unit-testable directly against the doc's worked examples. The db-p2p
|
|
8
|
-
* `seeker-walk-client` drives the walk (register / query / renew / withdraw) and calls {@link decide}.
|
|
9
|
-
*
|
|
10
|
-
* Decision rule (`docs/matchmaking.md` §Decision rule):
|
|
11
|
-
*
|
|
12
|
-
* 1. **Immediate-match.** If `currentMatches >= wantCount` → `done`. (The caller has already issued the
|
|
13
|
-
* `QueryV1`; `currentMatches` is its filter-matched, re-validated yield. This also covers edge case 2
|
|
14
|
-
* — a stale `arrivalsPerMin = 0` after an epoch rotation still gets a real query first, so a quiet
|
|
15
|
-
* cohort that actually holds enough providers resolves to `done` rather than a spurious escalate.)
|
|
16
|
-
* 2. **Hang-out feasibility.**
|
|
17
|
-
* ```
|
|
18
|
-
* expectedNewMatches ≈ arrivalsPerMin × filterAcceptRatio × (patienceMsRemaining / 60000)
|
|
19
|
-
* contentionFactor ≈ min(1 + (queriesPerMin × meanWantCount) / max(arrivalsPerMin, 1), cap)
|
|
20
|
-
* hang out iff currentMatches + expectedNewMatches ≥ wantCount × contentionFactor
|
|
21
|
-
* ```
|
|
22
|
-
* 3. **Otherwise escalate** (walk one tier toward the root).
|
|
23
|
-
*
|
|
24
|
-
* Edge cases the engine encodes (the rest live in the walk client, which owns the walk topology):
|
|
25
|
-
* - **Filter matches almost nothing (case 4):** a `filterAcceptRatio` decayed toward 0 collapses
|
|
26
|
-
* `expectedNewMatches`, so the feasibility test fails at every tier and the seeker walks to the root.
|
|
27
|
-
* - **Many seekers competing (case 5):** a high `queriesPerMin` inflates `contentionFactor` up to
|
|
28
|
-
* `cap`, raising the threshold so more seekers escalate toward aggregation — self-balancing, bounded.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import type { HangOutConfig } from "./config.js";
|
|
32
|
-
import { FILTER_ACCEPT_RATIO_INITIAL, MEAN_WANT_COUNT_DEFAULT } from "./config.js";
|
|
33
|
-
|
|
34
|
-
/** Inputs to {@link decide}: the cohort's `topicTraffic` plus the seeker's running state. */
|
|
35
|
-
export interface SeekerDecisionInputs {
|
|
36
|
-
/** Filter-matched, re-validated providers from the immediate `QueryV1` at this tier. */
|
|
37
|
-
readonly currentMatches: number;
|
|
38
|
-
/** `topicTraffic.directParticipants` — informational (providers known here right now). */
|
|
39
|
-
readonly directParticipants: number;
|
|
40
|
-
/** `topicTraffic.arrivalsPerMin` — provider registration + renewal rate. */
|
|
41
|
-
readonly arrivalsPerMin: number;
|
|
42
|
-
/** `topicTraffic.queriesPerMin` — competing seeker activity over the same pool. */
|
|
43
|
-
readonly queriesPerMin: number;
|
|
44
|
-
/** `topicTraffic.childCohortCount` — `> 0` means this tier has promoted (descend territory). */
|
|
45
|
-
readonly childCohortCount: number;
|
|
46
|
-
/** Providers the seeker needs. */
|
|
47
|
-
readonly wantCount: number;
|
|
48
|
-
/** Patience budget left at this tier (drains across walked tiers — the client tracks it). */
|
|
49
|
-
readonly patienceMsRemaining: number;
|
|
50
|
-
/** Running estimate of the fraction of returned providers that pass the seeker's filter (decays from 1.0). */
|
|
51
|
-
readonly filterAcceptRatio: number;
|
|
52
|
-
/** Assumed mean `wantCount` of competing seekers (small constant or learned). */
|
|
53
|
-
readonly meanWantCount: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The outcome of {@link decide}. `hangOut` carries the poll cadence for the requery loop. */
|
|
57
|
-
export type SeekerDecision =
|
|
58
|
-
| { readonly action: "done" }
|
|
59
|
-
| { readonly action: "hangOut"; readonly requeryIntervalMs: number }
|
|
60
|
-
| { readonly action: "escalate" };
|
|
61
|
-
|
|
62
|
-
/** `expectedNewMatches ≈ arrivalsPerMin × filterAcceptRatio × (patienceMsRemaining / 60000)`. */
|
|
63
|
-
export function expectedNewMatches(inputs: SeekerDecisionInputs): number {
|
|
64
|
-
return inputs.arrivalsPerMin * inputs.filterAcceptRatio * (inputs.patienceMsRemaining / 60_000);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** `contentionFactor ≈ min(1 + (queriesPerMin × meanWantCount) / max(arrivalsPerMin, 1), cap)`. */
|
|
68
|
-
export function contentionFactor(inputs: SeekerDecisionInputs, cfg: HangOutConfig): number {
|
|
69
|
-
const raw = 1 + (inputs.queriesPerMin * inputs.meanWantCount) / Math.max(inputs.arrivalsPerMin, 1);
|
|
70
|
-
return Math.min(raw, cfg.contentionFactorCap);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The hang-out feasibility threshold `wantCount × contentionFactor`. */
|
|
74
|
-
export function hangOutThreshold(inputs: SeekerDecisionInputs, cfg: HangOutConfig): number {
|
|
75
|
-
return inputs.wantCount * contentionFactor(inputs, cfg);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Decide whether the seeker is `done` (immediate match), should `hangOut` (feasible to wait here), or
|
|
80
|
-
* should `escalate` (walk one tier toward the root). Pure — see the module header for the rule.
|
|
81
|
-
*/
|
|
82
|
-
export function decide(inputs: SeekerDecisionInputs, cfg: HangOutConfig): SeekerDecision {
|
|
83
|
-
if (inputs.currentMatches >= inputs.wantCount) {
|
|
84
|
-
return { action: "done" };
|
|
85
|
-
}
|
|
86
|
-
const projected = inputs.currentMatches + expectedNewMatches(inputs);
|
|
87
|
-
if (projected >= hangOutThreshold(inputs, cfg)) {
|
|
88
|
-
return { action: "hangOut", requeryIntervalMs: cfg.requeryIntervalMs };
|
|
89
|
-
}
|
|
90
|
-
return { action: "escalate" };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// --- filterAcceptRatio running refinement (`docs/matchmaking.md` §Decision rule / §Edge cases 4) ---
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Running yield accumulator for `filterAcceptRatio`. The ratio starts at `filter_accept_ratio_initial`
|
|
97
|
-
* (1.0, used before any observation) and is refined to the cumulative `matched / returned` over the
|
|
98
|
-
* walk: after two cohorts that each return ~10% matchable providers it settles near 0.1, collapsing
|
|
99
|
-
* `expectedNewMatches` for a pathological filter (edge case 4).
|
|
100
|
-
*/
|
|
101
|
-
export interface FilterAcceptRatioState {
|
|
102
|
-
/** Total filter-matched providers observed across queries so far. */
|
|
103
|
-
readonly matched: number;
|
|
104
|
-
/** Total providers returned across queries so far. */
|
|
105
|
-
readonly returned: number;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** A fresh (zero-observation) {@link FilterAcceptRatioState}. */
|
|
109
|
-
export function newFilterAcceptRatioState(): FilterAcceptRatioState {
|
|
110
|
-
return { matched: 0, returned: 0 };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Fold one query's yield (`matched` of `returned`) into the running state. */
|
|
114
|
-
export function observeYield(state: FilterAcceptRatioState, matched: number, returned: number): FilterAcceptRatioState {
|
|
115
|
-
return { matched: state.matched + matched, returned: state.returned + returned };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The current `filterAcceptRatio`: cumulative `matched / returned`, or `initial` (1.0) before any
|
|
120
|
-
* providers have been returned (so the first hang-out estimate is optimistic, then refines).
|
|
121
|
-
*/
|
|
122
|
-
export function filterAcceptRatio(state: FilterAcceptRatioState, initial: number = FILTER_ACCEPT_RATIO_INITIAL): number {
|
|
123
|
-
return state.returned > 0 ? state.matched / state.returned : initial;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** The default assumed competing-seeker `meanWantCount` (re-exported for the walk client). */
|
|
127
|
-
export const DEFAULT_MEAN_WANT_COUNT = MEAN_WANT_COUNT_DEFAULT;
|
|
1
|
+
/**
|
|
2
|
+
* Matchmaking — seeker hang-out-vs-continue decision engine (db-core, pure).
|
|
3
|
+
*
|
|
4
|
+
* Per `docs/matchmaking.md` §Hang-out vs. continue. After a seeker registers at a cohort and receives
|
|
5
|
+
* `Accepted` with `topicTraffic` at tree tier `d`, it must decide: is *this* tier the right place to
|
|
6
|
+
* wait, or should it walk one tier toward the root? This module is the pure arithmetic of that choice
|
|
7
|
+
* — no I/O, no clock — so it is unit-testable directly against the doc's worked examples. The db-p2p
|
|
8
|
+
* `seeker-walk-client` drives the walk (register / query / renew / withdraw) and calls {@link decide}.
|
|
9
|
+
*
|
|
10
|
+
* Decision rule (`docs/matchmaking.md` §Decision rule):
|
|
11
|
+
*
|
|
12
|
+
* 1. **Immediate-match.** If `currentMatches >= wantCount` → `done`. (The caller has already issued the
|
|
13
|
+
* `QueryV1`; `currentMatches` is its filter-matched, re-validated yield. This also covers edge case 2
|
|
14
|
+
* — a stale `arrivalsPerMin = 0` after an epoch rotation still gets a real query first, so a quiet
|
|
15
|
+
* cohort that actually holds enough providers resolves to `done` rather than a spurious escalate.)
|
|
16
|
+
* 2. **Hang-out feasibility.**
|
|
17
|
+
* ```
|
|
18
|
+
* expectedNewMatches ≈ arrivalsPerMin × filterAcceptRatio × (patienceMsRemaining / 60000)
|
|
19
|
+
* contentionFactor ≈ min(1 + (queriesPerMin × meanWantCount) / max(arrivalsPerMin, 1), cap)
|
|
20
|
+
* hang out iff currentMatches + expectedNewMatches ≥ wantCount × contentionFactor
|
|
21
|
+
* ```
|
|
22
|
+
* 3. **Otherwise escalate** (walk one tier toward the root).
|
|
23
|
+
*
|
|
24
|
+
* Edge cases the engine encodes (the rest live in the walk client, which owns the walk topology):
|
|
25
|
+
* - **Filter matches almost nothing (case 4):** a `filterAcceptRatio` decayed toward 0 collapses
|
|
26
|
+
* `expectedNewMatches`, so the feasibility test fails at every tier and the seeker walks to the root.
|
|
27
|
+
* - **Many seekers competing (case 5):** a high `queriesPerMin` inflates `contentionFactor` up to
|
|
28
|
+
* `cap`, raising the threshold so more seekers escalate toward aggregation — self-balancing, bounded.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { HangOutConfig } from "./config.js";
|
|
32
|
+
import { FILTER_ACCEPT_RATIO_INITIAL, MEAN_WANT_COUNT_DEFAULT } from "./config.js";
|
|
33
|
+
|
|
34
|
+
/** Inputs to {@link decide}: the cohort's `topicTraffic` plus the seeker's running state. */
|
|
35
|
+
export interface SeekerDecisionInputs {
|
|
36
|
+
/** Filter-matched, re-validated providers from the immediate `QueryV1` at this tier. */
|
|
37
|
+
readonly currentMatches: number;
|
|
38
|
+
/** `topicTraffic.directParticipants` — informational (providers known here right now). */
|
|
39
|
+
readonly directParticipants: number;
|
|
40
|
+
/** `topicTraffic.arrivalsPerMin` — provider registration + renewal rate. */
|
|
41
|
+
readonly arrivalsPerMin: number;
|
|
42
|
+
/** `topicTraffic.queriesPerMin` — competing seeker activity over the same pool. */
|
|
43
|
+
readonly queriesPerMin: number;
|
|
44
|
+
/** `topicTraffic.childCohortCount` — `> 0` means this tier has promoted (descend territory). */
|
|
45
|
+
readonly childCohortCount: number;
|
|
46
|
+
/** Providers the seeker needs. */
|
|
47
|
+
readonly wantCount: number;
|
|
48
|
+
/** Patience budget left at this tier (drains across walked tiers — the client tracks it). */
|
|
49
|
+
readonly patienceMsRemaining: number;
|
|
50
|
+
/** Running estimate of the fraction of returned providers that pass the seeker's filter (decays from 1.0). */
|
|
51
|
+
readonly filterAcceptRatio: number;
|
|
52
|
+
/** Assumed mean `wantCount` of competing seekers (small constant or learned). */
|
|
53
|
+
readonly meanWantCount: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The outcome of {@link decide}. `hangOut` carries the poll cadence for the requery loop. */
|
|
57
|
+
export type SeekerDecision =
|
|
58
|
+
| { readonly action: "done" }
|
|
59
|
+
| { readonly action: "hangOut"; readonly requeryIntervalMs: number }
|
|
60
|
+
| { readonly action: "escalate" };
|
|
61
|
+
|
|
62
|
+
/** `expectedNewMatches ≈ arrivalsPerMin × filterAcceptRatio × (patienceMsRemaining / 60000)`. */
|
|
63
|
+
export function expectedNewMatches(inputs: SeekerDecisionInputs): number {
|
|
64
|
+
return inputs.arrivalsPerMin * inputs.filterAcceptRatio * (inputs.patienceMsRemaining / 60_000);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `contentionFactor ≈ min(1 + (queriesPerMin × meanWantCount) / max(arrivalsPerMin, 1), cap)`. */
|
|
68
|
+
export function contentionFactor(inputs: SeekerDecisionInputs, cfg: HangOutConfig): number {
|
|
69
|
+
const raw = 1 + (inputs.queriesPerMin * inputs.meanWantCount) / Math.max(inputs.arrivalsPerMin, 1);
|
|
70
|
+
return Math.min(raw, cfg.contentionFactorCap);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The hang-out feasibility threshold `wantCount × contentionFactor`. */
|
|
74
|
+
export function hangOutThreshold(inputs: SeekerDecisionInputs, cfg: HangOutConfig): number {
|
|
75
|
+
return inputs.wantCount * contentionFactor(inputs, cfg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decide whether the seeker is `done` (immediate match), should `hangOut` (feasible to wait here), or
|
|
80
|
+
* should `escalate` (walk one tier toward the root). Pure — see the module header for the rule.
|
|
81
|
+
*/
|
|
82
|
+
export function decide(inputs: SeekerDecisionInputs, cfg: HangOutConfig): SeekerDecision {
|
|
83
|
+
if (inputs.currentMatches >= inputs.wantCount) {
|
|
84
|
+
return { action: "done" };
|
|
85
|
+
}
|
|
86
|
+
const projected = inputs.currentMatches + expectedNewMatches(inputs);
|
|
87
|
+
if (projected >= hangOutThreshold(inputs, cfg)) {
|
|
88
|
+
return { action: "hangOut", requeryIntervalMs: cfg.requeryIntervalMs };
|
|
89
|
+
}
|
|
90
|
+
return { action: "escalate" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- filterAcceptRatio running refinement (`docs/matchmaking.md` §Decision rule / §Edge cases 4) ---
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Running yield accumulator for `filterAcceptRatio`. The ratio starts at `filter_accept_ratio_initial`
|
|
97
|
+
* (1.0, used before any observation) and is refined to the cumulative `matched / returned` over the
|
|
98
|
+
* walk: after two cohorts that each return ~10% matchable providers it settles near 0.1, collapsing
|
|
99
|
+
* `expectedNewMatches` for a pathological filter (edge case 4).
|
|
100
|
+
*/
|
|
101
|
+
export interface FilterAcceptRatioState {
|
|
102
|
+
/** Total filter-matched providers observed across queries so far. */
|
|
103
|
+
readonly matched: number;
|
|
104
|
+
/** Total providers returned across queries so far. */
|
|
105
|
+
readonly returned: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A fresh (zero-observation) {@link FilterAcceptRatioState}. */
|
|
109
|
+
export function newFilterAcceptRatioState(): FilterAcceptRatioState {
|
|
110
|
+
return { matched: 0, returned: 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Fold one query's yield (`matched` of `returned`) into the running state. */
|
|
114
|
+
export function observeYield(state: FilterAcceptRatioState, matched: number, returned: number): FilterAcceptRatioState {
|
|
115
|
+
return { matched: state.matched + matched, returned: state.returned + returned };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The current `filterAcceptRatio`: cumulative `matched / returned`, or `initial` (1.0) before any
|
|
120
|
+
* providers have been returned (so the first hang-out estimate is optimistic, then refines).
|
|
121
|
+
*/
|
|
122
|
+
export function filterAcceptRatio(state: FilterAcceptRatioState, initial: number = FILTER_ACCEPT_RATIO_INITIAL): number {
|
|
123
|
+
return state.returned > 0 ? state.matched / state.returned : initial;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The default assumed competing-seeker `meanWantCount` (re-exported for the walk client). */
|
|
127
|
+
export const DEFAULT_MEAN_WANT_COUNT = MEAN_WANT_COUNT_DEFAULT;
|
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matchmaking — seeker registration state (db-core, transport-agnostic).
|
|
3
|
-
*
|
|
4
|
-
* A {@link MatchmakingSeeker} owns the short-lived seeker state for one topic and builds the signed
|
|
5
|
-
* {@link SeekerAppPayloadV1} that the db-p2p `seeker-manager` registers at cohort-topic tier **T2**
|
|
6
|
-
* with a short TTL (`seeker_ttl`, default 10 s — `docs/matchmaking.md` §Seeker query). The seeker
|
|
7
|
-
* registers briefly so other seekers can find it (collective assembly) and the cohort sees active
|
|
8
|
-
* demand.
|
|
9
|
-
*
|
|
10
|
-
* This module holds *registration* state only. The `QueryV1` issuance and the hang-out-vs-continue
|
|
11
|
-
* decision live in the pure {@link import("./seeker-walk.js").decide} engine and the db-p2p
|
|
12
|
-
* `seeker-walk-client` that drives the walk; the capability filter is {@link import("./capability-filter.js").matchesFilter}.
|
|
13
|
-
*
|
|
14
|
-
* Crypto-free: signing is an injected callback, matching {@link MatchmakingProvider}.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { randomBytes } from "@noble/hashes/utils.js";
|
|
18
|
-
import { seekerSigningPayload, type CapabilityFilter, type SeekerAppPayloadV1, encodeSeekerAppPayload } from "./wire.js";
|
|
19
|
-
|
|
20
|
-
/** Construction inputs for a {@link MatchmakingSeeker}. */
|
|
21
|
-
export interface MatchmakingSeekerOptions {
|
|
22
|
-
/** The matchmaking topic this seeker is querying. */
|
|
23
|
-
readonly topicId: Uint8Array;
|
|
24
|
-
/** Number of providers desired (integer `>= 1`). */
|
|
25
|
-
readonly wantCount: number;
|
|
26
|
-
/** Multiaddr or PeerId-based callback (collective-assembly use). */
|
|
27
|
-
readonly contactHint: string;
|
|
28
|
-
/** Optional capability filter (evaluated cohort-side in the next ticket; carried here). */
|
|
29
|
-
readonly filter?: CapabilityFilter;
|
|
30
|
-
/** Opt into arrival pushes; default false (poll path). Consumed by the next ticket. */
|
|
31
|
-
readonly pushOnArrival?: boolean;
|
|
32
|
-
/** Sign the canonical registration image; resolves the base64url signature. */
|
|
33
|
-
readonly sign: (payload: Uint8Array) => Promise<string>;
|
|
34
|
-
/** 16-byte registration correlation id (not signature-bound); default fresh CSPRNG bytes. */
|
|
35
|
-
readonly correlationId?: Uint8Array;
|
|
36
|
-
/** CSPRNG source (injectable for deterministic tests). Default `@noble/hashes` `randomBytes`. */
|
|
37
|
-
readonly randomBytes?: (n: number) => Uint8Array;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Live seeker state + signed-payload builder for one matchmaking topic (registration only). */
|
|
41
|
-
export class MatchmakingSeeker {
|
|
42
|
-
readonly topicId: Uint8Array;
|
|
43
|
-
readonly correlationId: Uint8Array;
|
|
44
|
-
private readonly wantCount: number;
|
|
45
|
-
private readonly contactHint: string;
|
|
46
|
-
private readonly filter?: CapabilityFilter;
|
|
47
|
-
private readonly pushOnArrival?: boolean;
|
|
48
|
-
private readonly sign: (payload: Uint8Array) => Promise<string>;
|
|
49
|
-
|
|
50
|
-
constructor(options: MatchmakingSeekerOptions) {
|
|
51
|
-
if (!Number.isInteger(options.wantCount) || options.wantCount < 1) {
|
|
52
|
-
throw new RangeError(`matchmaking seeker: wantCount must be an integer >= 1, got ${options.wantCount}`);
|
|
53
|
-
}
|
|
54
|
-
this.topicId = options.topicId;
|
|
55
|
-
this.wantCount = options.wantCount;
|
|
56
|
-
this.contactHint = options.contactHint;
|
|
57
|
-
this.filter = options.filter;
|
|
58
|
-
this.pushOnArrival = options.pushOnArrival;
|
|
59
|
-
this.sign = options.sign;
|
|
60
|
-
const rand = options.randomBytes ?? randomBytes;
|
|
61
|
-
this.correlationId = options.correlationId ?? rand(16);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Build the signed {@link SeekerAppPayloadV1} for this seeker's registration. */
|
|
65
|
-
async buildAppPayload(): Promise<SeekerAppPayloadV1> {
|
|
66
|
-
const signature = await this.sign(seekerSigningPayload(this.topicId, this.wantCount));
|
|
67
|
-
const payload: SeekerAppPayloadV1 = {
|
|
68
|
-
kind: "match-seeker",
|
|
69
|
-
wantCount: this.wantCount,
|
|
70
|
-
contactHint: this.contactHint,
|
|
71
|
-
signature,
|
|
72
|
-
};
|
|
73
|
-
if (this.filter !== undefined) {
|
|
74
|
-
payload.filter = this.filter;
|
|
75
|
-
}
|
|
76
|
-
if (this.pushOnArrival !== undefined) {
|
|
77
|
-
payload.pushOnArrival = this.pushOnArrival;
|
|
78
|
-
}
|
|
79
|
-
return payload;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Build the opaque bytes for the cohort-topic `RegisterV1.appPayload` slot. */
|
|
83
|
-
async appPayloadBytes(): Promise<Uint8Array> {
|
|
84
|
-
return encodeSeekerAppPayload(await this.buildAppPayload());
|
|
85
|
-
}
|
|
86
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Matchmaking — seeker registration state (db-core, transport-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* A {@link MatchmakingSeeker} owns the short-lived seeker state for one topic and builds the signed
|
|
5
|
+
* {@link SeekerAppPayloadV1} that the db-p2p `seeker-manager` registers at cohort-topic tier **T2**
|
|
6
|
+
* with a short TTL (`seeker_ttl`, default 10 s — `docs/matchmaking.md` §Seeker query). The seeker
|
|
7
|
+
* registers briefly so other seekers can find it (collective assembly) and the cohort sees active
|
|
8
|
+
* demand.
|
|
9
|
+
*
|
|
10
|
+
* This module holds *registration* state only. The `QueryV1` issuance and the hang-out-vs-continue
|
|
11
|
+
* decision live in the pure {@link import("./seeker-walk.js").decide} engine and the db-p2p
|
|
12
|
+
* `seeker-walk-client` that drives the walk; the capability filter is {@link import("./capability-filter.js").matchesFilter}.
|
|
13
|
+
*
|
|
14
|
+
* Crypto-free: signing is an injected callback, matching {@link MatchmakingProvider}.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from "@noble/hashes/utils.js";
|
|
18
|
+
import { seekerSigningPayload, type CapabilityFilter, type SeekerAppPayloadV1, encodeSeekerAppPayload } from "./wire.js";
|
|
19
|
+
|
|
20
|
+
/** Construction inputs for a {@link MatchmakingSeeker}. */
|
|
21
|
+
export interface MatchmakingSeekerOptions {
|
|
22
|
+
/** The matchmaking topic this seeker is querying. */
|
|
23
|
+
readonly topicId: Uint8Array;
|
|
24
|
+
/** Number of providers desired (integer `>= 1`). */
|
|
25
|
+
readonly wantCount: number;
|
|
26
|
+
/** Multiaddr or PeerId-based callback (collective-assembly use). */
|
|
27
|
+
readonly contactHint: string;
|
|
28
|
+
/** Optional capability filter (evaluated cohort-side in the next ticket; carried here). */
|
|
29
|
+
readonly filter?: CapabilityFilter;
|
|
30
|
+
/** Opt into arrival pushes; default false (poll path). Consumed by the next ticket. */
|
|
31
|
+
readonly pushOnArrival?: boolean;
|
|
32
|
+
/** Sign the canonical registration image; resolves the base64url signature. */
|
|
33
|
+
readonly sign: (payload: Uint8Array) => Promise<string>;
|
|
34
|
+
/** 16-byte registration correlation id (not signature-bound); default fresh CSPRNG bytes. */
|
|
35
|
+
readonly correlationId?: Uint8Array;
|
|
36
|
+
/** CSPRNG source (injectable for deterministic tests). Default `@noble/hashes` `randomBytes`. */
|
|
37
|
+
readonly randomBytes?: (n: number) => Uint8Array;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Live seeker state + signed-payload builder for one matchmaking topic (registration only). */
|
|
41
|
+
export class MatchmakingSeeker {
|
|
42
|
+
readonly topicId: Uint8Array;
|
|
43
|
+
readonly correlationId: Uint8Array;
|
|
44
|
+
private readonly wantCount: number;
|
|
45
|
+
private readonly contactHint: string;
|
|
46
|
+
private readonly filter?: CapabilityFilter;
|
|
47
|
+
private readonly pushOnArrival?: boolean;
|
|
48
|
+
private readonly sign: (payload: Uint8Array) => Promise<string>;
|
|
49
|
+
|
|
50
|
+
constructor(options: MatchmakingSeekerOptions) {
|
|
51
|
+
if (!Number.isInteger(options.wantCount) || options.wantCount < 1) {
|
|
52
|
+
throw new RangeError(`matchmaking seeker: wantCount must be an integer >= 1, got ${options.wantCount}`);
|
|
53
|
+
}
|
|
54
|
+
this.topicId = options.topicId;
|
|
55
|
+
this.wantCount = options.wantCount;
|
|
56
|
+
this.contactHint = options.contactHint;
|
|
57
|
+
this.filter = options.filter;
|
|
58
|
+
this.pushOnArrival = options.pushOnArrival;
|
|
59
|
+
this.sign = options.sign;
|
|
60
|
+
const rand = options.randomBytes ?? randomBytes;
|
|
61
|
+
this.correlationId = options.correlationId ?? rand(16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build the signed {@link SeekerAppPayloadV1} for this seeker's registration. */
|
|
65
|
+
async buildAppPayload(): Promise<SeekerAppPayloadV1> {
|
|
66
|
+
const signature = await this.sign(seekerSigningPayload(this.topicId, this.wantCount));
|
|
67
|
+
const payload: SeekerAppPayloadV1 = {
|
|
68
|
+
kind: "match-seeker",
|
|
69
|
+
wantCount: this.wantCount,
|
|
70
|
+
contactHint: this.contactHint,
|
|
71
|
+
signature,
|
|
72
|
+
};
|
|
73
|
+
if (this.filter !== undefined) {
|
|
74
|
+
payload.filter = this.filter;
|
|
75
|
+
}
|
|
76
|
+
if (this.pushOnArrival !== undefined) {
|
|
77
|
+
payload.pushOnArrival = this.pushOnArrival;
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the opaque bytes for the cohort-topic `RegisterV1.appPayload` slot. */
|
|
83
|
+
async appPayloadBytes(): Promise<Uint8Array> {
|
|
84
|
+
return encodeSeekerAppPayload(await this.buildAppPayload());
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matchmaking — stable topic anchor.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/matchmaking.md` §Anchor:
|
|
5
|
-
*
|
|
6
|
-
* ```
|
|
7
|
-
* topicId(kind, label) = H(kind ‖ label ‖ "match")
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* Unlike reactivity, matchmaking topics do **not** rotate — a task or capability label has a stable
|
|
11
|
-
* identity over its useful lifetime, so the anchor is a pure function of `(kind, label)`.
|
|
12
|
-
*
|
|
13
|
-
* `kind` namespaces the topic so unrelated label spaces never collide; `label` is application-defined
|
|
14
|
-
* (capability name, proposal hash, task-type id). The resulting `topicId` is fed verbatim into
|
|
15
|
-
* cohort-topic tier addressing (`coord_d(self, topicId)`), so it is derived with the **same** hash
|
|
16
|
-
* primitive cohort-topic uses for `coord_d` input — db-core's own {@link IRingHash} (SHA-256 truncated
|
|
17
|
-
* to the ring width), **not** a FRET import. At the default `ringBits = 256` the anchor is a 32-byte
|
|
18
|
-
* value, matching the cohort-topic `topicId` width.
|
|
19
|
-
*
|
|
20
|
-
* Concatenation is delimiter-free, exactly as the spec writes it. This is unambiguous because `kind`
|
|
21
|
-
* is drawn from a closed set ({@link MATCH_TOPIC_KINDS}) in which no member is a prefix of another, so
|
|
22
|
-
* `kind ‖ label` can never alias a different `(kind, label)` pair. The trailing `"match"` literal
|
|
23
|
-
* domain-separates matchmaking anchors from any other application that might hash the same label.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { createRingHash } from "../cohort-topic/ring-hash.js";
|
|
27
|
-
import type { IRingHash } from "../cohort-topic/ports.js";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The category of a matchmaking topic. Namespaces the `label` space so unrelated topics never
|
|
31
|
-
* collide on the ring (`docs/matchmaking.md` §Anchor).
|
|
32
|
-
*
|
|
33
|
-
* - `task` — a work-task type id; long-lived, matures a deep tree.
|
|
34
|
-
* - `capability` — a capability name (storage class, region, hardware feature); long-lived.
|
|
35
|
-
* - `quorum` — a voting-proposal hash; short-lived, forms a shallow tree that demotes once closed.
|
|
36
|
-
* - `capacity-class` — a capacity bucket for capacity gossip.
|
|
37
|
-
*/
|
|
38
|
-
export type MatchTopicKind = "task" | "capability" | "quorum" | "capacity-class";
|
|
39
|
-
|
|
40
|
-
/** Every matchmaking topic kind. Validated against on the seeker/provider build path. */
|
|
41
|
-
export const MATCH_TOPIC_KINDS: readonly MatchTopicKind[] = ["task", "capability", "quorum", "capacity-class"];
|
|
42
|
-
|
|
43
|
-
/** Domain-separation suffix mixed into every matchmaking anchor. */
|
|
44
|
-
const MATCH_SUFFIX = "match";
|
|
45
|
-
|
|
46
|
-
const utf8 = new TextEncoder();
|
|
47
|
-
|
|
48
|
-
/** Derives the stable `topicId` for a matchmaking `(kind, label)` pair. */
|
|
49
|
-
export interface MatchTopicAnchor {
|
|
50
|
-
/** `H(kind ‖ label ‖ "match")` — the cohort-topic `topicId` for this matchmaking topic. */
|
|
51
|
-
topicId(kind: MatchTopicKind, label: string): Uint8Array;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** True iff `kind` is one of the closed {@link MATCH_TOPIC_KINDS}. */
|
|
55
|
-
export function isMatchTopicKind(kind: string): kind is MatchTopicKind {
|
|
56
|
-
return (MATCH_TOPIC_KINDS as readonly string[]).includes(kind);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
class HashMatchTopicAnchor implements MatchTopicAnchor {
|
|
60
|
-
constructor(private readonly hash: IRingHash) {}
|
|
61
|
-
|
|
62
|
-
topicId(kind: MatchTopicKind, label: string): Uint8Array {
|
|
63
|
-
if (!isMatchTopicKind(kind)) {
|
|
64
|
-
throw new RangeError(`matchmaking topicId: unknown kind ${JSON.stringify(kind)}`);
|
|
65
|
-
}
|
|
66
|
-
const kindBytes = utf8.encode(kind);
|
|
67
|
-
const labelBytes = utf8.encode(label);
|
|
68
|
-
const suffixBytes = utf8.encode(MATCH_SUFFIX);
|
|
69
|
-
const input = new Uint8Array(kindBytes.length + labelBytes.length + suffixBytes.length);
|
|
70
|
-
input.set(kindBytes, 0);
|
|
71
|
-
input.set(labelBytes, kindBytes.length);
|
|
72
|
-
input.set(suffixBytes, kindBytes.length + labelBytes.length);
|
|
73
|
-
return this.hash.H(input);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Build a {@link MatchTopicAnchor} over the injected hash. db-p2p passes the same {@link IRingHash}
|
|
79
|
-
* instance it binds to FRET's `RING_BITS`, so the anchor and cohort-topic routing keys line up; the
|
|
80
|
-
* default constructs db-core's own {@link createRingHash} (256-bit SHA-256), which is byte-identical
|
|
81
|
-
* to what the cohort-topic host uses.
|
|
82
|
-
*/
|
|
83
|
-
export function createMatchTopicAnchor(hash: IRingHash = createRingHash()): MatchTopicAnchor {
|
|
84
|
-
return new HashMatchTopicAnchor(hash);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** One-shot convenience: `H(kind ‖ label ‖ "match")` over the default db-core ring hash. */
|
|
88
|
-
export function matchTopicId(kind: MatchTopicKind, label: string, hash: IRingHash = createRingHash()): Uint8Array {
|
|
89
|
-
return createMatchTopicAnchor(hash).topicId(kind, label);
|
|
90
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Matchmaking — stable topic anchor.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/matchmaking.md` §Anchor:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* topicId(kind, label) = H(kind ‖ label ‖ "match")
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Unlike reactivity, matchmaking topics do **not** rotate — a task or capability label has a stable
|
|
11
|
+
* identity over its useful lifetime, so the anchor is a pure function of `(kind, label)`.
|
|
12
|
+
*
|
|
13
|
+
* `kind` namespaces the topic so unrelated label spaces never collide; `label` is application-defined
|
|
14
|
+
* (capability name, proposal hash, task-type id). The resulting `topicId` is fed verbatim into
|
|
15
|
+
* cohort-topic tier addressing (`coord_d(self, topicId)`), so it is derived with the **same** hash
|
|
16
|
+
* primitive cohort-topic uses for `coord_d` input — db-core's own {@link IRingHash} (SHA-256 truncated
|
|
17
|
+
* to the ring width), **not** a FRET import. At the default `ringBits = 256` the anchor is a 32-byte
|
|
18
|
+
* value, matching the cohort-topic `topicId` width.
|
|
19
|
+
*
|
|
20
|
+
* Concatenation is delimiter-free, exactly as the spec writes it. This is unambiguous because `kind`
|
|
21
|
+
* is drawn from a closed set ({@link MATCH_TOPIC_KINDS}) in which no member is a prefix of another, so
|
|
22
|
+
* `kind ‖ label` can never alias a different `(kind, label)` pair. The trailing `"match"` literal
|
|
23
|
+
* domain-separates matchmaking anchors from any other application that might hash the same label.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createRingHash } from "../cohort-topic/ring-hash.js";
|
|
27
|
+
import type { IRingHash } from "../cohort-topic/ports.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The category of a matchmaking topic. Namespaces the `label` space so unrelated topics never
|
|
31
|
+
* collide on the ring (`docs/matchmaking.md` §Anchor).
|
|
32
|
+
*
|
|
33
|
+
* - `task` — a work-task type id; long-lived, matures a deep tree.
|
|
34
|
+
* - `capability` — a capability name (storage class, region, hardware feature); long-lived.
|
|
35
|
+
* - `quorum` — a voting-proposal hash; short-lived, forms a shallow tree that demotes once closed.
|
|
36
|
+
* - `capacity-class` — a capacity bucket for capacity gossip.
|
|
37
|
+
*/
|
|
38
|
+
export type MatchTopicKind = "task" | "capability" | "quorum" | "capacity-class";
|
|
39
|
+
|
|
40
|
+
/** Every matchmaking topic kind. Validated against on the seeker/provider build path. */
|
|
41
|
+
export const MATCH_TOPIC_KINDS: readonly MatchTopicKind[] = ["task", "capability", "quorum", "capacity-class"];
|
|
42
|
+
|
|
43
|
+
/** Domain-separation suffix mixed into every matchmaking anchor. */
|
|
44
|
+
const MATCH_SUFFIX = "match";
|
|
45
|
+
|
|
46
|
+
const utf8 = new TextEncoder();
|
|
47
|
+
|
|
48
|
+
/** Derives the stable `topicId` for a matchmaking `(kind, label)` pair. */
|
|
49
|
+
export interface MatchTopicAnchor {
|
|
50
|
+
/** `H(kind ‖ label ‖ "match")` — the cohort-topic `topicId` for this matchmaking topic. */
|
|
51
|
+
topicId(kind: MatchTopicKind, label: string): Uint8Array;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True iff `kind` is one of the closed {@link MATCH_TOPIC_KINDS}. */
|
|
55
|
+
export function isMatchTopicKind(kind: string): kind is MatchTopicKind {
|
|
56
|
+
return (MATCH_TOPIC_KINDS as readonly string[]).includes(kind);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class HashMatchTopicAnchor implements MatchTopicAnchor {
|
|
60
|
+
constructor(private readonly hash: IRingHash) {}
|
|
61
|
+
|
|
62
|
+
topicId(kind: MatchTopicKind, label: string): Uint8Array {
|
|
63
|
+
if (!isMatchTopicKind(kind)) {
|
|
64
|
+
throw new RangeError(`matchmaking topicId: unknown kind ${JSON.stringify(kind)}`);
|
|
65
|
+
}
|
|
66
|
+
const kindBytes = utf8.encode(kind);
|
|
67
|
+
const labelBytes = utf8.encode(label);
|
|
68
|
+
const suffixBytes = utf8.encode(MATCH_SUFFIX);
|
|
69
|
+
const input = new Uint8Array(kindBytes.length + labelBytes.length + suffixBytes.length);
|
|
70
|
+
input.set(kindBytes, 0);
|
|
71
|
+
input.set(labelBytes, kindBytes.length);
|
|
72
|
+
input.set(suffixBytes, kindBytes.length + labelBytes.length);
|
|
73
|
+
return this.hash.H(input);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a {@link MatchTopicAnchor} over the injected hash. db-p2p passes the same {@link IRingHash}
|
|
79
|
+
* instance it binds to FRET's `RING_BITS`, so the anchor and cohort-topic routing keys line up; the
|
|
80
|
+
* default constructs db-core's own {@link createRingHash} (256-bit SHA-256), which is byte-identical
|
|
81
|
+
* to what the cohort-topic host uses.
|
|
82
|
+
*/
|
|
83
|
+
export function createMatchTopicAnchor(hash: IRingHash = createRingHash()): MatchTopicAnchor {
|
|
84
|
+
return new HashMatchTopicAnchor(hash);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One-shot convenience: `H(kind ‖ label ‖ "match")` over the default db-core ring hash. */
|
|
88
|
+
export function matchTopicId(kind: MatchTopicKind, label: string, hash: IRingHash = createRingHash()): Uint8Array {
|
|
89
|
+
return createMatchTopicAnchor(hash).topicId(kind, label);
|
|
90
|
+
}
|