Files
git.stella-ops.org/src/Telemetry/StellaOps.Telemetry.Federation.Tests/PrivacyBudgetTrackerTests.cs
2026-02-19 22:10:54 +02:00

121 lines
3.5 KiB
C#

using Microsoft.Extensions.Options;
using StellaOps.Telemetry.Federation.Privacy;
namespace StellaOps.Telemetry.Federation.Tests;
public sealed class PrivacyBudgetTrackerTests
{
private static IOptions<FederatedTelemetryOptions> DefaultOptions(
double epsilon = 1.0,
TimeSpan? resetPeriod = null)
{
return Options.Create(new FederatedTelemetryOptions
{
EpsilonBudget = epsilon,
BudgetResetPeriod = resetPeriod ?? TimeSpan.FromHours(24)
});
}
[Fact]
public void Initial_budget_equals_total()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 2.0));
Assert.Equal(2.0, tracker.TotalBudget);
Assert.Equal(2.0, tracker.RemainingEpsilon);
Assert.False(tracker.IsBudgetExhausted);
}
[Fact]
public void TrySpend_reduces_remaining_epsilon()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0));
Assert.True(tracker.TrySpend(0.3));
Assert.Equal(0.7, tracker.RemainingEpsilon, precision: 10);
}
[Fact]
public void TrySpend_rejects_when_budget_exhausted()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5));
Assert.True(tracker.TrySpend(0.5));
Assert.False(tracker.TrySpend(0.1));
Assert.True(tracker.IsBudgetExhausted);
}
[Fact]
public void TrySpend_rejects_negative_or_zero_epsilon()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions());
Assert.False(tracker.TrySpend(0));
Assert.False(tracker.TrySpend(-0.5));
}
[Fact]
public void Reset_restores_full_budget()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0));
tracker.TrySpend(0.8);
Assert.Equal(0.2, tracker.RemainingEpsilon, precision: 10);
tracker.Reset();
Assert.Equal(1.0, tracker.RemainingEpsilon);
Assert.False(tracker.IsBudgetExhausted);
}
[Fact]
public void Snapshot_tracks_queries_and_suppressed_counts()
{
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5));
tracker.TrySpend(0.2); // success
tracker.TrySpend(0.2); // success
tracker.TrySpend(0.2); // fails — over budget
var snapshot = tracker.GetSnapshot();
Assert.Equal(2, snapshot.QueriesThisPeriod);
Assert.Equal(1, snapshot.SuppressedThisPeriod);
Assert.True(snapshot.Exhausted);
}
[Fact]
public void LaplacianNoise_produces_finite_values()
{
var rng = new Random(42);
for (int i = 0; i < 1000; i++)
{
var noise = PrivacyBudgetTracker.LaplacianNoise(1.0, 0.5, rng);
Assert.True(double.IsFinite(noise));
}
}
[Fact]
public void LaplacianNoise_is_deterministic_with_fixed_seed()
{
var rng1 = new Random(12345);
var rng2 = new Random(12345);
var noise1 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng1);
var noise2 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng2);
Assert.Equal(noise1, noise2);
}
[Fact]
public void LaplacianNoise_scales_with_sensitivity_and_epsilon()
{
var rng = new Random(42);
var samples = Enumerable.Range(0, 10000)
.Select(_ => PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng))
.ToList();
// Mean should be approximately 0 for large sample
var mean = samples.Average();
Assert.True(Math.Abs(mean) < 0.1, $"Mean {mean} too far from 0");
}
}