11import { Set } from "immutable" ;
22import { describe , expect , it } from "vitest" ;
3+ import fc from "fast-check" ;
34
45import { WeightsContainer } from "../index.js" ;
56import { ByzantineRobustAggregator } from "./byzantine.js" ;
@@ -39,8 +40,8 @@ describe("ByzantineRobustAggregator", () => {
3940 expect ( arr ) . to . deep . equal ( [ [ 2 ] , [ 3 ] ] ) ;
4041 } ) ;
4142
42- it ( "clips a single outlier with small radius " , async ( ) => {
43- const agg = new ByzantineRobustAggregator ( 0 , 3 , "absolute" , 1.0 , 1 , 0 ) ;
43+ it ( "reduces influence of a single outlier" , async ( ) => {
44+ const agg = new ByzantineRobustAggregator ( 0 , 3 , "absolute" , 1.0 , 10 , 0 ) ;
4445 const [ c1 , c2 , bad ] = [ "c1" , "c2" , "bad" ] ;
4546 agg . setNodes ( Set . of ( c1 , c2 , bad ) ) ;
4647
@@ -51,43 +52,67 @@ describe("ByzantineRobustAggregator", () => {
5152
5253 const out = await p ;
5354 const arr = await WSIntoArrays ( out ) ;
54- expect ( arr [ 0 ] [ 0 ] ) . to . be . closeTo ( 1 , 1e-6 ) ;
55+
56+ const result = arr [ 0 ] [ 0 ] ;
57+ const mean = ( 1 + 1 + 100 ) / 3 ;
58+
59+ expect ( Math . abs ( result - 1 ) ) . to . be . lessThan ( Math . abs ( mean - 1 ) ) ;
5560 } ) ;
5661
57- it ( "applies multiple clipping iterations (maxIterations > 1)" , async ( ) => {
58- const agg = new ByzantineRobustAggregator ( 0 , 2 , "absolute" , 1.0 , 3 , 0 ) ;
59- const [ c1 , bad ] = [ "c1" , "bad" ] ;
60- agg . setNodes ( Set . of ( c1 , bad ) ) ;
62+ it ( "multiple iterations improve the estimate" , async ( ) => {
63+ const [ c1 , c2 , bad ] = [ "c1" , "c2" , "bad" ] ;
6164
62- const p = agg . getPromiseForAggregation ( ) ;
63- agg . add ( c1 , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
64- agg . add ( bad , WeightsContainer . of ( [ 10 ] ) , 0 ) ;
65+ const agg1 = new ByzantineRobustAggregator ( 0 , 3 , "absolute" , 1.0 , 1 , 0 ) ;
66+ agg1 . setNodes ( Set . of ( c1 , c2 , bad ) ) ;
6567
66- const out = await p ;
67- const arr = await WSIntoArrays ( out ) ;
68- expect ( arr [ 0 ] [ 0 ] ) . to . be . lessThan ( 1 ) ; // clipped closer to 0
68+ const p1 = agg1 . getPromiseForAggregation ( ) ;
69+ agg1 . add ( c1 , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
70+ agg1 . add ( c2 , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
71+ agg1 . add ( bad , WeightsContainer . of ( [ 10 ] ) , 0 ) ;
72+ const out1 = await p1 ;
73+ const arr1 = await WSIntoArrays ( out1 ) ;
74+
75+ const agg3 = new ByzantineRobustAggregator ( 0 , 3 , "absolute" , 1.0 , 3 , 0 ) ;
76+ agg3 . setNodes ( Set . of ( c1 , c2 , bad ) ) ;
77+
78+ const p3 = agg3 . getPromiseForAggregation ( ) ;
79+ agg3 . add ( c1 , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
80+ agg3 . add ( c2 , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
81+ agg3 . add ( bad , WeightsContainer . of ( [ 10 ] ) , 0 ) ;
82+ const out3 = await p3 ;
83+ const arr3 = await WSIntoArrays ( out3 ) ;
84+
85+ const honest = 0 ;
86+
87+ expect ( Math . abs ( arr3 [ 0 ] [ 0 ] - honest ) ) . to . be . lessThanOrEqual (
88+ Math . abs ( arr1 [ 0 ] [ 0 ] - honest ) ,
89+ ) ;
6990 } ) ;
7091
7192 it ( "uses momentum when beta > 0" , async ( ) => {
7293 const agg = new ByzantineRobustAggregator ( 0 , 2 , "absolute" , 1e6 , 1 , 0.5 ) ;
7394 const [ c1 , c2 ] = [ "c1" , "c2" ] ;
7495 agg . setNodes ( Set . of ( c1 , c2 ) ) ;
7596
97+ // Round 1
7698 const p1 = agg . getPromiseForAggregation ( ) ;
7799 agg . add ( c1 , WeightsContainer . of ( [ 2 ] ) , 0 ) ;
78100 agg . add ( c2 , WeightsContainer . of ( [ 2 ] ) , 0 ) ;
79101 const out1 = await p1 ;
80102 const arr1 = await WSIntoArrays ( out1 ) ;
81- expect ( arr1 [ 0 ] [ 0 ] ) . to . equal ( 2 ) ;
82103
104+ // m₀ = (1 - β) * g = 1
105+ expect ( arr1 [ 0 ] [ 0 ] ) . to . be . closeTo ( 1 , 1e-6 ) ;
106+
107+ // Round 2
83108 const p2 = agg . getPromiseForAggregation ( ) ;
84109 agg . add ( c1 , WeightsContainer . of ( [ 4 ] ) , 1 ) ;
85110 agg . add ( c2 , WeightsContainer . of ( [ 4 ] ) , 1 ) ;
86111 const out2 = await p2 ;
87112 const arr2 = await WSIntoArrays ( out2 ) ;
88113
89- // With momentum = 0.5, result = 0.5 * prev + 0 .5 * current = 3.0
90- expect ( arr2 [ 0 ] [ 0 ] ) . to . be . closeTo ( 3 , 1e-6 ) ;
114+ // m₁ = 0.5*4 + 0.5*1 = 2 .5 → avg = 2.5
115+ expect ( arr2 [ 0 ] [ 0 ] ) . to . be . closeTo ( 2.5 , 1e-6 ) ;
91116 } ) ;
92117
93118 it ( "respects roundCutoff — ignores old contributions" , async ( ) => {
@@ -108,4 +133,213 @@ describe("ByzantineRobustAggregator", () => {
108133 const arr2 = await WSIntoArrays ( out2 ) ;
109134 expect ( arr2 [ 0 ] [ 0 ] ) . to . equal ( 20 ) ;
110135 } ) ;
136+
137+ it ( "remains robust with 30% Byzantine clients" , async ( ) => {
138+ const honest = Array ( 7 ) . fill ( 1 ) ;
139+ const byzantine = Array ( 3 ) . fill ( 100 ) ;
140+
141+ const agg = new ByzantineRobustAggregator ( 0 , 10 , "absolute" , 1.0 , 5 , 0 ) ;
142+ const ids = [ ...honest , ...byzantine ] . map ( ( _ , i ) => `c${ i } ` ) ;
143+ agg . setNodes ( Set ( ids ) ) ;
144+
145+ const p = agg . getPromiseForAggregation ( ) ;
146+ honest . forEach ( ( v , i ) => agg . add ( `c${ i } ` , WeightsContainer . of ( [ v ] ) , 0 ) ) ;
147+ byzantine . forEach ( ( v , i ) =>
148+ agg . add ( `c${ i + honest . length } ` , WeightsContainer . of ( [ v ] ) , 0 ) ,
149+ ) ;
150+
151+ const out = await p ;
152+ const arr = await WSIntoArrays ( out ) ;
153+
154+ const honestMean = honest . reduce ( ( a , b ) => a + b , 0 ) / honest . length ;
155+ const rawMean =
156+ [ ...honest , ...byzantine ] . reduce ( ( a , b ) => a + b , 0 ) /
157+ ( honest . length + byzantine . length ) ;
158+
159+ expect ( Math . abs ( arr [ 0 ] [ 0 ] - honestMean ) ) . to . be . lessThan (
160+ Math . abs ( rawMean - honestMean ) ,
161+ ) ;
162+ } ) ;
163+
164+ it ( "moves closer to the honest signal under constant input" , async ( ) => {
165+ const honest = 1 ;
166+
167+ const agg = new ByzantineRobustAggregator ( 0 , 4 , "absolute" , 1.0 , 3 , 0 ) ;
168+ agg . setNodes ( Set ( [ "a" , "b" , "c" , "d" ] ) ) ;
169+
170+ const p = agg . getPromiseForAggregation ( ) ;
171+ agg . add ( "a" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
172+ agg . add ( "b" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
173+ agg . add ( "c" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
174+ agg . add ( "d" , WeightsContainer . of ( [ 10 ] ) , 0 ) ;
175+
176+ const out = await p ;
177+ const v = ( await out . weights [ 0 ] . data ( ) ) [ 0 ] ;
178+
179+ const mean = ( 1 + 1 + 1 + 10 ) / 4 ;
180+
181+ expect ( Math . abs ( v - honest ) ) . to . be . lessThan ( Math . abs ( mean - honest ) ) ;
182+ } ) ;
183+
184+ it ( "does not significantly worsen deviation compared to mean" , async ( ) => {
185+ const clipRadius = 1.0 ;
186+
187+ await fc . assert (
188+ fc . asyncProperty (
189+ fc
190+ . array (
191+ fc . double ( {
192+ min : - 1 ,
193+ max : 1 ,
194+ noNaN : true ,
195+ noDefaultInfinity : true ,
196+ } ) ,
197+ { minLength : 3 , maxLength : 10 } ,
198+ )
199+ // avoid degenerate constant arrays (no signal)
200+ . filter ( ( arr ) => arr . some ( ( v ) => Math . abs ( v - arr [ 0 ] ) > 1e-8 ) ) ,
201+
202+ async ( honest ) => {
203+ const n = honest . length + 1 ;
204+
205+ // clean aggregation
206+ const aggClean = new ByzantineRobustAggregator (
207+ 0 ,
208+ honest . length ,
209+ "absolute" ,
210+ clipRadius ,
211+ 1 ,
212+ 0 ,
213+ ) ;
214+ const honestIds = honest . map ( ( _ , i ) => `h${ i } ` ) ;
215+ aggClean . setNodes ( Set ( honestIds ) ) ;
216+
217+ const pClean = aggClean . getPromiseForAggregation ( ) ;
218+ honest . forEach ( ( v , i ) =>
219+ aggClean . add ( `h${ i } ` , WeightsContainer . of ( [ v ] ) , 0 ) ,
220+ ) ;
221+ const cleanOut = await pClean ;
222+ const clean = ( await cleanOut . weights [ 0 ] . data ( ) ) [ 0 ] ;
223+
224+ // aggregation with Byzantine
225+ const aggByz = new ByzantineRobustAggregator (
226+ 0 ,
227+ n ,
228+ "absolute" ,
229+ clipRadius ,
230+ 1 ,
231+ 0 ,
232+ ) ;
233+ const ids = honestIds . concat ( "byz" ) ;
234+ aggByz . setNodes ( Set ( ids ) ) ;
235+
236+ const pByz = aggByz . getPromiseForAggregation ( ) ;
237+ honest . forEach ( ( v , i ) =>
238+ aggByz . add ( `h${ i } ` , WeightsContainer . of ( [ v ] ) , 0 ) ,
239+ ) ;
240+ aggByz . add ( "byz" , WeightsContainer . of ( [ 1e9 ] ) , 0 ) ;
241+
242+ const byzOut = await pByz ;
243+ const byz = ( await byzOut . weights [ 0 ] . data ( ) ) [ 0 ] ;
244+
245+ const deviation = Math . abs ( byz - clean ) ;
246+ const mean = [ ...honest , 1e9 ] . reduce ( ( a , b ) => a + b , 0 ) / n ;
247+ const baseline = Math . abs ( mean - clean ) ;
248+
249+ // combined tolerance (absolute + relative)
250+ const ABS_EPS = 1e-6 ;
251+ const REL_EPS = 1e-6 ;
252+
253+ expect ( deviation ) . toBeLessThanOrEqual (
254+ baseline * ( 1 + REL_EPS ) + ABS_EPS ,
255+ ) ;
256+ } ,
257+ ) ,
258+ { numRuns : 500 } ,
259+ ) ;
260+ } ) ;
261+
262+ it ( "is invariant to client ordering" , async ( ) => {
263+ const values = [ 0 , 1 , 100 ] ;
264+ const ids1 = [ "a" , "b" , "c" ] ;
265+ const ids2 = [ "c" , "a" , "b" ] ;
266+
267+ const run = async ( ids : string [ ] ) => {
268+ const agg = new ByzantineRobustAggregator ( 0 , 3 , "absolute" , 1.0 , 3 , 0 ) ;
269+ agg . setNodes ( Set ( ids ) ) ;
270+ const p = agg . getPromiseForAggregation ( ) ;
271+ ids . forEach ( ( id , i ) => agg . add ( id , WeightsContainer . of ( [ values [ i ] ] ) , 0 ) ) ;
272+ return ( await ( await p ) . weights [ 0 ] . data ( ) ) [ 0 ] ;
273+ } ;
274+
275+ const out1 = await run ( ids1 ) ;
276+ const out2 = await run ( ids2 ) ;
277+
278+ expect ( out1 ) . to . be . closeTo ( out2 , 1e-6 ) ;
279+ } ) ;
280+
281+ it ( "is idempotent when all inputs are identical and within clipping radius" , async ( ) => {
282+ const agg = new ByzantineRobustAggregator ( 0 , 5 , "absolute" , 10.0 , 5 , 0 ) ;
283+ const ids = [ "a" , "b" , "c" , "d" , "e" ] ;
284+ agg . setNodes ( Set ( ids ) ) ;
285+
286+ const p = agg . getPromiseForAggregation ( ) ;
287+ ids . forEach ( ( id ) => agg . add ( id , WeightsContainer . of ( [ 3.14 ] ) , 0 ) ) ;
288+ const out = await p ;
289+
290+ const v = ( await out . weights [ 0 ] . data ( ) ) [ 0 ] ;
291+ expect ( v ) . to . be . closeTo ( 3.14 , 1e-6 ) ;
292+ } ) ;
293+
294+ it ( "limits bias under symmetric Byzantine attacks" , async ( ) => {
295+ const agg = new ByzantineRobustAggregator ( 0 , 4 , "absolute" , 1.0 , 3 , 0 ) ;
296+ agg . setNodes ( Set ( [ "h1" , "h2" , "b1" , "b2" ] ) ) ;
297+
298+ const p = agg . getPromiseForAggregation ( ) ;
299+ agg . add ( "h1" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
300+ agg . add ( "h2" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
301+ agg . add ( "b1" , WeightsContainer . of ( [ 100 ] ) , 0 ) ;
302+ agg . add ( "b2" , WeightsContainer . of ( [ - 100 ] ) , 0 ) ;
303+
304+ const out = await p ;
305+ const v = ( await out . weights [ 0 ] . data ( ) ) [ 0 ] ;
306+
307+ expect ( Math . abs ( v - 1 ) ) . to . be . lessThan (
308+ Math . abs ( ( 1 + 1 + 100 - 100 ) / 4 - 1 ) ,
309+ ) ;
310+ } ) ;
311+
312+ it ( "reduces influence of extreme outliers" , async ( ) => {
313+ const agg = new ByzantineRobustAggregator ( 0 , 4 , "absolute" , 1.0 , 3 , 0 ) ;
314+ agg . setNodes ( Set ( [ "a" , "b" , "c" , "d" ] ) ) ;
315+
316+ const p = agg . getPromiseForAggregation ( ) ;
317+ agg . add ( "a" , WeightsContainer . of ( [ 0 ] ) , 0 ) ;
318+ agg . add ( "b" , WeightsContainer . of ( [ 0.5 ] ) , 0 ) ;
319+ agg . add ( "c" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
320+ agg . add ( "d" , WeightsContainer . of ( [ 100 ] ) , 0 ) ;
321+
322+ const out = await p ;
323+ const v = ( await out . weights [ 0 ] . data ( ) ) [ 0 ] ;
324+
325+ const mean = ( 0 + 0.5 + 1 + 100 ) / 4 ;
326+ const honestCenter = ( 0 + 0.5 + 1 ) / 3 ;
327+
328+ expect ( Math . abs ( v - honestCenter ) ) . to . be . lessThan (
329+ Math . abs ( mean - honestCenter ) ,
330+ ) ;
331+ } ) ;
332+
333+ it ( "reset state when starting fresh aggregator" , async ( ) => {
334+ const run = async ( ) => {
335+ const agg = new ByzantineRobustAggregator ( 0 , 2 , "absolute" , 1.0 , 3 , 0.9 ) ;
336+ agg . setNodes ( Set ( [ "a" , "b" ] ) ) ;
337+ const p = agg . getPromiseForAggregation ( ) ;
338+ agg . add ( "a" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
339+ agg . add ( "b" , WeightsContainer . of ( [ 1 ] ) , 0 ) ;
340+ return ( await ( await p ) . weights [ 0 ] . data ( ) ) [ 0 ] ;
341+ } ;
342+
343+ expect ( await run ( ) ) . to . be . closeTo ( await run ( ) , 1e-6 ) ;
344+ } ) ;
111345} ) ;
0 commit comments