fedmesh 0.1.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.
fedmesh/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """fedmesh: privacy-preserving federated training across organisations.
2
+
3
+ fedmesh coordinates training runs that span several organisations, each of
4
+ which keeps its data local. It is built on iroh-tinker's public surface:
5
+ ``LocalTransport``/``IrohTransport`` for the wire, ``CompressedTransport``
6
+ for bandwidth, and ``JobManager``/``LaunchSpec`` for the launch.
7
+
8
+ Two things make it different from a plain multi-node job:
9
+
10
+ * **No raw gradient leaves a party.** Contributions are masked with pairwise
11
+ secrets before they cross the wire, and the masks cancel in the aggregate.
12
+ * **No single party holds a global secret.** Every pair of parties derives
13
+ its own secret, so compromising the coordinator does not open the other
14
+ pairs' channels.
15
+
16
+ The guarantees, and their limits, are written down in ``docs/threat-model.md``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ from .dp import (
23
+ DPConfig,
24
+ PrivacyAccountant,
25
+ PrivacyBudgetError,
26
+ add_gaussian_noise,
27
+ clip_by_l2_norm,
28
+ epsilon_for_gaussian_mechanism,
29
+ )
30
+ from .masking import (
31
+ InsecureTransportError,
32
+ MaskedTransport,
33
+ MaskRegistry,
34
+ broadcast_within_party,
35
+ exchange_party_sums,
36
+ is_lossy_transport,
37
+ )
38
+ from .mesh import Mesh, MeshError, PartyManifest
39
+ from .party import Party, PartyError, pairwise_secrets
40
+ from .sampler import Shard, ShardAudit, ShardPolicy, audit_shards
41
+ from .trust import (
42
+ PairwiseSecrets,
43
+ PartyKeyPair,
44
+ TrustError,
45
+ derive_pairwise_secret,
46
+ hkdf_expand,
47
+ hkdf_extract,
48
+ sign_ticket,
49
+ verify_ticket,
50
+ )
51
+
52
+ __all__ = [
53
+ "__version__",
54
+ # trust
55
+ "PartyKeyPair",
56
+ "PairwiseSecrets",
57
+ "TrustError",
58
+ "derive_pairwise_secret",
59
+ "hkdf_expand",
60
+ "hkdf_extract",
61
+ "sign_ticket",
62
+ "verify_ticket",
63
+ # mesh
64
+ "Mesh",
65
+ "MeshError",
66
+ "PartyManifest",
67
+ # party
68
+ "Party",
69
+ "PartyError",
70
+ "pairwise_secrets",
71
+ # masking
72
+ "MaskedTransport",
73
+ "MaskRegistry",
74
+ "InsecureTransportError",
75
+ "broadcast_within_party",
76
+ "exchange_party_sums",
77
+ "is_lossy_transport",
78
+ # differential privacy
79
+ "DPConfig",
80
+ "PrivacyAccountant",
81
+ "PrivacyBudgetError",
82
+ "add_gaussian_noise",
83
+ "clip_by_l2_norm",
84
+ "epsilon_for_gaussian_mechanism",
85
+ # sampling
86
+ "Shard",
87
+ "ShardAudit",
88
+ "ShardPolicy",
89
+ "audit_shards",
90
+ ]
fedmesh/cli.py ADDED
@@ -0,0 +1,377 @@
1
+ """``fedmesh`` command line interface.
2
+
3
+ Subcommands:
4
+
5
+ ``init`` create a party's key pair and namespace
6
+ ``manifest`` print this party's manifest
7
+ ``mesh`` combine manifests into a mesh and elect the coordinator
8
+ ``shards`` show which party owns which dataset rows
9
+ ``dp`` project a differential privacy budget
10
+ ``spike`` run the compression/masking spike and print the numbers
11
+
12
+ Every command supports ``--json``.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import stat
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .dp import DPConfig, PrivacyAccountant
25
+ from .masking import MaskRegistry
26
+ from .mesh import Mesh
27
+ from .party import Party
28
+ from .sampler import ShardPolicy
29
+ from .trust import PartyKeyPair
30
+
31
+ __all__ = ["build_parser", "main"]
32
+
33
+ KEY_FILE = "party_key.json"
34
+
35
+
36
+ def _write_private(
37
+ path: Path,
38
+ party_id: str,
39
+ keypair: PartyKeyPair,
40
+ ranks: int,
41
+ rank_offset: int = 0,
42
+ ) -> None:
43
+ """Write a party's private key with owner-only permissions."""
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ payload = {
46
+ "party_id": party_id,
47
+ "ranks": int(ranks),
48
+ "rank_offset": int(rank_offset),
49
+ "private_exponent": str(keypair.private_exponent),
50
+ "public_key": keypair.public_bytes().hex(),
51
+ }
52
+ path.write_text(json.dumps(payload, indent=2) + "\n")
53
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
54
+
55
+
56
+ def _read_key(path: Path) -> PartyKeyPair:
57
+ raw = json.loads(path.read_text())
58
+ return PartyKeyPair.from_private(
59
+ str(raw["party_id"]), int(str(raw["private_exponent"]))
60
+ )
61
+
62
+
63
+ def _load_party(directory: Path, party_id: str | None = None) -> Party:
64
+ key_path = directory / KEY_FILE
65
+ if not key_path.exists():
66
+ raise FileNotFoundError(f"no party key at {key_path}; run 'fedmesh init' first")
67
+ keypair = _read_key(key_path)
68
+ raw_ranks = json.loads(key_path.read_text()).get("ranks", 1)
69
+ if party_id is not None and party_id != keypair.party_id:
70
+ raise ValueError(
71
+ f"key at {key_path} belongs to {keypair.party_id!r}, not {party_id!r}"
72
+ )
73
+ offset = int(json.loads(key_path.read_text()).get("rank_offset", 0))
74
+ ranks = tuple(range(offset, offset + int(raw_ranks)))
75
+ return Party(
76
+ party_id=keypair.party_id,
77
+ keypair=keypair,
78
+ ranks=ranks,
79
+ rendezvous_uri=str(directory),
80
+ )
81
+
82
+
83
+ def build_parser() -> argparse.ArgumentParser:
84
+ parser = argparse.ArgumentParser(
85
+ prog="fedmesh",
86
+ description="Privacy-preserving federated training across organisations.",
87
+ )
88
+ parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
89
+ sub = parser.add_subparsers(dest="command", required=True)
90
+
91
+ init = sub.add_parser("init", help="create a party key pair")
92
+ init.add_argument("--party-id", required=True, help="the party's identifier")
93
+ init.add_argument("--dir", required=True, help="party directory")
94
+ init.add_argument("--ranks", type=int, default=1, help="number of ranks")
95
+ init.add_argument(
96
+ "--rank-offset",
97
+ type=int,
98
+ default=0,
99
+ help="the party's first global rank in the mesh",
100
+ )
101
+ init.add_argument("--json", action="store_true", help="emit JSON on stdout")
102
+
103
+ manifest = sub.add_parser("manifest", help="print this party's manifest")
104
+ manifest.add_argument("--dir", required=True, help="party directory")
105
+ manifest.add_argument("--json", action="store_true", help="emit JSON on stdout")
106
+
107
+ mesh = sub.add_parser("mesh", help="combine manifests into a mesh")
108
+ mesh.add_argument("manifests", nargs="+", help="manifest JSON files")
109
+ mesh.add_argument("--json", action="store_true", help="emit JSON on stdout")
110
+
111
+ shards = sub.add_parser("shards", help="show the shard assignment")
112
+ shards.add_argument("--parties", required=True, help="comma separated party ids")
113
+ shards.add_argument("--rows", type=int, default=1000, help="number of dataset rows")
114
+ shards.add_argument("--json", action="store_true", help="emit JSON on stdout")
115
+
116
+ dp = sub.add_parser("dp", help="project a privacy budget")
117
+ dp.add_argument("--epsilon", type=float, default=8.0, help="target epsilon")
118
+ dp.add_argument("--delta", type=float, default=1e-5, help="target delta")
119
+ dp.add_argument("--noise", type=float, default=10.0, help="noise multiplier")
120
+ dp.add_argument("--clip", type=float, default=1.0, help="clipping norm")
121
+ dp.add_argument("--steps", type=int, default=10, help="number of steps to project")
122
+ dp.add_argument("--json", action="store_true", help="emit JSON on stdout")
123
+
124
+ spike = sub.add_parser("spike", help="run the compression/masking spike")
125
+ spike.add_argument("--json", action="store_true", help="emit JSON on stdout")
126
+
127
+ return parser
128
+
129
+
130
+ def _cmd_init(args: argparse.Namespace) -> int:
131
+ directory = Path(args.dir)
132
+ key_path = directory / KEY_FILE
133
+ if key_path.exists():
134
+ raise FileExistsError(f"party key already exists at {key_path}")
135
+ keypair = PartyKeyPair.generate(args.party_id)
136
+ _write_private(key_path, args.party_id, keypair, args.ranks, args.rank_offset)
137
+ party = Party(
138
+ party_id=args.party_id,
139
+ keypair=keypair,
140
+ ranks=tuple(range(args.ranks)),
141
+ rendezvous_uri=str(directory),
142
+ )
143
+ payload = {
144
+ "party_id": party.party_id,
145
+ "ranks": list(party.ranks),
146
+ "public_key": party.public_key().hex(),
147
+ "key_file": str(key_path),
148
+ }
149
+ if args.json:
150
+ _emit_json(payload)
151
+ return 0
152
+ print(f"party {payload['party_id']} initialised with {len(party.ranks)} rank(s)")
153
+ print(f"key file: {key_path} (mode 600)")
154
+ return 0
155
+
156
+
157
+ def _cmd_manifest(args: argparse.Namespace) -> int:
158
+ party = _load_party(Path(args.dir))
159
+ manifest = party.manifest().to_dict()
160
+ if args.json:
161
+ _emit_json(manifest)
162
+ else:
163
+ print(json.dumps(manifest, indent=2))
164
+ return 0
165
+
166
+
167
+ def _cmd_mesh(args: argparse.Namespace) -> int:
168
+ mesh = Mesh()
169
+ for path in args.manifests:
170
+ raw = json.loads(Path(path).read_text())
171
+ for entry in raw.get("parties", [raw]):
172
+ from .mesh import PartyManifest
173
+
174
+ mesh.add(PartyManifest.from_dict(entry))
175
+ mesh.validate()
176
+ payload = mesh.to_dict()
177
+ if args.json:
178
+ _emit_json(payload)
179
+ return 0
180
+ print(f"parties: {len(mesh.parties)} world size: {mesh.world_size()}")
181
+ print(f"coordinator: {mesh.coordinator()}")
182
+ print(f"election proof: {mesh.election_proof()}")
183
+ for party in mesh.party_ids():
184
+ ranks = mesh.ranks_of(party)
185
+ print(f" {party:<12} ranks {list(ranks)}")
186
+ return 0
187
+
188
+
189
+ def _cmd_shards(args: argparse.Namespace) -> int:
190
+ party_ids = [p.strip() for p in args.parties.split(",") if p.strip()]
191
+ policy = ShardPolicy(party_ids)
192
+ shards = policy.assign(args.rows)
193
+ payload = {
194
+ party: {
195
+ "rows": len(shard),
196
+ "indices": list(shard.indices[:20]),
197
+ "truncated": len(shard.indices) > 20,
198
+ }
199
+ for party, shard in sorted(shards.items())
200
+ }
201
+ if args.json:
202
+ _emit_json(payload)
203
+ return 0
204
+ for party, entry in payload.items():
205
+ print(f" {party:<12} {entry['rows']:>6} rows")
206
+ print(f"total: {args.rows} rows, one owner per row")
207
+ return 0
208
+
209
+
210
+ def _cmd_dp(args: argparse.Namespace) -> int:
211
+ config = DPConfig(
212
+ clip_norm=args.clip,
213
+ noise_multiplier=args.noise,
214
+ target_epsilon=args.epsilon,
215
+ target_delta=args.delta,
216
+ )
217
+ accountant = PrivacyAccountant(args.epsilon, args.delta)
218
+ steps = 0
219
+ try:
220
+ for _ in range(args.steps):
221
+ accountant.spend_step(config)
222
+ steps += 1
223
+ except Exception as exc: # noqa: BLE001 - the budget error is the answer
224
+ payload = {
225
+ "config": {
226
+ "clip_norm": config.clip_norm,
227
+ "noise_multiplier": config.noise_multiplier,
228
+ "target_epsilon": config.target_epsilon,
229
+ "target_delta": config.target_delta,
230
+ },
231
+ "steps_completed": steps,
232
+ "budget_exhausted": True,
233
+ "reason": str(exc),
234
+ "accountant": accountant.to_dict(),
235
+ }
236
+ if args.json:
237
+ _emit_json(payload)
238
+ else:
239
+ print(f"budget exhausted after {steps} step(s): {exc}")
240
+ return 1
241
+ payload = {
242
+ "config": {
243
+ "clip_norm": config.clip_norm,
244
+ "noise_multiplier": config.noise_multiplier,
245
+ "target_epsilon": config.target_epsilon,
246
+ "target_delta": config.target_delta,
247
+ },
248
+ "steps_completed": steps,
249
+ "budget_exhausted": False,
250
+ "accountant": accountant.to_dict(),
251
+ }
252
+ if args.json:
253
+ _emit_json(payload)
254
+ return 0
255
+ print(
256
+ f"{steps} step(s) within budget: epsilon {accountant.epsilon:.4f} of "
257
+ f"{accountant.target_epsilon}, delta {accountant.delta:.3e}"
258
+ )
259
+ return 0
260
+
261
+
262
+ def _cmd_spike(args: argparse.Namespace) -> int:
263
+ """Run the P2.1 spike: masks cancel exactly, and not through compression."""
264
+ import numpy as np
265
+
266
+ from .party import pairwise_secrets
267
+
268
+ size = 1024
269
+ party_ids = ("alpha", "beta", "gamma")
270
+ parties: dict[str, Party] = {}
271
+ for index, party_id in enumerate(party_ids):
272
+ parties[party_id] = Party(
273
+ party_id=party_id,
274
+ keypair=PartyKeyPair.generate(party_id, seed=b"fedmesh-spike"),
275
+ ranks=(index,),
276
+ )
277
+ for i, first in enumerate(party_ids):
278
+ for second in party_ids[i + 1 :]:
279
+ parties[first].establish(second, parties[second].public_key())
280
+ parties[second].establish(first, parties[first].public_key())
281
+ registry = MaskRegistry(
282
+ party_ids=party_ids,
283
+ secrets=pairwise_secrets(parties),
284
+ size=size,
285
+ scale=1.0,
286
+ )
287
+ masks = registry.all_masks()
288
+ rng = np.random.default_rng(17)
289
+ local_sums = {
290
+ index: rng.normal(0.0, 1.0, size=size).astype(np.float32)
291
+ for index in range(len(party_ids))
292
+ }
293
+ truth = sum(local_sums.values())
294
+
295
+ from iroh_tinker.comm.transport import (
296
+ CompressedTransport,
297
+ LocalNetwork,
298
+ Transport,
299
+ )
300
+
301
+ from .masking import MaskedTransport, exchange_party_sums
302
+
303
+ def run(compress: bool) -> np.ndarray:
304
+ network = LocalNetwork(len(party_ids))
305
+ transports: dict[int, MaskedTransport] = {}
306
+ for rank in range(len(party_ids)):
307
+ base: Transport = network.transport(rank)
308
+ if compress:
309
+ base = CompressedTransport(base, scheme="8bit", block_size=128)
310
+ transports[rank] = MaskedTransport(
311
+ base,
312
+ party_id=party_ids[rank],
313
+ party_of_rank=list(party_ids),
314
+ mask=masks[party_ids[rank]],
315
+ allow_lossy=True,
316
+ )
317
+ results = exchange_party_sums(
318
+ transports=transports,
319
+ party_of_rank=list(party_ids),
320
+ local_sums=local_sums,
321
+ masks=masks,
322
+ )
323
+ return results[0]
324
+
325
+ def relative(value: np.ndarray) -> float:
326
+ error = float(np.linalg.norm(value - truth))
327
+ return error / max(float(np.linalg.norm(truth)), 1e-9)
328
+
329
+ exact_error = relative(run(compress=False))
330
+ lossy_error = relative(run(compress=True))
331
+ payload = {
332
+ "exact_relative_error": exact_error,
333
+ "lossy_relative_error": lossy_error,
334
+ "lossy_is_worse_by": lossy_error / max(exact_error, 1e-18),
335
+ "decision": "the masked path must be lossless",
336
+ }
337
+ if args.json:
338
+ _emit_json(payload)
339
+ return 0
340
+ print(f"exact fp32 wire : relative error {exact_error:.3e}")
341
+ print(f"8-bit wire : relative error {lossy_error:.3e}")
342
+ print(f"lossy is {payload['lossy_is_worse_by']:.0f}x worse -> {payload['decision']}")
343
+ return 0
344
+
345
+
346
+ def _emit_json(payload: Any) -> None:
347
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
348
+
349
+
350
+ def main(argv: Sequence[str] | None = None) -> int:
351
+ parser = build_parser()
352
+ args = parser.parse_args(list(argv) if argv is not None else None)
353
+ try:
354
+ if args.command == "init":
355
+ return _cmd_init(args)
356
+ if args.command == "manifest":
357
+ return _cmd_manifest(args)
358
+ if args.command == "mesh":
359
+ return _cmd_mesh(args)
360
+ if args.command == "shards":
361
+ return _cmd_shards(args)
362
+ if args.command == "dp":
363
+ return _cmd_dp(args)
364
+ if args.command == "spike":
365
+ return _cmd_spike(args)
366
+ except (FileExistsError, FileNotFoundError, ValueError, RuntimeError) as exc:
367
+ if getattr(args, "json", False):
368
+ _emit_json({"error": str(exc)})
369
+ else:
370
+ print(f"error: {exc}", file=sys.stderr)
371
+ return 2
372
+ parser.error(f"unknown command {args.command!r}")
373
+ return 2
374
+
375
+
376
+ if __name__ == "__main__": # pragma: no cover
377
+ raise SystemExit(main())
fedmesh/dp.py ADDED
@@ -0,0 +1,163 @@
1
+ """Differential privacy for the federated update.
2
+
3
+ fedmesh does not implement DP in the first version of the training loop; this
4
+ module provides the pieces a training run needs and, importantly, a budget
5
+ accountant that *refuses* to go over budget.
6
+
7
+ The privacy guarantee is the standard Gaussian mechanism: per-example
8
+ gradients are clipped to a bounded L2 norm and Gaussian noise proportional to
9
+ that norm is added before the update leaves the party. The accountant uses
10
+ the basic (not the tighter Rényi) composition bound, so the reported epsilon
11
+ is an upper bound.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ __all__ = [
22
+ "DPConfig",
23
+ "PrivacyAccountant",
24
+ "PrivacyBudgetError",
25
+ "add_gaussian_noise",
26
+ "clip_by_l2_norm",
27
+ "epsilon_for_gaussian_mechanism",
28
+ ]
29
+
30
+
31
+ class PrivacyBudgetError(RuntimeError):
32
+ """Raised when a step would exceed the privacy budget."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DPConfig:
37
+ """Parameters of the differential privacy mechanism."""
38
+
39
+ clip_norm: float = 1.0
40
+ noise_multiplier: float = 10.0
41
+ target_epsilon: float = 8.0
42
+ target_delta: float = 1e-5
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.clip_norm <= 0:
46
+ raise ValueError("clip_norm must be positive")
47
+ if self.noise_multiplier <= 0:
48
+ raise ValueError("noise_multiplier must be positive")
49
+ if not 0 < self.target_delta < 1:
50
+ raise ValueError("target_delta must be in (0, 1)")
51
+ if self.target_epsilon <= 0:
52
+ raise ValueError("target_epsilon must be positive")
53
+
54
+
55
+ def clip_by_l2_norm(vector: np.ndarray, max_norm: float) -> np.ndarray:
56
+ """Clip a vector to ``max_norm`` in L2, preserving direction."""
57
+ flat = np.asarray(vector, dtype=np.float64).reshape(-1)
58
+ norm = float(np.linalg.norm(flat))
59
+ if norm <= max_norm or norm == 0.0:
60
+ return flat.astype(np.float32)
61
+ return (flat * (max_norm / norm)).astype(np.float32)
62
+
63
+
64
+ def add_gaussian_noise(
65
+ vector: np.ndarray,
66
+ noise_multiplier: float,
67
+ rng: np.random.Generator,
68
+ ) -> np.ndarray:
69
+ """Add Gaussian noise with the given multiplier of the sensitivity."""
70
+ flat = np.asarray(vector, dtype=np.float64).reshape(-1)
71
+ noise = rng.normal(0.0, noise_multiplier, size=flat.shape)
72
+ return (flat + noise).astype(np.float32)
73
+
74
+
75
+ def epsilon_for_gaussian_mechanism(
76
+ noise_multiplier: float,
77
+ clip_norm: float,
78
+ delta: float,
79
+ ) -> float:
80
+ """The basic (upper bound) epsilon of the Gaussian mechanism.
81
+
82
+ Uses the standard bound ``eps = C * sqrt(2 * ln(1.25 / delta)) / sigma``
83
+ for the Gaussian mechanism with sensitivity ``C`` and noise ``sigma *
84
+ C``. It is not the tightest bound available, but it is the one that is
85
+ easy to audit and it never understates the privacy loss.
86
+ """
87
+ if not 0 < delta < 1:
88
+ raise ValueError("delta must be in (0, 1)")
89
+ if noise_multiplier <= 0:
90
+ raise ValueError("noise_multiplier must be positive")
91
+ return clip_norm * math.sqrt(2.0 * math.log(1.25 / delta)) / noise_multiplier
92
+
93
+
94
+ @dataclass
95
+ class PrivacyAccountant:
96
+ """Tracks the privacy budget spent by a run.
97
+
98
+ Basic composition: epsilon adds up, delta combines as
99
+ ``1 - prod(1 - delta_i)``.
100
+ """
101
+
102
+ target_epsilon: float
103
+ target_delta: float
104
+ _epsilon: float = 0.0
105
+ _delta_product: float = 1.0
106
+ steps: int = 0
107
+ history: list[dict[str, Any]] = field(default_factory=list)
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.target_epsilon <= 0:
111
+ raise ValueError("target_epsilon must be positive")
112
+ if not 0 < self.target_delta < 1:
113
+ raise ValueError("target_delta must be in (0, 1)")
114
+
115
+ @property
116
+ def epsilon(self) -> float:
117
+ return self._epsilon
118
+
119
+ @property
120
+ def delta(self) -> float:
121
+ return 1.0 - self._delta_product
122
+
123
+ @property
124
+ def remaining(self) -> float:
125
+ return max(0.0, self.target_epsilon - self._epsilon)
126
+
127
+ def spend(self, epsilon: float, delta: float) -> None:
128
+ """Record a step, refusing to exceed the budget."""
129
+ if epsilon < 0 or delta < 0:
130
+ raise ValueError("epsilon and delta must be non-negative")
131
+ if epsilon + self._epsilon > self.target_epsilon + 1e-12:
132
+ raise PrivacyBudgetError(
133
+ f"step would spend epsilon {epsilon:.4f} on top of "
134
+ f"{self._epsilon:.4f}, exceeding the budget {self.target_epsilon:.4f}"
135
+ )
136
+ if self.delta + delta > self.target_delta + 1e-15:
137
+ raise PrivacyBudgetError(
138
+ f"step would spend delta {delta:.3e} on top of {self.delta:.3e}, "
139
+ f"exceeding the budget {self.target_delta:.3e}"
140
+ )
141
+ self._epsilon += epsilon
142
+ self._delta_product *= 1.0 - delta
143
+ self.steps += 1
144
+ self.history.append({"step": self.steps, "epsilon": epsilon, "delta": delta})
145
+
146
+ def spend_step(self, config: DPConfig) -> float:
147
+ """Spend one step of the Gaussian mechanism described by ``config``."""
148
+ epsilon = epsilon_for_gaussian_mechanism(
149
+ config.noise_multiplier, config.clip_norm, config.target_delta
150
+ )
151
+ self.spend(epsilon, config.target_delta)
152
+ return epsilon
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ return {
156
+ "target_epsilon": self.target_epsilon,
157
+ "target_delta": self.target_delta,
158
+ "epsilon": self.epsilon,
159
+ "delta": self.delta,
160
+ "steps": self.steps,
161
+ "remaining_epsilon": self.remaining,
162
+ "history": list(self.history),
163
+ }