blockchainkit 0.2.0__py3-none-any.whl

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 (101) hide show
  1. blockchainkit/__init__.py +12 -0
  2. blockchainkit/_validation.py +44 -0
  3. blockchainkit/consensus/__init__.py +66 -0
  4. blockchainkit/consensus/core/__init__.py +25 -0
  5. blockchainkit/consensus/core/base.py +97 -0
  6. blockchainkit/consensus/systems/__init__.py +46 -0
  7. blockchainkit/consensus/systems/byzantine.py +91 -0
  8. blockchainkit/consensus/systems/catch_up.py +60 -0
  9. blockchainkit/consensus/systems/difficulty.py +74 -0
  10. blockchainkit/consensus/systems/finality.py +92 -0
  11. blockchainkit/consensus/systems/fork_choice.py +44 -0
  12. blockchainkit/consensus/systems/pbft.py +78 -0
  13. blockchainkit/consensus/systems/pos.py +53 -0
  14. blockchainkit/consensus/systems/pow.py +58 -0
  15. blockchainkit/consensus/systems/pricing.py +50 -0
  16. blockchainkit/consensus/systems/randomized.py +114 -0
  17. blockchainkit/consensus/systems/selfish.py +81 -0
  18. blockchainkit/consensus/systems/sortition.py +48 -0
  19. blockchainkit/consensus/systems/stake_games.py +44 -0
  20. blockchainkit/consensus/systems/synchrony.py +49 -0
  21. blockchainkit/consensus/visualizers/__init__.py +9 -0
  22. blockchainkit/consensus/visualizers/plots.py +80 -0
  23. blockchainkit/constants.py +66 -0
  24. blockchainkit/crypto/__init__.py +157 -0
  25. blockchainkit/crypto/core/__init__.py +21 -0
  26. blockchainkit/crypto/core/base.py +109 -0
  27. blockchainkit/crypto/systems/__init__.py +137 -0
  28. blockchainkit/crypto/systems/asymmetric.py +136 -0
  29. blockchainkit/crypto/systems/commitments.py +93 -0
  30. blockchainkit/crypto/systems/curves.py +205 -0
  31. blockchainkit/crypto/systems/discrete_log.py +156 -0
  32. blockchainkit/crypto/systems/hashing.py +95 -0
  33. blockchainkit/crypto/systems/lamport.py +65 -0
  34. blockchainkit/crypto/systems/mac.py +34 -0
  35. blockchainkit/crypto/systems/merkle_damgard.py +139 -0
  36. blockchainkit/crypto/systems/multisig.py +98 -0
  37. blockchainkit/crypto/systems/one_time_pad.py +42 -0
  38. blockchainkit/crypto/systems/puzzles.py +96 -0
  39. blockchainkit/crypto/systems/sharing.py +159 -0
  40. blockchainkit/crypto/systems/signatures.py +232 -0
  41. blockchainkit/crypto/utils/__init__.py +5 -0
  42. blockchainkit/crypto/utils/primes.py +39 -0
  43. blockchainkit/crypto/visualizers/__init__.py +9 -0
  44. blockchainkit/crypto/visualizers/plots.py +88 -0
  45. blockchainkit/network/__init__.py +70 -0
  46. blockchainkit/network/core/__init__.py +21 -0
  47. blockchainkit/network/core/base.py +149 -0
  48. blockchainkit/network/systems/__init__.py +54 -0
  49. blockchainkit/network/systems/addresses.py +126 -0
  50. blockchainkit/network/systems/broadcast.py +124 -0
  51. blockchainkit/network/systems/clocks.py +149 -0
  52. blockchainkit/network/systems/epidemics.py +111 -0
  53. blockchainkit/network/systems/gossip.py +201 -0
  54. blockchainkit/network/systems/kademlia.py +147 -0
  55. blockchainkit/network/systems/privacy.py +98 -0
  56. blockchainkit/network/systems/propagation.py +60 -0
  57. blockchainkit/network/systems/relay.py +123 -0
  58. blockchainkit/network/systems/replication.py +122 -0
  59. blockchainkit/network/systems/topology.py +238 -0
  60. blockchainkit/network/visualizers/__init__.py +14 -0
  61. blockchainkit/network/visualizers/plots.py +177 -0
  62. blockchainkit/py.typed +0 -0
  63. blockchainkit/structures/__init__.py +61 -0
  64. blockchainkit/structures/core/__init__.py +21 -0
  65. blockchainkit/structures/core/base.py +100 -0
  66. blockchainkit/structures/systems/__init__.py +45 -0
  67. blockchainkit/structures/systems/block.py +144 -0
  68. blockchainkit/structures/systems/bloom.py +81 -0
  69. blockchainkit/structures/systems/chain.py +131 -0
  70. blockchainkit/structures/systems/hash_chain.py +35 -0
  71. blockchainkit/structures/systems/headers.py +54 -0
  72. blockchainkit/structures/systems/ledger.py +87 -0
  73. blockchainkit/structures/systems/merkle.py +273 -0
  74. blockchainkit/structures/systems/mmr.py +112 -0
  75. blockchainkit/structures/systems/sparse_merkle.py +139 -0
  76. blockchainkit/structures/systems/transaction.py +116 -0
  77. blockchainkit/structures/systems/utxo.py +156 -0
  78. blockchainkit/structures/utils/__init__.py +6 -0
  79. blockchainkit/structures/utils/accounts.py +13 -0
  80. blockchainkit/structures/utils/encoding.py +11 -0
  81. blockchainkit/structures/visualizers/__init__.py +13 -0
  82. blockchainkit/structures/visualizers/plots.py +154 -0
  83. blockchainkit/vm/__init__.py +60 -0
  84. blockchainkit/vm/core/__init__.py +25 -0
  85. blockchainkit/vm/core/base.py +171 -0
  86. blockchainkit/vm/systems/__init__.py +40 -0
  87. blockchainkit/vm/systems/assembler.py +80 -0
  88. blockchainkit/vm/systems/expressions.py +91 -0
  89. blockchainkit/vm/systems/programs.py +143 -0
  90. blockchainkit/vm/systems/reentrancy.py +70 -0
  91. blockchainkit/vm/systems/script.py +268 -0
  92. blockchainkit/vm/systems/stack_machine.py +239 -0
  93. blockchainkit/vm/systems/turing.py +111 -0
  94. blockchainkit/vm/systems/verifier.py +81 -0
  95. blockchainkit/vm/visualizers/__init__.py +9 -0
  96. blockchainkit/vm/visualizers/plots.py +97 -0
  97. blockchainkit-0.2.0.dist-info/METADATA +195 -0
  98. blockchainkit-0.2.0.dist-info/RECORD +101 -0
  99. blockchainkit-0.2.0.dist-info/WHEEL +5 -0
  100. blockchainkit-0.2.0.dist-info/licenses/LICENSE +21 -0
  101. blockchainkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,126 @@
1
+ """Eclipse attacks on peer selection (Heilman, Kendler, Zohar and Goldberg 2015).
2
+
3
+ A node picks its outbound connections at random from a table of addresses
4
+ it has heard of. An attacker who fills that table with its own addresses
5
+ can make every connection land on an attacker: the node is *eclipsed*, and
6
+ sees only the blocks and transactions the attacker chooses to show it.
7
+
8
+ Bitcoin's defense, strengthened after the 2015 paper, is bucketing: an
9
+ address goes to a bucket determined by a secret hash of its network group
10
+ (its /16 IP prefix), and each group can reach only a few buckets. An
11
+ attacker with many addresses but few groups can then fill only a small part
12
+ of the table, however many addresses it sends.
13
+ """
14
+
15
+ from random import Random
16
+
17
+ from blockchainkit._validation import integer, probability
18
+ from blockchainkit._validation import seed as check_seed
19
+ from blockchainkit.crypto.systems.hashing import sha256
20
+
21
+
22
+ def eclipse_probability(attacker_fraction: float, outbound: int) -> float:
23
+ """Chance that all ``outbound`` connections land on attackers: ``f ** outbound``.
24
+
25
+ Each connection is modeled as an independent draw from a table in which
26
+ a fraction ``f`` of the entries belong to the attacker.
27
+
28
+ >>> from blockchainkit.network import eclipse_probability
29
+ >>> eclipse_probability(0.5, 8)
30
+ 0.00390625
31
+ """
32
+ probability(attacker_fraction, "attacker_fraction")
33
+ integer(outbound, "outbound", 1)
34
+ return float(attacker_fraction**outbound)
35
+
36
+
37
+ class AddressManager:
38
+ """A node's table of known peer addresses, with optional group bucketing.
39
+
40
+ Parameters
41
+ ----------
42
+ buckets : int
43
+ Number of buckets.
44
+ bucket_size : int
45
+ Capacity of each bucket. Adding to a full bucket evicts a random
46
+ entry, so a flood of new addresses pushes old ones out.
47
+ buckets_per_group : int, optional
48
+ If given, each network group can place addresses in only this many
49
+ buckets (Bitcoin's defense). If omitted, an address may land in any
50
+ bucket.
51
+ seed : int
52
+ Seeds both the node's secret bucketing key and its random choices.
53
+
54
+ Examples
55
+ --------
56
+ >>> from blockchainkit.network import AddressManager
57
+ >>> table = AddressManager(buckets=4, bucket_size=2, seed=1)
58
+ >>> table.add("10.0.0.1", "10.0")
59
+ >>> table.addresses
60
+ ('10.0.0.1',)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ buckets: int = 64,
67
+ bucket_size: int = 16,
68
+ buckets_per_group: int | None = None,
69
+ seed: int = 0,
70
+ ) -> None:
71
+ integer(buckets, "buckets", 1)
72
+ integer(bucket_size, "bucket_size", 1)
73
+ if buckets_per_group is not None:
74
+ integer(buckets_per_group, "buckets_per_group", 1)
75
+ check_seed(seed)
76
+ self._random = Random(seed)
77
+ self._secret = self._random.randbytes(16)
78
+ self._per_group = buckets_per_group
79
+ self._buckets: list[list[str]] = [[] for _ in range(buckets)]
80
+ self._size = bucket_size
81
+
82
+ def __repr__(self) -> str:
83
+ return f"AddressManager(buckets={len(self._buckets)}, addresses={len(self.addresses)})"
84
+
85
+ def _hash(self, *parts: str) -> int:
86
+ return int.from_bytes(sha256(self._secret + "\0".join(parts).encode())[:8], "big")
87
+
88
+ def bucket_of(self, address: str, group: str) -> int:
89
+ """The bucket an address is stored in, derived from the node's secret key."""
90
+ if self._per_group is None:
91
+ return self._hash(address) % len(self._buckets)
92
+ slot = self._hash(address) % self._per_group
93
+ return self._hash(group, str(slot)) % len(self._buckets)
94
+
95
+ def add(self, address: str, group: str) -> None:
96
+ """Store an address heard from the network, evicting a random entry if needed."""
97
+ if not isinstance(address, str) or not isinstance(group, str):
98
+ raise TypeError("address and group must be str")
99
+ bucket = self._buckets[self.bucket_of(address, group)]
100
+ if address in bucket:
101
+ return
102
+ if len(bucket) >= self._size:
103
+ bucket.pop(self._random.randrange(len(bucket)))
104
+ bucket.append(address)
105
+
106
+ @property
107
+ def addresses(self) -> tuple[str, ...]:
108
+ """Every stored address, bucket by bucket."""
109
+ return tuple(address for bucket in self._buckets for address in bucket)
110
+
111
+ def select(self, count: int) -> tuple[str, ...]:
112
+ """Choose ``count`` distinct addresses: each time a random nonempty bucket, then an entry.
113
+
114
+ Picking a bucket first, as Bitcoin does, means an attacker confined
115
+ to a few buckets is picked rarely even if those buckets are full.
116
+ """
117
+ integer(count, "count", 1)
118
+ if count > len(self.addresses):
119
+ raise ValueError("not enough addresses")
120
+ chosen: list[str] = []
121
+ while len(chosen) < count:
122
+ bucket = self._random.choice([b for b in self._buckets if b])
123
+ address = self._random.choice(bucket)
124
+ if address not in chosen:
125
+ chosen.append(address)
126
+ return tuple(chosen)
@@ -0,0 +1,124 @@
1
+ """Byzantine reliable broadcast (Bracha 1987).
2
+
3
+ A sender wants every correct process to deliver the same value, even if the
4
+ sender lies by telling different processes different things. Bracha's
5
+ protocol adds two all-to-all phases on top of the sender's message:
6
+
7
+ 1. **Echo.** On the sender's value, a process tells everyone "I got v".
8
+ 2. **Ready.** On ``ceil((n + t + 1) / 2)`` echoes of v, or ``t + 1`` readies
9
+ of v (amplification), a process tells everyone "ready for v", once.
10
+ 3. **Deliver.** On ``2t + 1`` readies of v, a process delivers v.
11
+
12
+ Any two echo quorums overlap in a correct process, which echoes only once,
13
+ so correct processes never become ready for different values. Amplification
14
+ makes delivery all-or-nothing. Both arguments need ``n > 3t``.
15
+ """
16
+
17
+ from collections.abc import Iterable, Sequence
18
+
19
+ from blockchainkit._validation import integer
20
+ from blockchainkit.network.core.base import BroadcastResult
21
+
22
+ SENDER = 0
23
+ """int: The broadcasting process."""
24
+
25
+
26
+ def reliable_broadcast(
27
+ n: int,
28
+ faulty: Iterable[int],
29
+ proposals: Sequence[str],
30
+ *,
31
+ tolerance: int | None = None,
32
+ ) -> BroadcastResult:
33
+ """Run Bracha's broadcast in synchronous rounds from process 0.
34
+
35
+ Parameters
36
+ ----------
37
+ n : int
38
+ Number of processes, at least 2.
39
+ faulty : iterable of int
40
+ Byzantine processes. A faulty sender sends ``proposals[r]`` to each
41
+ process ``r``. Faulty processes collude with that split: to each
42
+ correct process ``r`` they echo and ready ``proposals[r]``, the
43
+ strongest support they can give to a disagreement.
44
+ proposals : sequence of str
45
+ One value per process; entries at faulty processes are ignored. If
46
+ the sender is correct, all correct entries must equal ``proposals[0]``.
47
+ tolerance : int, optional
48
+ The ``t`` used in the thresholds; defaults to ``(n - 1) // 3``, the
49
+ most the protocol can tolerate. Running with more faulty processes
50
+ than ``tolerance`` shows the bound is needed.
51
+
52
+ Returns
53
+ -------
54
+ BroadcastResult
55
+
56
+ Examples
57
+ --------
58
+ >>> from blockchainkit.network import reliable_broadcast
59
+ >>> result = reliable_broadcast(4, {0}, ["a", "a", "b", "b"])
60
+ >>> result.agreement, result.totality, sorted(set(result.delivered.values()))
61
+ (True, True, ['b'])
62
+ """
63
+ integer(n, "n", 2)
64
+ bad = set(faulty)
65
+ for process in bad:
66
+ integer(process, "faulty process")
67
+ if process >= n:
68
+ raise ValueError("faulty processes must be in range(n)")
69
+ t = (n - 1) // 3 if tolerance is None else tolerance
70
+ integer(t, "tolerance")
71
+ if len(proposals) != n or not all(isinstance(v, str) for v in proposals):
72
+ raise ValueError("give one str proposal per process")
73
+ correct = [p for p in range(n) if p not in bad]
74
+ if SENDER not in bad and any(proposals[p] != proposals[SENDER] for p in correct):
75
+ raise ValueError("a correct sender sends the same value to everyone")
76
+ echo_quorum, amplify, deliver_quorum = -(-(n + t + 1) // 2), t + 1, 2 * t + 1
77
+
78
+ echoes: dict[int, dict[str, set[int]]] = {p: {} for p in correct}
79
+ readies: dict[int, dict[str, set[int]]] = {p: {} for p in correct}
80
+ for r in correct: # Faulty processes back the value the sender gave r.
81
+ for f in bad:
82
+ echoes[r].setdefault(proposals[r], set()).add(f)
83
+ readies[r].setdefault(proposals[r], set()).add(f)
84
+ echoed: set[int] = set()
85
+ ready: dict[int, str] = {}
86
+ delivered: dict[int, str | None] = dict.fromkeys(correct)
87
+ messages = len(correct) if SENDER in correct else 0 # The SEND messages.
88
+
89
+ def broadcast(table: dict[int, dict[str, set[int]]], source: int, value: str) -> int:
90
+ for r in correct:
91
+ table[r].setdefault(value, set()).add(source)
92
+ return n
93
+
94
+ changed = True
95
+ while changed:
96
+ changed = False
97
+ sends: list[tuple[str, int, str]] = []
98
+ for p in correct:
99
+ if p not in echoed:
100
+ sends.append(("echo", p, proposals[p]))
101
+ echoed.add(p)
102
+ if p not in ready:
103
+ candidates = sorted(
104
+ v
105
+ for v in set(echoes[p]) | set(readies[p])
106
+ if len(echoes[p].get(v, ())) >= echo_quorum
107
+ or len(readies[p].get(v, ())) >= amplify
108
+ )
109
+ if candidates:
110
+ ready[p] = candidates[0]
111
+ sends.append(("ready", p, candidates[0]))
112
+ if delivered[p] is None:
113
+ for v in sorted(readies[p]):
114
+ if len(readies[p][v]) >= deliver_quorum:
115
+ delivered[p] = v
116
+ changed = True
117
+ break
118
+ for kind, p, value in sends: # Messages of a round arrive together.
119
+ messages += broadcast(echoes if kind == "echo" else readies, p, value)
120
+ changed = True
121
+ values = {v for v in delivered.values() if v is not None}
122
+ some = any(v is not None for v in delivered.values())
123
+ every = all(v is not None for v in delivered.values())
124
+ return BroadcastResult(delivered, len(values) <= 1, every or not some, messages)
@@ -0,0 +1,149 @@
1
+ """Logical clocks: Lamport timestamps (1978) and vector clocks (Fidge, Mattern 1988).
2
+
3
+ Peers have no shared clock, so "which happened first?" has no physical
4
+ answer. Lamport defined *happened before* from the messages themselves: an
5
+ event precedes later events at the same process, a send precedes its
6
+ receive, and the relation is transitive. Two events related neither way are
7
+ *concurrent*.
8
+
9
+ A history is a sequence of processes, each a sequence of events. An event is
10
+ a pair ``(kind, label)``: ``("local", name)``, ``("send", message)`` or
11
+ ``("receive", message)``. Each message label is sent once and received at
12
+ most once.
13
+ """
14
+
15
+ from collections.abc import Sequence
16
+
17
+ Event = tuple[str, str]
18
+ """``(kind, label)`` with kind ``"local"``, ``"send"`` or ``"receive"``."""
19
+
20
+ _KINDS = ("local", "send", "receive")
21
+
22
+
23
+ def _schedule(processes: Sequence[Sequence[Event]]) -> list[tuple[int, int]]:
24
+ """Positions ``(process, index)`` in an order where every send precedes its receive."""
25
+ senders: dict[str, tuple[int, int]] = {}
26
+ received: set[str] = set()
27
+ for p, events in enumerate(processes):
28
+ for i, event in enumerate(events):
29
+ if not isinstance(event, tuple) or len(event) != 2 or event[0] not in _KINDS:
30
+ raise ValueError(f"event {event!r} is not (kind, label) with a known kind")
31
+ kind, label = event
32
+ if kind == "send":
33
+ if label in senders:
34
+ raise ValueError(f"message {label!r} is sent twice")
35
+ senders[label] = (p, i)
36
+ elif kind == "receive":
37
+ if label in received:
38
+ raise ValueError(f"message {label!r} is received twice")
39
+ received.add(label)
40
+ if missing := received - senders.keys():
41
+ raise ValueError(f"messages received but never sent: {sorted(missing)}")
42
+ done: set[tuple[int, int]] = set()
43
+ order: list[tuple[int, int]] = []
44
+ cursor = [0] * len(processes)
45
+ progress = True
46
+ while progress:
47
+ progress = False
48
+ for p, events in enumerate(processes):
49
+ while cursor[p] < len(events):
50
+ kind, label = events[cursor[p]]
51
+ if kind == "receive" and senders[label] not in done:
52
+ break # Wait until the matching send has a timestamp.
53
+ done.add((p, cursor[p]))
54
+ order.append((p, cursor[p]))
55
+ cursor[p] += 1
56
+ progress = True
57
+ if len(order) != sum(len(events) for events in processes):
58
+ raise ValueError("the history has a causal cycle: a receive waits on its own future")
59
+ return order
60
+
61
+
62
+ def lamport_timestamps(processes: Sequence[Sequence[Event]]) -> tuple[tuple[int, ...], ...]:
63
+ """Assign Lamport clock values to every event.
64
+
65
+ Each process keeps a counter. It increments the counter before each
66
+ event, attaches it to every message it sends, and on receipt jumps to
67
+ ``max(own, received) + 1``. The result satisfies the *clock condition*:
68
+ if a happened before b, then ``C(a) < C(b)``. The converse does not hold.
69
+
70
+ Returns
71
+ -------
72
+ tuple of tuple of int
73
+ ``result[p][i]`` is the timestamp of event ``i`` at process ``p``.
74
+
75
+ Examples
76
+ --------
77
+ >>> from blockchainkit.network import lamport_timestamps
78
+ >>> lamport_timestamps([[("send", "m")], [("local", "x"), ("local", "y"), ("receive", "m")]])
79
+ ((1,), (1, 2, 3))
80
+ """
81
+ stamps = [[0] * len(events) for events in processes]
82
+ clock = [0] * len(processes)
83
+ sent: dict[str, int] = {}
84
+ for p, i in _schedule(processes):
85
+ kind, label = processes[p][i]
86
+ if kind == "receive":
87
+ clock[p] = max(clock[p], sent[label])
88
+ clock[p] += 1
89
+ stamps[p][i] = clock[p]
90
+ if kind == "send":
91
+ sent[label] = clock[p]
92
+ return tuple(tuple(row) for row in stamps)
93
+
94
+
95
+ def vector_timestamps(
96
+ processes: Sequence[Sequence[Event]],
97
+ ) -> tuple[tuple[tuple[int, ...], ...], ...]:
98
+ """Assign vector clock values to every event.
99
+
100
+ Each process ``p`` keeps one counter per process. It increments entry
101
+ ``p`` before each event and, on receipt, first takes the entrywise maximum
102
+ with the vector carried by the message. Entry ``q`` of an event's vector
103
+ counts the events at ``q`` that happened before or at it, so vectors
104
+ characterize causality exactly: see :func:`happened_before`.
105
+
106
+ Examples
107
+ --------
108
+ >>> from blockchainkit.network import vector_timestamps
109
+ >>> vector_timestamps([[("send", "m")], [("local", "x"), ("receive", "m")]])
110
+ (((1, 0),), ((0, 1), (1, 2)))
111
+ """
112
+ n = len(processes)
113
+ stamps: list[list[tuple[int, ...]]] = [[()] * len(events) for events in processes]
114
+ clock = [[0] * n for _ in range(n)]
115
+ sent: dict[str, tuple[int, ...]] = {}
116
+ for p, i in _schedule(processes):
117
+ kind, label = processes[p][i]
118
+ if kind == "receive":
119
+ clock[p] = [max(a, b) for a, b in zip(clock[p], sent[label], strict=True)]
120
+ clock[p][p] += 1
121
+ stamps[p][i] = tuple(clock[p])
122
+ if kind == "send":
123
+ sent[label] = stamps[p][i]
124
+ return tuple(tuple(row) for row in stamps)
125
+
126
+
127
+ def happened_before(a: Sequence[int], b: Sequence[int]) -> bool:
128
+ """True if the event stamped ``a`` happened before the event stamped ``b``.
129
+
130
+ For vector timestamps, ``a -> b`` exactly when ``a <= b`` entrywise and
131
+ ``a != b``.
132
+
133
+ >>> from blockchainkit.network import happened_before
134
+ >>> happened_before((1, 0), (1, 2)), happened_before((1, 0), (0, 1))
135
+ (True, False)
136
+ """
137
+ if len(a) != len(b):
138
+ raise ValueError("vector timestamps must have the same length")
139
+ return all(x <= y for x, y in zip(a, b, strict=True)) and tuple(a) != tuple(b)
140
+
141
+
142
+ def concurrent(a: Sequence[int], b: Sequence[int]) -> bool:
143
+ """True if neither vector-stamped event happened before the other (and they differ).
144
+
145
+ >>> from blockchainkit.network import concurrent
146
+ >>> concurrent((1, 0), (0, 1))
147
+ True
148
+ """
149
+ return tuple(a) != tuple(b) and not happened_before(a, b) and not happened_before(b, a)
@@ -0,0 +1,111 @@
1
+ """Epidemic dissemination: push, pull and push-pull rumor spreading.
2
+
3
+ Each round, every peer calls one neighbor chosen uniformly at random. In
4
+ *push*, informed callers tell the callee; in *pull*, uninformed callers ask
5
+ the callee and learn the rumor if it knows it; *push-pull* does both. Demers
6
+ et al. (1987) compared these for replicated databases. On the complete graph,
7
+ push alone informs everyone in about ``log2 n + ln n`` rounds (Frieze and
8
+ Grimmett 1985, Pittel 1987).
9
+ """
10
+
11
+ from math import log, log2
12
+ from random import Random
13
+
14
+ from blockchainkit._validation import integer
15
+ from blockchainkit._validation import seed as check_seed
16
+ from blockchainkit.network.core.base import RumorRun
17
+ from blockchainkit.network.systems.topology import Graph
18
+
19
+ MODES = ("push", "pull", "push-pull")
20
+ """tuple of str: The supported exchange modes."""
21
+
22
+
23
+ def pittel_rounds(n: int) -> float:
24
+ """Rounds push gossip needs on the complete graph: ``log2 n + ln n``, up to O(1).
25
+
26
+ The ``log2 n`` term is the doubling phase, while few peers know the
27
+ rumor; the ``ln n`` term is the coupon-collector tail, while the last
28
+ uninformed peers wait to be called.
29
+
30
+ >>> from blockchainkit.network import pittel_rounds
31
+ >>> round(pittel_rounds(1024), 2)
32
+ 16.93
33
+ """
34
+ integer(n, "n", 1)
35
+ return log2(n) + log(n)
36
+
37
+
38
+ def spread_rumor(
39
+ network: Graph | int,
40
+ *,
41
+ mode: str = "push",
42
+ source: int = 0,
43
+ seed: int = 0,
44
+ max_rounds: int = 10_000,
45
+ ) -> RumorRun:
46
+ """Simulate synchronous random-call rumor spreading.
47
+
48
+ Parameters
49
+ ----------
50
+ network : Graph or int
51
+ The peer graph, or an integer ``n`` for the complete graph on ``n``
52
+ peers (simulated without building its n(n-1)/2 edges).
53
+ mode : {"push", "pull", "push-pull"}
54
+ Who learns from each call; see the module docstring.
55
+ source : int
56
+ The peer that knows the rumor at round 0.
57
+ seed : int
58
+ Seed for the random calls.
59
+ max_rounds : int
60
+ Stop after this many rounds even if peers remain uninformed.
61
+
62
+ Returns
63
+ -------
64
+ RumorRun
65
+ Informed counts per round. The run also stops once every peer that
66
+ the source can reach is informed.
67
+
68
+ Examples
69
+ --------
70
+ >>> from blockchainkit.network import spread_rumor
71
+ >>> run = spread_rumor(1000, seed=1)
72
+ >>> run.complete, run.informed[:4]
73
+ (True, (1, 2, 4, 8))
74
+ """
75
+ if mode not in MODES:
76
+ raise ValueError(f"mode must be one of {MODES}")
77
+ check_seed(seed)
78
+ integer(max_rounds, "max_rounds", 1)
79
+ if isinstance(network, Graph):
80
+ n = network.n
81
+ reachable = sum(1 for d in network.distances(source) if d is not None)
82
+ else:
83
+ integer(network, "n", 1)
84
+ n = reachable = network
85
+ integer(source, "source")
86
+ if source >= n:
87
+ raise ValueError("source must be a peer in range(n)")
88
+ random = Random(seed)
89
+
90
+ def call(peer: int) -> int | None:
91
+ if isinstance(network, Graph):
92
+ neighbors = network.neighbors(peer)
93
+ return random.choice(neighbors) if neighbors else None
94
+ partner = random.randrange(n - 1) # Any peer but the caller.
95
+ return partner + 1 if partner >= peer else partner
96
+
97
+ informed = [False] * n
98
+ informed[source] = True
99
+ counts = [1]
100
+ while counts[-1] < reachable and len(counts) <= max_rounds:
101
+ before = informed[:] # Calls in a round see the state at its start.
102
+ for caller in range(n):
103
+ callee = call(caller)
104
+ if callee is None:
105
+ continue
106
+ if mode != "pull" and before[caller]:
107
+ informed[callee] = True
108
+ if mode != "push" and before[callee]:
109
+ informed[caller] = True
110
+ counts.append(sum(informed))
111
+ return RumorRun(tuple(counts), n)