diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 9579aae..b83531d 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -30,7 +30,7 @@ | BL-21 | Daemon | `dind` persists no logs to disk | `configure_logging()` (`dincli/dind/logging.py` β€” lands with [PR #32](https://github.com/InfiniteZeroFoundation/DevNet/pull/32), not yet on `develop`) attaches a plain `StreamHandler` (stderr) with the `JsonFormatter` β€” no `RotatingFileHandler`, nothing written into `StateDirs` despite it already holding `jobs.db`/`dind.pid`/`preferences.json`. Under systemd (journald) or a container log driver the supervisor persists logs; run directly in a terminal β€” the documented and tested path β€” stderr is gone when the session closes, leaving nothing to `grep` for "why did my node skip a job three hours ago". For an always-on daemon aimed at non-expert operators (issue #21), `dind` should own a rotating log file under its own state dir. | P4-1.1 (operability, before `dind` is load-bearing) | PR #32 code review finding No. 6, Jul 30, 2026 | 🟒 Fixed on the PR branch, unmerged β€” [PR #32](https://github.com/InfiniteZeroFoundation/DevNet/pull/32)'s `dincli/dind/logging.py` adds a `RotatingFileHandler` in the state directory when `state_dir` is given, alongside stderr. Lands with PR #32. Checked 2026-09-30 | | BL-22 | Daemon / SDK | `NonceManager._instances` never evicted | `NonceManager` (`dincli/sdk/tx.py` β€” lands with [PR #31](https://github.com/InfiniteZeroFoundation/DevNet/pull/31), not yet on `develop`) keys its per-`(chain_id, address)` cache in a class-level `_instances` dict with no eviction, so it grows for the process lifetime. Correct and irrelevant for the CLI (one invocation per process), but it's process-wide mutable state β€” worth a look once `dind` runs long-lived. Already caused real test pollution: reservations leaked between test classes until each cleared `NonceManager._instances` in `setup_method`. | P4-1.1 (before `dind` runs long-lived) | `task_300726_8` implementation note, Jul 31, 2026 | 🟒 Fixed on the PR branch, unmerged β€” [PR #31](https://github.com/InfiniteZeroFoundation/DevNet/pull/31)'s `dincli/sdk/tx.py` bounds `_instances` at `_MAX_INSTANCES` (strong cache, with live instances tracked separately so an in-flight reservation isn't dropped). Lands with PR #31. Checked 2026-09-30 | | BL-23 | DP / Privacy (cache_model_0) | `clip_scope: global` default paired with an unreviewed `clipping_norm: 1.0` may over-clip real weights | [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) (`fix/dp-sensitivity`) correctly ties noise to the clip (`noise_scale = noise_multiplier * clipping_norm`) and defaults `clip_scope` to `global` so the clip actually bounds the combined L2 norm the noise is calibrated against β€” the sensitivity-calibration fix itself is sound (`per_layer` only bounds the combined norm at `clipping_norm * sqrt(n)`, not `clipping_norm`, so pairing it with the new noise formula would under-noise as tensor count grows). But `cache_model_0/manifest.json`'s `clipping_norm: 1.0` was never revisited: under the old `per_layer` scope it bounded each tensor independently to norm 1.0; under `global` it bounds the *entire model's combined* L2 norm to 1.0, a much tighter constraint for any model with more than one floating tensor (`sqrt(Ξ£β€–tensor_iβ€–Β²)` grows with tensor count even if no individual tensor's norm changes). No test in PR #109 exercises this against `cache_model_0`'s real trained `state_dict` (the new tests use synthetic tensors), so it's unverified whether the new default clips β€” and thus degrades β€” the reference model's weights far more aggressively than before. Follow-up: run `apply_dp_mechanism`/`clip_state_dict` against `cache_model_0`'s actual trained weights under the new default and retune `clipping_norm` if the combined-norm bound turns out to be destructive. | P3 (verify before/at merge) | PR #109 review, Sep 8, 2026 | βœ… Fixed β€” PR No. 263 | -| BL-24 | DP / Privacy (cache_model_0) | F4 β€” Laplace noise calibrated against an L2 clip, not the L1 sensitivity it's normally calibrated against | `post_training_laplace`'s `laplace_scale` is scaled by the same `clipping_norm` as the Gaussian mechanisms (`clip_state_dict`'s L2-norm clip), but the Laplace mechanism's textbook privacy guarantee is calibrated against L1 sensitivity, not L2. [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) flagged this explicitly rather than silently assuming it's fine (`Documentation/technical/services/clients.md:210-212`: "Laplace noise calibrated against an L2 clip is an open question tracked separately"), and [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99)'s F4 (`Documentation/technical/audits/dp-mechanism-review.md` Β§F4) called the structural mismatch confirmed but left the exact magnitude/severity for a DP specialist's sign-off β€” explicitly excluded from #99's scope rather than fixed. [#119](https://github.com/InfiniteZeroFoundation/DevNet/pull/119) (which closed #99's remaining F8 items) left F4 untouched, so nothing currently tracks it now that #99 is closed. Needs either a DP specialist's opinion that L2-calibrated Laplace is acceptable as-is (and the open-question note downgraded to an explanation), or a fix (e.g. recalibrating against an actual L1 clip, or reclassifying `post_training_laplace` as experimental alongside F5's `per_layer` treatment) before any Ξ΅ is claimed for it. | P3/P4 (needs DP specialist input first) | [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99) F4 (excluded from scope), `Documentation/technical/audits/dp-mechanism-review.md` Β§F4, Sep 10, 2026 | 🟑 Partially addressed β€” [issue #279](https://github.com/InfiniteZeroFoundation/DevNet/issues/279) Track A reclassified both `post_training_gaussian` and `post_training_laplace` as experimental (F5's recommended fix): `resolve_dp_config` now logs a warning on selection, and `Documentation/technical/services/clients.md` labels both accordingly. F4's actual L2-vs-L1 mismatch is still unfixed β€” needs a DP specialist's recommendation per issue #279 Track B before `post_training_laplace` can carry any Ξ΅ | +| BL-24 | DP / Privacy (cache_model_0) | F4 β€” Laplace noise calibrated against an L2 clip, not the L1 sensitivity it's normally calibrated against | `post_training_laplace`'s `laplace_scale` is scaled by the same `clipping_norm` as the Gaussian mechanisms (`clip_state_dict`'s L2-norm clip), but the Laplace mechanism's textbook privacy guarantee is calibrated against L1 sensitivity, not L2. [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) flagged this explicitly rather than silently assuming it's fine (`Documentation/technical/services/clients.md:210-212`: "Laplace noise calibrated against an L2 clip is an open question tracked separately"), and [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99)'s F4 (`Documentation/technical/audits/dp-mechanism-review.md` Β§F4) called the structural mismatch confirmed but left the exact magnitude/severity for a DP specialist's sign-off β€” explicitly excluded from #99's scope rather than fixed. [#119](https://github.com/InfiniteZeroFoundation/DevNet/pull/119) (which closed #99's remaining F8 items) left F4 untouched, so nothing currently tracks it now that #99 is closed. Needs either a DP specialist's opinion that L2-calibrated Laplace is acceptable as-is (and the open-question note downgraded to an explanation), or a fix (e.g. recalibrating against an actual L1 clip, or reclassifying `post_training_laplace` as experimental alongside F5's `per_layer` treatment) before any Ξ΅ is claimed for it. | P3/P4 (needs DP specialist input first) | [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99) F4 (excluded from scope), `Documentation/technical/audits/dp-mechanism-review.md` Β§F4, Sep 10, 2026 | βœ… Resolved (qualified) β€” [issue #279](https://github.com/InfiniteZeroFoundation/DevNet/issues/279) Track A reclassified both `post_training_gaussian` and `post_training_laplace` as experimental (F5's recommended fix): `resolve_dp_config` now logs a warning on selection, and `Documentation/technical/services/clients.md` labels both accordingly. Track B then adds `update_laplace`, fixing F4's L2-vs-L1 mismatch: it mirrors `update_gaussian`'s delta-level clipping, but clips the delta's **L1** norm instead of L2, so `clipping_norm` is the norm the Laplace mechanism's noise is actually calibrated against. `post_training_laplace` itself remains unchanged and experimental (F5). Under a stated neighbor definition (client-level DP: one neighbor includes a client's update, the other replaces it with a zero contribution), this gives an L1 sensitivity of `clipping_norm` and an (Ξ΅, 0)-DP guarantee at `laplace_scale = clipping_norm / Ξ΅` under that definition β€” not yet specialist-reviewed, and non-floating (integer) buffers in the state dict still pass through unprivatized, presumed non-sensitive | | BL-25 | Tooling / reference model (cache_model_0) | `cache_model_0/abis/` task-contract artifacts are stale vs. `foundry/src` | `cache_model_0/abis/DINTaskCoordinator.json` and `DINTaskAuditor.json` are full hardhat artifacts (abi + bytecode) last regenerated 2026-06-09, missing 67 ABI entries present in `foundry/src` for the coordinator (104 vs 170) and 96 for the auditor (68 vs 158), with pre-foundry constructors. [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) regenerated the six bundled `dincli/abis/` files from `foundry/out` but deliberately left these alone: [cache_model_0/manifest.json](../cache_model_0/manifest.json)'s `task_contracts` block pins each artifact to an IPFS CID, records `"type": "hardhat-artifact"` and a hardhat/cancun/optimizer-off `compilation` block, and carries deployed addresses, and dincli resolves task artifacts by CID. Regenerating the files locally would leave the manifest describing artifacts it no longer points to. Needs one pass: regenerate both from `foundry/out` (with bytecode, e.g. `dincli system dump-abi --bytecode --output cache_model_0/abis`), update the manifest's artifact type and compilation metadata to foundry, re-pin to IPFS and update the CIDs. Addresses only change on a redeploy. | P3/P4 | PR #171 merge (2026-09-26): out of scope for the PR's ABI refresh | 🟑 Fix in review β€” [PR #261](https://github.com/InfiniteZeroFoundation/DevNet/pull/261) regenerates both ABIs from `foundry/out` and updates `cache_model_0/manifest.json` (changes requested, Oct 22); tracked in [#246](https://github.com/InfiniteZeroFoundation/DevNet/issues/246) | | BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw β€” repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` are evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed is already public, and Fisher-Yates re-rolls the whole assignment on any pool-size change β€” so a validator can unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). Ready-made fix for the pool-reshaping gap: adopt `_assignFreshSubgroup`'s pattern β€” shuffle the fixed per-GI registration list (`dinAggregators[_GI]` / the auditor equivalent) with the locked seed, then skip inactive validators while filling seats, so an unstake only forfeits the leaver's own seat instead of re-rolling everyone else's. | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | 🟑 Partially mitigated β€” the decline-to-lock re-roll is narrowed by a validator-side lock in dincli, added at the PR #191 merge (`dincli aggregator lock-seed` / `dincli auditor lock-seed`, and automatically in `show-t1-batches`/`show-t2-batches`/`lms-evaluation show-batch` before batches exist): any validator online after the seed block locks it, so the owner only gets a re-roll if nobody else is. A contract-side cap on re-anchors was considered and deferred: with no GI-abort path, a GI capped out would be stuck forever (`endGI` needs `AggregatorsSlashed`, `startGI` needs `GIended`), locking validators' registration slots and the reward pool β€” it needs a GI-abort design (who may abort, where the reward pool goes) first. Pool-reshaping gap unaddressed. Documented as residuals in the PR (`DINTaskCoordinator.md` Β§6.3). Sequencer-trust residual (shared with BL-11) tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); the pool-reshaping gap is now [#243](https://github.com/InfiniteZeroFoundation/DevNet/issues/243) (fix in review, [PR #253](https://github.com/InfiniteZeroFoundation/DevNet/pull/253)); the re-roll-by-declining-to-lock gap has no dedicated issue yet, same as BL-11's own un-issued residual (2) | | BL-27 | dincli (model owner) | `dincli model-owner deploy` still uses the pre-foundry task-contract constructors | `dincli/cli/modelownerd/deploy.py` sends `DINTaskCoordinator.constructor(stake)` (`:35`) and `DINTaskAuditor.constructor(stake, coordinator)` (`:77`), but `foundry/src` takes `(stake, modelId)` and `(stake, coordinator, modelId)`. Against foundry artifacts, which are the bundled `dincli/abis/` since PR #171, the deploy fails at ABI encoding, so model owners can't deploy task contracts from dincli. The `tests/dincli/` harness hides this because it deploys task contracts from **hardhat** artifacts. The fix also has to settle where `modelId` comes from: the registry assigns it only at approval, after the contracts exist (`DINTaskCoordinator.md` Β§10 No. 4). | P3 (before any model-owner onboarding on DevNet 3.0) | PR No. 204 review, Sep 30, 2026 (caveats `DINTaskCoordinator.md` Β§10 No. 10, `DINTaskAuditor.md` Β§13 No. 8) | 🟑 Fix in review β€” [PR #261](https://github.com/InfiniteZeroFoundation/DevNet/pull/261) adds `--model-id` and passes it to both constructors (changes requested, Oct 22); tracked in [#223](https://github.com/InfiniteZeroFoundation/DevNet/issues/223) Part 1 | diff --git a/Documentation/technical/services/clients.md b/Documentation/technical/services/clients.md index dde15f0..56b40d7 100644 --- a/Documentation/technical/services/clients.md +++ b/Documentation/technical/services/clients.md @@ -135,7 +135,8 @@ Behavior: *absolute trained weights*, not the per-client *update*, so it does not bound the quantity an attacker observing successive rounds actually cares about. `resolve_dp_config` logs a warning whenever this mechanism is selected. -`update_gaussian` is the mechanism with a defined sensitivity bound. +`update_gaussian` and `update_laplace` are the mechanisms with a defined +sensitivity bound. Use when: @@ -156,7 +157,9 @@ sized against the L2 `clipping_norm` while the Laplace mechanism's guarantee is normally calibrated against L1 sensitivity β€” for model weights the gap between the two can be as large as √d (d = parameter count). Neither gap is fixed here; `resolve_dp_config` logs a warning whenever this mechanism is -selected. +selected. `update_laplace` (below) is the correctly-calibrated counterpart +that privatizes the per-client update instead of absolute weights and clips +against the right (L1) norm for the Laplace mechanism. Use when: @@ -168,7 +171,7 @@ Use when: Behavior: 1. compute the local update relative to the starting model for the round; -2. clip that delta; +2. L2-clip that delta; 3. add Gaussian noise to the delta; 4. reconstruct a full weight file from `reference + noisy_delta`; 5. upload the reconstructed private `state_dict`. @@ -178,17 +181,65 @@ Why this matters: - it is closer to the federated-learning concept of privatizing an update rather than raw final weights; - it still preserves compatibility with the current aggregator, which expects full model weights rather than deltas. +### `update_laplace` + +Behavior: + +1. compute the local update relative to the starting model for the round; +2. **L1**-clip that delta (see below for why this differs from the other three mechanisms); +3. add Laplace noise to the delta; +4. reconstruct a full weight file from `reference + noisy_delta`; +5. upload the reconstructed private `state_dict`. + +Why this matters (issue #279 Track B, dp-mechanism-review.md F4): the Laplace +mechanism's privacy guarantee is calibrated against **L1** sensitivity, not +L2. `post_training_laplace` reuses the same L2 clip as the Gaussian +mechanisms, which for model weights can understate the true L1 sensitivity by +a factor of up to √d (d = parameter count) β€” the √d figure is a worst-case +norm-conversion bound for a given dimension, not a measured typical ratio or +a demonstrated privacy loss on this model. `update_laplace` clips the delta +against its L1 norm instead, so `clipping_norm` bounds the clipped delta's L1 +norm, same shape as `update_gaussian` but calibrated correctly for the +Laplace distribution. + +**What that sensitivity bound actually guarantees, and what it doesn't.** +"`clipping_norm` is the sensitivity bound" is only meaningful under a stated +neighbor definition. Under client-level DP β€” neighboring datasets differ by +one client's entire contribution, i.e. one neighbor has that client's update +and the other replaces it with a zero delta β€” the L1 distance between the two +possible outputs is at most `clipping_norm` (the clipped delta's own L1 norm +bound), giving an L1 sensitivity of `clipping_norm` and an (Ξ΅, 0)-DP guarantee +at `laplace_scale = clipping_norm / Ξ΅` under that definition. This has not +had DP-specialist review, and it covers only the floating tensors that are +actually clipped and noised β€” non-floating (integer) buffers in the +`state_dict` pass through unchanged (see **Clipping Behavior** below) and are +presumed non-sensitive, not privatized by this mechanism. + +Use when: + +- you want a non-Gaussian noise option with a sensitivity bound that actually + matches the Laplace mechanism's calibration; +- `post_training_laplace` remains available only as a labelled experimental + baseline (see dp-mechanism-review.md F5) β€” this is its update-level, + correctly-calibrated counterpart. + ## Clipping Behavior The service supports two clipping scopes: -- `global` (default): one combined L2 norm is computed across all floating tensors and one shared scale factor is applied. +- `global` (default): one combined norm is computed across all floating tensors and one shared scale factor is applied. - `per_layer`: each floating tensor is clipped independently. +The norm type depends on the mechanism: `update_laplace` clips against the L1 +norm (the sensitivity notion Laplace noise is calibrated against); every +other mechanism clips against the L2 norm. + `global` is the default because it is the only scope whose clip bounds the whole update at `clipping_norm`. Clipping `n` tensors independently to -`clipping_norm` bounds the combined L2 norm at `clipping_norm * sqrt(n)`, so -under `per_layer` the quantity the noise is calibrated against grows with model +`clipping_norm` bounds the combined norm at `clipping_norm * sqrt(n)` for L2 +mechanisms, or `clipping_norm * n` for `update_laplace`'s L1 clip (L1 is +exactly additive across tensors, no square root) β€” either way, under +`per_layer` the quantity the noise is calibrated against grows with model depth. `per_layer` remains available for utility, but it carries no formal sensitivity bound and should be treated as experimental. @@ -200,10 +251,10 @@ which produced noise with nothing bounding the quantity it was meant to hide. Both `resolve_dp_config` and `apply_dp_mechanism` now reject it, so a hand-built config cannot bypass the manifest-level check. -`post_training_laplace` requires an explicit `laplace_scale`. It previously -defaulted to whatever `noise_multiplier` was set to, which silently produced a -scale nobody chose, since the two parameters carry different units and are -calibrated differently. +`post_training_laplace` and `update_laplace` both require an explicit +`laplace_scale`. It previously defaulted to whatever `noise_multiplier` was +set to, which silently produced a scale nobody chose, since the two +parameters carry different units and are calibrated differently. **DP is off by default.** With no `dp` block, or `dp.enabled: false`, the client uploads raw trained weights. That is a deliberate devnet default, not an @@ -221,10 +272,17 @@ together. Loosening the clip widens the sensitivity and widens the noise to match, instead of leaving the noise fixed while the privacy level silently changes. -`laplace_scale` is scaled the same way. Note that Laplace noise calibrated -against an L2 clip is a known mismatch, since the Laplace mechanism is -normally calibrated against an L1 sensitivity β€” see `post_training_laplace` -above and [issue #279](https://github.com/InfiniteZeroFoundation/DevNet/issues/279). +`laplace_scale` is scaled the same way: `laplace_scale * clipping_norm`. For +`post_training_laplace` this reuses an L2 clip, which is a known mismatch +(issue #279 F4) since the Laplace mechanism is calibrated against L1 +sensitivity β€” see `post_training_laplace` above and +[issue #279](https://github.com/InfiniteZeroFoundation/DevNet/issues/279); +`post_training_laplace` is an experimental baseline only (F5) and makes no +claim this resolves. `update_laplace` clips the delta against its L1 norm +instead, so `clipping_norm` there bounds the clipped delta's L1 norm for this +formula β€” see the qualification above (`update_laplace` section) on what +sensitivity bound that does and doesn't establish, and that it hasn't had +DP-specialist review. Only floating tensors are clipped/noised. Integer buffers or counters are copied through unchanged. diff --git a/cache_model_0/services/client.py b/cache_model_0/services/client.py index 61daa53..f4e44dc 100644 --- a/cache_model_0/services/client.py +++ b/cache_model_0/services/client.py @@ -34,14 +34,20 @@ console = Console() -# The service currently supports three manifest-selectable DP mechanisms: +# The service currently supports four manifest-selectable DP mechanisms: # - post_training_gaussian: perturb final weights directly with Gaussian noise # - post_training_laplace: perturb final weights directly with Laplace noise # - update_gaussian: perturb the local model delta, then reconstruct weights +# - update_laplace: L1-clip the local delta, perturb it with Laplace noise, +# then reconstruct weights (issue #279 Track B's F4 fix: mirrors +# update_gaussian's delta-level clipping, but with the L1 clip the Laplace +# mechanism is actually calibrated against, rather than reusing the L2 clip +# post_training_laplace uses) SUPPORTED_DP_MECHANISMS = { "post_training_gaussian", "post_training_laplace", "update_gaussian", + "update_laplace", } # issue #279 (F4/F5, dp-mechanism-review.md): both post-training mechanisms @@ -63,6 +69,7 @@ "post-training-gaussian": "post_training_gaussian", "laplace": "post_training_laplace", "post-training-laplace": "post_training_laplace", + "update-laplace": "update_laplace", "update": "update_gaussian", "update-gaussian": "update_gaussian", } @@ -110,8 +117,17 @@ def add_laplace_noise(weights, scale): return weights + noise -def clip_weights(weights, clipping_norm): - """Clip a tensor to an L2 norm bound and return the clipped copy. +def _tensor_norm(weights, norm_type="l2"): + """L1 or L2 norm of a tensor, flattened. L1 is the sensitivity notion the + Laplace mechanism is actually calibrated against (issue #279 F4); L2 is + what every other mechanism here uses.""" + if norm_type == "l1": + return torch.sum(torch.abs(weights)) + return torch.norm(weights) + + +def clip_weights(weights, clipping_norm, norm_type="l2"): + """Clip a tensor to an L1 or L2 norm bound and return the clipped copy. This helper is used for per-layer clipping. If the tensor already fits inside the clipping radius, it is simply cloned and returned unchanged. @@ -120,7 +136,7 @@ def clip_weights(weights, clipping_norm): if clipping_norm <= 0: return weights.detach().clone() - norm = torch.norm(weights) + norm = _tensor_norm(weights, norm_type) if not torch.isfinite(norm) or norm <= clipping_norm: return weights.detach().clone() @@ -144,13 +160,18 @@ def clone_state_dict(state_dict): return cloned_state_dict -def clip_state_dict(state_dict, clipping_norm, clip_scope="per_layer"): +def clip_state_dict(state_dict, clipping_norm, clip_scope="per_layer", norm_type="l2"): """Clip all floating tensors in a state_dict. `clip_scope` controls whether clipping happens: - per_layer: each floating tensor is clipped independently. - global: one shared scale factor is computed across all floating tensors. + `norm_type` selects the sensitivity notion the bound is expressed in: + - l2 (default): used by the Gaussian mechanisms. + - l1: used by the Laplace mechanisms (issue #279 F4) -- the Laplace + mechanism's guarantee is calibrated against L1 sensitivity, not L2. + Non-floating tensors such as counters or integer buffers are copied through unchanged because noise/clipping semantics for them would be ill-defined. """ @@ -166,10 +187,14 @@ def clip_state_dict(state_dict, clipping_norm, clip_scope="per_layer"): ] if clip_scope == "global" and float_items: - # Compute one global L2 norm across all floating tensors, then scale - # every floating tensor by the same factor. - global_norm_sq = sum(torch.norm(value).item() ** 2 for _, value in float_items) - global_norm = global_norm_sq ** 0.5 + # Compute one global norm across all floating tensors, then scale + # every floating tensor by the same factor. L1 is exactly additive + # across tensors (no square root needed); L2 needs sum-of-squares. + if norm_type == "l1": + global_norm = sum(_tensor_norm(value, "l1").item() for _, value in float_items) + else: + global_norm_sq = sum(torch.norm(value).item() ** 2 for _, value in float_items) + global_norm = global_norm_sq ** 0.5 scale = min(1.0, clipping_norm / (global_norm + 1e-12)) for key, value in state_dict.items(): @@ -185,7 +210,7 @@ def clip_state_dict(state_dict, clipping_norm, clip_scope="per_layer"): for key, value in state_dict.items(): if torch.is_tensor(value): if torch.is_floating_point(value): - clipped_state_dict[key] = clip_weights(value, clipping_norm) + clipped_state_dict[key] = clip_weights(value, clipping_norm, norm_type) else: clipped_state_dict[key] = value.detach().clone() else: @@ -368,7 +393,8 @@ def resolve_dp_config(runtime=None): "no formal privacy guarantee. It clips absolute trained weights rather than " "per-client updates, so it does not bound what an attacker observing " "successive rounds can learn (dp-mechanism-review.md findings F4/F5). " - "Use 'update_gaussian' for a mechanism with a defined sensitivity bound." + "Use 'update_gaussian' or 'update_laplace' for a mechanism with a defined " + "sensitivity bound." "[/yellow]" ) @@ -378,12 +404,13 @@ def resolve_dp_config(runtime=None): parameters.setdefault("noise_multiplier", 0.5) parameters["clipping_norm"] = validate_clipping_norm(parameters["clipping_norm"]) - if dp_mechanism == "post_training_laplace": + if dp_mechanism in ("post_training_laplace", "update_laplace"): require_laplace_scale(parameters) # `global` is the only scope whose clip bounds the whole update at # `clipping_norm`. `per_layer` clips each of n tensors independently, so - # the combined L2 norm is bounded at clipping_norm * sqrt(n), and the - # noise added here does not account for n. + # the combined norm is bounded at clipping_norm * n (L1) or + # clipping_norm * sqrt(n) (L2), and the noise added here does not + # account for n. parameters.setdefault("clip_scope", "global") return { @@ -400,8 +427,14 @@ def apply_dp_mechanism(trained_state_dict, dp_config, reference_state_dict=None) Mechanism summary: - post_training_gaussian: clip final weights, then add Gaussian noise. - post_training_laplace: clip final weights, then add Laplace noise. - - update_gaussian: clip the local delta, add Gaussian noise to that delta, - then reconstruct a full weight file from the privatized update. + Carries no formal privacy guarantee (issue #279 F5) -- it clips + absolute weights rather than the per-client update. + - update_gaussian: L2-clip the local delta, add Gaussian noise to that + delta, then reconstruct a full weight file from the privatized update. + - update_laplace: L1-clip the local delta, add Laplace noise to that + delta, then reconstruct a full weight file from the privatized update. + Unlike post_training_laplace, the clip here matches the sensitivity + notion the Laplace mechanism is calibrated against (issue #279 F4). """ mechanism = dp_config["mechanism"] @@ -418,7 +451,7 @@ def apply_dp_mechanism(trained_state_dict, dp_config, reference_state_dict=None) gaussian_scale = float(params.get("noise_multiplier", 0.5)) * clipping_norm laplace_scale = ( require_laplace_scale(params) * clipping_norm - if mechanism == "post_training_laplace" + if mechanism in ("post_training_laplace", "update_laplace") else 0.0 ) @@ -453,6 +486,23 @@ def apply_dp_mechanism(trained_state_dict, dp_config, reference_state_dict=None) ) return reconstruct_state_dict_from_delta(reference_state_dict, noisy_delta, trained_state_dict) + if mechanism == "update_laplace": + if reference_state_dict is None: + raise ValueError("update_laplace requires a reference_state_dict") + + # Same delta-level privatization as update_gaussian, but clipped + # against the L1 norm the Laplace mechanism's guarantee is actually + # calibrated against (issue #279 F4), instead of reusing the L2 clip + # post_training_laplace uses. + local_delta = compute_state_dict_delta(trained_state_dict, reference_state_dict) + clipped_delta = clip_state_dict(local_delta, clipping_norm, clip_scope, norm_type="l1") + noisy_delta = add_noise_to_state_dict( + clipped_delta, + noise_kind="laplace", + noise_scale=laplace_scale, + ) + return reconstruct_state_dict_from_delta(reference_state_dict, noisy_delta, trained_state_dict) + raise ValueError(f"Unsupported DP mechanism '{mechanism}'") @@ -521,7 +571,8 @@ def train_client_model( console.print("Initial model loaded and weights initialized from GM") # Capture the exact starting point before local optimization so - # `update_gaussian` can privatize the model delta relative to this state. + # `update_gaussian`/`update_laplace` can privatize the model delta + # relative to this state. reference_state_dict = clone_state_dict(model_architecture.state_dict()) # Standard local training setup for the reference MNIST model. diff --git a/tests/test_cache_client_dp.py b/tests/test_cache_client_dp.py index 0f864f1..308a274 100644 --- a/tests/test_cache_client_dp.py +++ b/tests/test_cache_client_dp.py @@ -207,16 +207,20 @@ def test_laplace_noise_is_finite_over_real_sampling(): "post_training_gaussian", "post_training_laplace", "update_gaussian", + "update_laplace", ] def build_small_state_dict(num_elements=20000): - """Build a state_dict whose L2 norm sits well inside every clipping norm - used below, so clipping is a no-op and the only difference between input - and output is the noise itself.""" + """Build a state_dict whose L1 *and* L2 norm sit well inside every + clipping norm used below, so clipping is a no-op and the only difference + between input and output is the noise itself. The L1 norm is the binding + constraint here (it is `num_elements * magnitude`, with no sqrt to shrink + it), which is why the per-element magnitude is much smaller than the L2 + case alone would need.""" return OrderedDict( { - "weight": torch.full((num_elements,), 1e-3, dtype=torch.float32), + "weight": torch.full((num_elements,), 1e-6, dtype=torch.float32), "counter": torch.tensor([7], dtype=torch.long), } ) @@ -369,6 +373,143 @@ def combined_norm(candidate_state_dict): assert combined_norm(global_state_dict) == pytest.approx(clipping_norm, rel=1e-4) +def test_global_clip_scope_bounds_the_combined_l1_norm(): + """Same composition gap as the L2 case above, but for norm_type='l1' + (used by update_laplace, issue #279 F4): clipping n tensors independently + to L1 norm `clipping_norm` bounds the combined L1 norm at + `clipping_norm * n` (no sqrt -- L1 is exactly additive across tensors), + not at `clipping_norm`. Only `global` produces the L1 bound the Laplace + mechanism is actually calibrated against.""" + client = load_client_module() + num_tensors = 16 + clipping_norm = 1.0 + state_dict = OrderedDict( + { + f"layer_{index}.weight": torch.full((4,), 1.0, dtype=torch.float32) + for index in range(num_tensors) + } + ) + + def combined_l1_norm(candidate_state_dict): + return sum(torch.sum(torch.abs(value)).item() for value in candidate_state_dict.values()) + + per_layer_state_dict = client.clip_state_dict(state_dict, clipping_norm, "per_layer", norm_type="l1") + global_state_dict = client.clip_state_dict(state_dict, clipping_norm, "global", norm_type="l1") + + assert combined_l1_norm(per_layer_state_dict) == pytest.approx( + clipping_norm * num_tensors, rel=1e-4 + ) + assert combined_l1_norm(global_state_dict) == pytest.approx(clipping_norm, rel=1e-4) + + +def test_update_laplace_preserves_weights_when_noise_is_zero(): + client = load_client_module() + reference_state_dict = OrderedDict( + { + "weight": torch.tensor([0.2, 0.4], dtype=torch.float32), + "counter": torch.tensor([1], dtype=torch.long), + } + ) + trained_state_dict = OrderedDict( + { + "weight": torch.tensor([0.8, 1.0], dtype=torch.float32), + "counter": torch.tensor([2], dtype=torch.long), + } + ) + dp_config = { + "enabled": True, + "mode": "afterTraining", + "mechanism": "update_laplace", + "parameters": { + "clipping_norm": 100.0, + "laplace_scale": 0.0, + "clip_scope": "global", + }, + } + + private_state_dict = client.apply_dp_mechanism( + trained_state_dict, + dp_config, + reference_state_dict=reference_state_dict, + ) + + assert torch.equal(private_state_dict["weight"], trained_state_dict["weight"]) + assert torch.equal(private_state_dict["counter"], trained_state_dict["counter"]) + + +def test_update_laplace_requires_a_reference_state_dict(): + client = load_client_module() + trained_state_dict = build_small_state_dict() + dp_config = { + "enabled": True, + "mode": "afterTraining", + "mechanism": "update_laplace", + "parameters": {"clipping_norm": 1.0, "laplace_scale": 0.25}, + } + + with pytest.raises(ValueError, match="reference_state_dict"): + client.apply_dp_mechanism(trained_state_dict, dp_config) + + +def test_update_laplace_requires_an_explicit_scale(): + """Same requirement as post_training_laplace -- update_laplace's noise is + also Laplace-distributed, so it needs its own scale rather than + inheriting noise_multiplier.""" + client = load_client_module() + trained_state_dict = build_small_state_dict() + reference_state_dict = OrderedDict( + { + "weight": torch.zeros_like(trained_state_dict["weight"]), + "counter": torch.tensor([7], dtype=torch.long), + } + ) + dp_config = { + "enabled": True, + "mode": "afterTraining", + "mechanism": "update_laplace", + "parameters": {"clipping_norm": 1.0, "noise_multiplier": 0.25}, + } + + with pytest.raises(ValueError, match="laplace_scale"): + client.apply_dp_mechanism(trained_state_dict, dp_config, reference_state_dict=reference_state_dict) + + +def test_resolve_dp_config_requires_laplace_scale_for_update_laplace(): + client = load_client_module() + runtime = DummyRuntime( + { + "dp": { + "enabled": True, + "mode": "afterTraining", + "mechanism": "update_laplace", + "parameters": {"clipping_norm": 1.0}, + }, + } + ) + + with pytest.raises(ValueError, match="laplace_scale"): + client.resolve_dp_config(runtime=runtime) + + +def test_resolve_dp_config_does_not_warn_for_update_laplace(capsys): + client = load_client_module() + runtime = DummyRuntime( + { + "dp": { + "enabled": True, + "mode": "afterTraining", + "mechanism": "update_laplace", + "parameters": {"clipping_norm": 1.0, "laplace_scale": 0.25}, + }, + } + ) + + client.resolve_dp_config(runtime=runtime) + + captured = capsys.readouterr() + assert "experimental" not in captured.out.lower() + + def test_resolve_dp_config_defaults_clip_scope_to_global(): """A manifest that does not set `clip_scope` gets the scope whose clip matches the sensitivity the noise assumes.""" @@ -796,3 +937,58 @@ def test_update_gaussian_delta_norm_fits_clipping_norm_reference(): ) ) assert clipped_delta_norm == pytest.approx(delta_norm, rel=1e-4) + + +def test_update_laplace_binding_clipping_through_apply_dp_mechanism(): + """End-to-end through apply_dp_mechanism where the delta exceeds global L1 limit. + + Removing norm_type="l1" from the update_laplace branch would fall back to + L2 clipping and leave a regression undetected by clip_state_dict-only tests. + """ + client = load_client_module() + torch = __import__("torch") + + # Nonzero reference so the test also exercises reconstruct_state_dict_from_delta + # adding the clipped delta *onto* the reference, not just returning the delta. + reference_state_dict = { + "w": torch.ones(10) * 0.5, + "b": torch.ones(5) * -0.25, + "counter": torch.tensor(7, dtype=torch.long), + } + # Large deltas per tensor so global L1 sum exceeds clipping_norm + trained_state_dict = { + "w": reference_state_dict["w"] + 3.0, # delta L1 contribution 30 + "b": reference_state_dict["b"] + 2.0, # delta L1 contribution 10 + "counter": torch.tensor(7, dtype=torch.long), + } + dp_config = { + "enabled": True, + "mechanism": "update_laplace", + "parameters": { + "clipping_norm": 10.0, + "laplace_scale": 0.0, + "clip_scope": "global", + }, + } + + out = client.apply_dp_mechanism(trained_state_dict, dp_config, reference_state_dict=reference_state_dict) + + # Compute expected clipped delta with L1 + local_delta = client.compute_state_dict_delta(trained_state_dict, reference_state_dict) + # L1 norms + w_l1 = local_delta["w"].abs().sum().item() + b_l1 = local_delta["b"].abs().sum().item() + total_l1 = w_l1 + b_l1 + assert total_l1 > 10.0 # should trigger clipping + + scale = 10.0 / (total_l1 + 1e-12) + expected_delta_w = local_delta["w"] * scale + expected_delta_b = local_delta["b"] * scale + expected_out_w = reference_state_dict["w"] + expected_delta_w + expected_out_b = reference_state_dict["b"] + expected_delta_b + + torch.testing.assert_close(out["w"], expected_out_w) + torch.testing.assert_close(out["b"], expected_out_b) + # Integer buffer preserved + assert out["counter"].item() == 7 + assert out["counter"].dtype == torch.long