@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.
Files changed (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. 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
+ }