Recovered source transcript
1// Package federated holds the pure Go differential privacy accountant and aggregator boundary for the cross
2// tenant federated learning path (WS8). The whole package is DEAD CODE unless MM_FEDERATED_DP_ENABLED is set
3// and a real cohort of five or more live non test tenants exists. In tenant LIVE keeps it off; only the in
4// tenant producer and the human approved curation pipeline ship enabled. Nothing here samples noise or emits
5// a release today: the package has no non test caller, and the noise sampler is a HARD activation blocker
6// (see Noise below) so the naive float sampler can never run on an activated path.
7//
8// THREAT MODEL (central DP, trusted server). This is plain central differential privacy: the Go API tier
9// clips and noises each tenant aggregate and only stores the noised result, so the server sees the raw per
10// tenant vector before noising and is therefore inside the trusted computing base, inside one cloud
11// boundary. We do NOT claim the server never sees tenant data. A TEE or secure aggregation upgrade removes
12// that trust later; the Aggregator interface is the seam for that drop in. The inference plane stays
13// stateless and never sees a cross tenant aggregate.
14//
15// UNIT OF PRIVACY is the tenant. One live non test organization is one privacy unit; each tenant
16// contribution is L2 clipped to a fixed sensitivity C, so a tenant that contributes many rows cannot blow
17// past the claimed guarantee. Record level DP would be the wrong, weaker unit for a multi tenant story.
18//
19// FIPS POSTURE. No new crypto dependency. The accountant is pure float64 and shopspring decimal arithmetic,
20// no cgo. When the path is activated, the noise sampler must use only in module primitives behind the FIPS
21// validated boundary (crypto/rand under GOFIPS140), via a Mironov safe discrete or snapped Gaussian routine.
22// That routine is not written yet and MUST NOT be a naive float sampler (see the float DP attack note on
23// Noise). No dashes in any string or comment here, by project rule.
24package federated
25
26import (
27 "errors"
28 "math"
29
30 "github.com/shopspring/decimal"
31)
32
33// ErrNotImplemented is returned by the activation blocked noise sampler. It guarantees that the only noise
34// path that exists today returns an error rather than sampling, so no naive float Gaussian can ever run.
35var ErrNotImplemented = errors.New("federated: secure noise sampler is an activation blocker, not implemented")
36
37// ErrBudgetExceeded is returned by a Charge that would push the accumulated epsilon at the configured delta
38// over the budget. The release is refused and nothing is written.
39var ErrBudgetExceeded = errors.New("federated: differential privacy budget exceeded, release refused")
40
41// defaultRenyiOrders is the FIXED Renyi order grid the accountant composes over. The conversion from
42// accumulated RDP to (epsilon, delta) takes the minimum epsilon over these orders, so a denser grid only
43// tightens the reported epsilon, never loosens it. The grid spans small fractional orders (which favor large
44// delta) up to large integer orders (which favor small delta), so the min over the grid tracks the optimal
45// order across the practical (sigma, delta) range. Orders are strictly greater than 1, because the
46// conversion divides by (alpha minus 1).
47var defaultRenyiOrders = []float64{
48 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512,
49}
50
51// GaussianRelease describes one Gaussian mechanism release for the accountant.
52//
53// CONVENTION (documented once, load bearing). The mechanism clips each per tenant contribution to L2 norm C
54// (the sensitivity), then adds isotropic Gaussian noise of standard deviation NoiseSigma. The privacy of a
55// Gaussian mechanism depends only on the noise multiplier sigma = NoiseSigma / C, the noise measured in
56// units of the sensitivity. For an L2 sensitivity 1 mechanism (NoiseSigma already expressed in sensitivity
57// units, C = 1) the multiplier is just NoiseSigma. The per release Renyi differential privacy at order alpha
58// is the closed form alpha / (2 * sigma^2). This is the standard Gaussian mechanism RDP curve (Mironov 2017,
59// cited in docs/AI_RESEARCH.md, the federated learning with differential privacy section).
60type GaussianRelease struct {
61 NoiseSigma float64 // standard deviation of the added Gaussian noise, in the same units as the clip norm
62 ClipNorm float64 // the L2 clip norm C, the per tenant sensitivity; must be greater than zero
63}
64
65// noiseMultiplier returns sigma = NoiseSigma / ClipNorm, the noise measured in units of the sensitivity, the
66// only quantity the Gaussian RDP curve depends on. A non positive clip norm or non positive noise sigma is a
67// programming error and yields NaN, so the per release RDP is NaN and the accountant rejects it rather than
68// silently treating an invalid mechanism as zero privacy loss.
69func (r GaussianRelease) noiseMultiplier() float64 {
70 if r.ClipNorm <= 0 || r.NoiseSigma <= 0 {
71 return math.NaN()
72 }
73 return r.NoiseSigma / r.ClipNorm
74}
75
76// gaussianRDP returns the per release Renyi differential privacy at order alpha for a Gaussian mechanism with
77// noise multiplier sigma, the closed form alpha / (2 * sigma^2). This accounts WITHOUT subsampling
78// amplification. WORST CASE: in a small fixed set of GovCon tenants, tenant participation is not genuinely
79// random and unknown to the adversary each round, so privacy amplification by subsampling cannot be claimed
80// (docs/AI_RESEARCH.md gotcha 5). Accounting without amplification is the conservative, worst case bound:
81// every tenant is assumed to participate every round, which can only overstate the spent epsilon, never
82// understate it. If a provably random participation scheme is ever adopted, the subsampled RDP bound from
83// arxiv 1808.00087 would tighten this, but the no amplification curve here stays a sound upper bound.
84func gaussianRDP(sigma, alpha float64) float64 {
85 return alpha / (2 * sigma * sigma)
86}
87
88// epsilonAtOrder converts an accumulated RDP value at one Renyi order to an (epsilon, delta) epsilon using
89// the standard closed form epsilon(alpha) = rdp(alpha) + log(1/delta) / (alpha - 1). This is the canonical
90// RDP to approximate DP conversion (Mironov 2017, cited in docs/AI_RESEARCH.md). alpha must be greater than 1
91// and delta must be in (0, 1).
92func epsilonAtOrder(rdp, alpha, delta float64) float64 {
93 return rdp + math.Log(1/delta)/(alpha-1)
94}
95
96// Accountant is the privacy budget accountant boundary. Charge composes one release into the running budget
97// and returns the epsilon at the configured delta after the release, or refuses it (returning
98// ErrBudgetExceeded) if it would exceed the budget. Remaining returns the epsilon headroom left before the
99// budget is spent. An accountant implementation may be pure (in memory, for tests and the math) or ledger
100// backed (the hard server side spend gate in federated.go).
101type Accountant interface {
102 // Charge composes the release into the running budget and returns the epsilon at the configured delta
103 // after the release. If the release would exceed the budget it is refused, nothing is persisted, and
104 // the returned error is ErrBudgetExceeded.
105 Charge(release GaussianRelease) (epsilonNow float64, err error)
106 // Remaining returns the epsilon headroom left before the budget is spent, the configured epsilon target
107 // minus the current epsilon at the configured delta. It is never negative; a spent budget returns zero.
108 Remaining() float64
109}
110
111// rdpAccountant is the pure in memory Gaussian mechanism RDP accountant. It composes releases by SUMMING the
112// per release RDP at each order in the fixed grid, then converts the accumulated RDP to an (epsilon, delta)
113// epsilon by taking the MINIMUM epsilon over the grid orders. The grid math is float64; a ledger backed gate
114// records the spent epsilon as shopspring decimal for a bit stable audit record. This type is the math the
115// hard tests pin and the engine the ledger backed gate composes through; it never samples noise.
116type rdpAccountant struct {
117 orders []float64 // the fixed Renyi order grid, strictly greater than 1
118 accRDP []float64 // accumulated RDP per order, index aligned with orders, summed over composed releases
119 epsilon float64 // the epsilon budget target at delta, from config, never a code constant
120 delta float64 // the delta target at which epsilon is reported, from config, never a code constant
121}
122
123// newRDPAccountant builds a pure RDP accountant over the fixed grid with the given epsilon and delta budget.
124// epsilon and delta come from configuration (MM_FEDERATED_DP_EPSILON and MM_FEDERATED_DP_DELTA), never from
125// code constants, so a privacy officer tunes them without a rebuild. priorAccRDP, when non nil, seeds the
126// accumulated RDP per order from a persisted ledger so composition resumes across process restarts; it must
127// be index aligned with the grid. delta must be in (0, 1) and epsilon must be positive.
128func newRDPAccountant(epsilon, delta float64, priorAccRDP []float64) (*rdpAccountant, error) {
129 if !(delta > 0 && delta < 1) {
130 return nil, errors.New("federated: delta must be in the open interval (0, 1)")
131 }
132 if !(epsilon > 0) {
133 return nil, errors.New("federated: epsilon budget must be positive")
134 }
135 orders := defaultRenyiOrders
136 acc := make([]float64, len(orders))
137 if priorAccRDP != nil {
138 if len(priorAccRDP) != len(orders) {
139 return nil, errors.New("federated: prior accumulated RDP length does not match the order grid")
140 }
141 copy(acc, priorAccRDP)
142 }
143 return &rdpAccountant{orders: orders, accRDP: acc, epsilon: epsilon, delta: delta}, nil
144}
145
146// composedRDP returns the per order RDP that composing one release ADDS to the running accumulation, index
147// aligned with the grid. It does not mutate the accountant; the caller adds it on a successful Charge. A
148// non finite noise multiplier (a non positive clip norm or sigma) yields a non finite RDP the caller rejects.
149func (a *rdpAccountant) composedRDP(release GaussianRelease) []float64 {
150 sigma := release.noiseMultiplier()
151 out := make([]float64, len(a.orders))
152 for i, alpha := range a.orders {
153 out[i] = gaussianRDP(sigma, alpha)
154 }
155 return out
156}
157
158// epsilonFor converts a per order accumulated RDP slice to the reported epsilon at the configured delta: the
159// MINIMUM over the grid of epsilon(alpha) = rdp(alpha) + log(1/delta) / (alpha - 1). Taking the min over the
160// grid is what makes this a valid (epsilon, delta) guarantee: an (alpha, rdp(alpha)) RDP bound implies the
161// (epsilon, delta) bound at every order, so the smallest of them is the tightest sound epsilon.
162//
163// An empty accumulation (no releases composed, every order zero) is the zero mechanism, whose true privacy
164// loss is exactly zero, so epsilonFor returns 0 rather than the positive delta slack the conversion formula
165// would otherwise report at zero RDP. This matches the standard accountant convention (a fresh budget has
166// spent nothing).
167func (a *rdpAccountant) epsilonFor(accRDP []float64) float64 {
168 allZero := true
169 for _, v := range accRDP {
170 if v != 0 {
171 allZero = false
172 break
173 }
174 }
175 if allZero {
176 return 0
177 }
178 best := math.Inf(1)
179 for i, alpha := range a.orders {
180 eps := epsilonAtOrder(accRDP[i], alpha, a.delta)
181 if eps < best {
182 best = eps
183 }
184 }
185 return best
186}
187
188// currentEpsilon returns the epsilon at the configured delta for the accumulation composed so far.
189func (a *rdpAccountant) currentEpsilon() float64 {
190 return a.epsilonFor(a.accRDP)
191}
192
193// Charge composes one release into the running accumulation and returns the epsilon at the configured delta
194// after it. The pure accountant enforces the budget itself: if the post release epsilon would exceed the
195// epsilon target it refuses, leaves the accumulation untouched, and returns ErrBudgetExceeded. A non finite
196// release (non positive clip norm or sigma) is rejected as a programming error before any composition.
197func (a *rdpAccountant) Charge(release GaussianRelease) (float64, error) {
198 add := a.composedRDP(release)
199 candidate := make([]float64, len(a.accRDP))
200 for i := range a.accRDP {
201 if math.IsInf(add[i], 0) || math.IsNaN(add[i]) {
202 return 0, errors.New("federated: non finite per release RDP, check the clip norm and sigma")
203 }
204 candidate[i] = a.accRDP[i] + add[i]
205 }
206 eps := a.epsilonFor(candidate)
207 if eps > a.epsilon {
208 return 0, ErrBudgetExceeded
209 }
210 a.accRDP = candidate
211 return eps, nil
212}
213
214// Remaining returns the epsilon headroom left before the budget is spent, never negative.
215func (a *rdpAccountant) Remaining() float64 {
216 rem := a.epsilon - a.currentEpsilon()
217 if rem < 0 {
218 return 0
219 }
220 return rem
221}
222
223// accumulatedRDPDecimal returns the accumulated RDP per order as shopspring decimals, the bit stable
224// representation the ledger persists in the rdp_orders jsonb so the audit record reproduces exactly. The grid
225// math runs in float64 (decimal is for the recorded artifact only, per docs/AI_RESEARCH.md).
226func (a *rdpAccountant) accumulatedRDPDecimal() map[float64]decimal.Decimal {
227 out := make(map[float64]decimal.Decimal, len(a.orders))
228 for i, alpha := range a.orders {
229 out[alpha] = decimal.NewFromFloat(a.accRDP[i])
230 }
231 return out
232}
233
234// epsilonDecimal returns the current epsilon at the configured delta as a shopspring decimal, the bit stable
235// epsilon_at_delta the ledger records.
236func (a *rdpAccountant) epsilonDecimal() decimal.Decimal {
237 return decimal.NewFromFloat(a.currentEpsilon())
238}
239
240// worstRDPDecimal returns the largest accumulated RDP over the grid as a shopspring decimal, a single bit
241// stable scalar for the ledger accumulated_rdp summary column. It is a conservative scalar summary of the per
242// order accumulation, not the conversion; the per order detail lives in rdp_orders.
243func (a *rdpAccountant) worstRDPDecimal() decimal.Decimal {
244 worst := math.Inf(-1)
245 for _, v := range a.accRDP {
246 if v > worst {
247 worst = v
248 }
249 }
250 return decimal.NewFromFloat(worst)
251}
252