Skip to content

Commit e84fd5b

Browse files
committed
feat(draw): add verifiable inventory permutation
1 parent e57e36c commit e84fd5b

19 files changed

Lines changed: 506 additions & 112 deletions

SecRandom.Core.Tests/FairDrawAlgorithmTests.cs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
using SecRandom.Core.Abstraction.Services;
88
using SecRandom.Core.Enums;
99
using SecRandom.Core.Enums.Configs;
10+
using SecRandom.Core.Interfaces;
1011
using SecRandom.Core.Models;
12+
using SecRandom.Core.Models.Verification;
1113
using SecRandom.Core.Models.SubConfigs.Picking;
1214
using SecRandom.Core.Services.Config;
1315
using SecRandom.Core.Services.Draw;
@@ -101,6 +103,32 @@ public void CreateStudentVerificationInput_UsesCourseScopedBalanceWeights()
101103
> input.Candidates.Single(candidate => candidate.RecordId == groupB.RecordId).WeightMicros);
102104
}
103105

106+
[Fact]
107+
public void CountLottery_UsesInventoryPermutationRatherThanPrizeWeights()
108+
{
109+
var first = new Prize { Name = "First", RecordId = Guid.NewGuid(), Count = 2, Weight = 100 };
110+
var second = new Prize { Name = "Second", RecordId = Guid.NewGuid(), Count = 1, Weight = 0.01 };
111+
var prizes = new PrizeList { Prizes = [first, second] };
112+
var config = CreateConfig(new FairDrawSettingsConfig());
113+
config.LotterySettings = new LotterySettingsConfig
114+
{
115+
DrawType = LotteryDrawType.Count,
116+
DrawMode = DrawMode.Repeat
117+
};
118+
119+
using var host = CreateHost(config, new TestProfileService(new StudentHistory(), new StudentList(), prizes));
120+
IAppHost.Host = host;
121+
var engine = new DrawEngine(new ScriptedRandomSource(2, 0));
122+
123+
var local = engine.DrawPrize(2, _ => true);
124+
var input = engine.CreatePrizeVerificationInput(2, new Dictionary<string, int>());
125+
126+
Assert.True(local.IsSuccess);
127+
Assert.Equal([second, first], local.Result);
128+
Assert.Equal(VerificationSamplingMode.InventoryPermutation, input.SamplingMode);
129+
Assert.All(input.Candidates, candidate => Assert.Equal(1_000_000, candidate.WeightMicros));
130+
}
131+
104132
private static MainConfigModel CreateConfig(FairDrawSettingsConfig fairSettings)
105133
{
106134
return new MainConfigModel
@@ -133,12 +161,16 @@ public override void SaveConfig<T>(T config) { }
133161
public override void DeleteConfig<T>(T config) { }
134162
}
135163

136-
private sealed class TestProfileService(StudentHistory history, StudentList students) : IProfileService
164+
private sealed class TestProfileService(
165+
StudentHistory history,
166+
StudentList students,
167+
PrizeList? prizes = null,
168+
PrizeHistory? prizeHistory = null) : IProfileService
137169
{
138170
public StudentList? CurrentStudentList { get; } = students;
139171
public StudentHistory? CurrentStudentHistory { get; } = history;
140-
public PrizeList? CurrentPrizeList { get; } = new();
141-
public PrizeHistory? CurrentPrizeHistory { get; } = new();
172+
public PrizeList? CurrentPrizeList { get; } = prizes ?? new();
173+
public PrizeHistory? CurrentPrizeHistory { get; } = prizeHistory ?? new();
142174
public StudentListConfig? StudentListConfig => null;
143175
public StudentHistoryConfig? StudentHistoryConfig => null;
144176
public PrizeListConfig? PrizeListConfig => null;
@@ -151,4 +183,19 @@ public void ClearCurrentStudentHistory() { }
151183
public void ClearCurrentPrizeHistory() { }
152184
public void SaveProfile() { }
153185
}
186+
187+
private sealed class ScriptedRandomSource(params int[] values) : IRandomSource
188+
{
189+
private readonly Queue<int> _values = new(values);
190+
191+
public int NextInt32(int maxExclusive)
192+
{
193+
var value = _values.Dequeue();
194+
if (value < 0 || value >= maxExclusive)
195+
throw new InvalidOperationException("The test random value is outside the requested bound.");
196+
return value;
197+
}
198+
199+
public double NextDouble() => throw new InvalidOperationException("Inventory permutation must not use weighted sampling.");
200+
}
154201
}

SecRandom.Core.Tests/VerificationKernelTests.cs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,20 @@ public sealed class VerificationKernelTests
1616
public void ProofProtocol_UsesSecRandomHistoryBalancedAlgorithmIdentity()
1717
{
1818
Assert.Equal("secrandom-fairdraw-history-balanced-weighted-chacha20/v3", VerificationWireCodec.AlgorithmId);
19-
Assert.Equal("3.0.0", VerificationWireCodec.KernelVersion);
19+
Assert.Equal("3.1.0", VerificationWireCodec.AlgorithmEngineVersion);
20+
}
21+
22+
[Fact]
23+
public void DrawProof_UsesAlgorithmEngineVersionAndReadsLegacyKernelVersion()
24+
{
25+
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
26+
var current = new DrawProof { AlgorithmEngineVersion = "3.1.0" };
27+
var currentJson = JsonSerializer.Serialize(current, options);
28+
var legacy = JsonSerializer.Deserialize<DrawProof>("{\"kernelVersion\":\"1.0.0\"}", options);
29+
30+
Assert.Contains("\"algorithmEngineVersion\":\"3.1.0\"", currentJson);
31+
Assert.DoesNotContain("\"kernelVersion\"", currentJson);
32+
Assert.Equal("1.0.0", legacy!.LegacyKernelVersion);
2033
}
2134

2235
[Fact]
@@ -72,6 +85,47 @@ public void Draw_AlwaysSelectsGuaranteedCandidateForSingleDraw()
7285
Assert.Equal(guaranteed, result.Winners[0].RecordId);
7386
}
7487

88+
[Fact]
89+
public void InventoryPermutation_IsStableAndDrawsWithoutReplacement()
90+
{
91+
var input = new VerificationDrawInput
92+
{
93+
Kind = VerificationDrawKind.Prize,
94+
SamplingMode = VerificationSamplingMode.InventoryPermutation,
95+
Count = 3,
96+
Candidates =
97+
[
98+
new VerificationCandidate(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), 0, 1_000_000, false),
99+
new VerificationCandidate(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), 1, 1_000_000, false),
100+
new VerificationCandidate(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), 0, 1_000_000, false),
101+
new VerificationCandidate(Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), 0, 1_000_000, false)
102+
]
103+
};
104+
var seed = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
105+
var kernel = new ManagedVerificationKernel();
106+
107+
var first = kernel.Draw(input, seed);
108+
var repeated = kernel.Draw(input, seed);
109+
110+
Assert.Equal(first.Winners, repeated.Winners);
111+
Assert.Equal(3, first.Winners.Count);
112+
Assert.Equal(3, first.Winners.Distinct().Count());
113+
}
114+
115+
[Fact]
116+
public void InventoryPermutation_RejectsWeightsChangedByInternalRules()
117+
{
118+
var input = new VerificationDrawInput
119+
{
120+
Kind = VerificationDrawKind.Prize,
121+
SamplingMode = VerificationSamplingMode.InventoryPermutation,
122+
Count = 1,
123+
Candidates = [new VerificationCandidate(Guid.NewGuid(), 0, 500_000, false)]
124+
};
125+
126+
Assert.Throws<InvalidOperationException>(() => new ManagedVerificationKernel().Draw(input, new byte[32]));
127+
}
128+
75129
[Fact]
76130
public void AttachedSettings_RestoresEnabledHundredPercentRuleFromPersistedJson()
77131
{
@@ -177,6 +231,58 @@ public void OnlineSeed_BindsEveryChallengeInput()
177231
Assert.NotEqual(first, changed);
178232
}
179233

234+
[Fact]
235+
public void CsprngSeedAndNonce_AreFresh32ByteValues()
236+
{
237+
var seeds = Enumerable.Range(0, 64).Select(_ => VerificationSeedDerivation.CreateCsprngSeed()).ToArray();
238+
var nonces = Enumerable.Range(0, 64).Select(_ => VerificationSeedDerivation.CreateCsprngNonce()).ToArray();
239+
240+
Assert.All(seeds, seed =>
241+
{
242+
Assert.Equal(32, seed.Length);
243+
Assert.Contains(seed, value => value != 0);
244+
});
245+
Assert.All(nonces, nonce =>
246+
{
247+
Assert.Equal(32, nonce.Length);
248+
Assert.Contains(nonce, value => value != 0);
249+
});
250+
Assert.Equal(seeds.Length, seeds.Select(Convert.ToHexString).Distinct(StringComparer.Ordinal).Count());
251+
Assert.Equal(nonces.Length, nonces.Select(Convert.ToHexString).Distinct(StringComparer.Ordinal).Count());
252+
}
253+
254+
[Fact]
255+
public void RejectionSampler_DiscardsOutOfRangeValues()
256+
{
257+
const ulong bound = (1UL << 63) + 1;
258+
var limit = ulong.MaxValue - ((ulong.MaxValue % bound + 1) % bound);
259+
var values = new Queue<ulong>([limit + 1, 0]);
260+
261+
var value = VerificationChaCha20Random.SampleBelow(bound, values.Dequeue);
262+
263+
Assert.Equal(0UL, value);
264+
Assert.Empty(values);
265+
Assert.Throws<ArgumentOutOfRangeException>(() => VerificationChaCha20Random.SampleBelow(0, () => 0));
266+
}
267+
268+
[Fact]
269+
public void VerificationChaCha20_IsDeterministicAcrossBlockBoundary()
270+
{
271+
var seed = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
272+
var first = new VerificationChaCha20Random(seed);
273+
var repeated = new VerificationChaCha20Random(seed);
274+
var changed = new VerificationChaCha20Random(seed.Select((value, index) => index == 0 ? (byte)(value ^ 1) : value).ToArray());
275+
var expected = Enumerable.Range(0, 17).Select(_ => first.NextUInt32()).ToArray();
276+
var repeatedValues = Enumerable.Range(0, 17).Select(_ => repeated.NextUInt32()).ToArray();
277+
var changedValues = Enumerable.Range(0, 17).Select(_ => changed.NextUInt32()).ToArray();
278+
279+
Assert.Equal(
280+
"D0880430F195099044A4CC6E2069BF99C1A98A457706F0726384A21D3518A19CD53AEE85709038B5949AFFD9CE07135293BE492CDE1C74028F45763DE2F2F534B201A72B",
281+
Convert.ToHexString(expected.SelectMany(BitConverter.GetBytes).ToArray()));
282+
Assert.Equal(expected, repeatedValues);
283+
Assert.False(expected.SequenceEqual(changedValues));
284+
}
285+
180286
[Fact]
181287
public void ResponseCodec_RejectsTrailingData()
182288
{

SecRandom.Core/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ SecRandom.Core/
7272
- Weighted drawing validates count, candidates, and weights before sampling; preserve explicit `DrawStatus` returns over
7373
exceptions at public boundary.
7474
- Draw fairness/repeat history for students and prizes must use `ProfileRecordIdentity`/`RecordId` first. Legacy `Id`/`Name` history fallback is only for backward compatibility and must stay ambiguity-safe.
75+
- Verification proof inputs commit a `VerificationSamplingMode`. Student proofs use history-balanced weighted sampling; count-lottery proofs use equal-probability partial inventory permutation only while no behind-scene rule is enabled. Any internal rule moves lottery proof generation to weighted-without-replacement and must stay visible in the anonymous audit payload.
7576
- Config handlers derive from `ConfigHandlerBase<TModel>`; config model defaults should be safe without existing data
7677
files.
7778
- IPC parser code is UI-free and must reject ambiguous routes, malformed percent escapes, control characters, oversized frames, and unsupported schemes. Keep route execution in the app layer.

SecRandom.Core/AssemblyInfo.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
[assembly: InternalsVisibleTo("ClassIsland")]
55
[assembly: InternalsVisibleTo("ClassIsland.Desktop")]
66
[assembly: InternalsVisibleTo("ClassIsland.Platforms.Windows")]
7+
[assembly: InternalsVisibleTo("SecRandom.Core.Tests")]
8+
[assembly: InternalsVisibleTo("SecRandom.FairnessAudit")]
79

810
[assembly: XmlnsPrefix("http://secrandom.sectl.cn/schemas/xaml/core", "sr")]
911
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core")]
1012
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.Abstraction.Controls")]
1113
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.Behaviors")]
1214
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.Controls")]
1315
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.Converters")]
14-
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.MarkupExtensions")]
16+
[assembly: XmlnsDefinition("http://secrandom.sectl.cn/schemas/xaml/core", "SecRandom.Core.MarkupExtensions")]

SecRandom.Core/Models/Verification/VerificationDrawInput.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace SecRandom.Core.Models.Verification;
55
public sealed class VerificationDrawInput
66
{
77
public required VerificationDrawKind Kind { get; init; }
8+
public VerificationSamplingMode SamplingMode { get; init; } = VerificationSamplingMode.HistoryBalancedWeighted;
89
public required int Count { get; init; }
910
public IReadOnlyList<VerificationCandidate> Candidates { get; init; } = [];
1011
public byte[] AuditPayload { get; init; } = [];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace SecRandom.Core.Models.Verification;
2+
3+
/// <summary>
4+
/// Selects the deterministic sampler committed into a verification request.
5+
/// </summary>
6+
public enum VerificationSamplingMode : byte
7+
{
8+
HistoryBalancedWeighted = 1,
9+
InventoryPermutation = 2,
10+
WeightedWithoutReplacement = 3
11+
}

SecRandom.Core/Services/Draw/DrawEngine.Verification.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public VerificationDrawInput CreateStudentVerificationInput(
4646
return new VerificationDrawInput
4747
{
4848
Kind = VerificationDrawKind.Student,
49+
SamplingMode = VerificationSamplingMode.HistoryBalancedWeighted,
4950
Count = count,
5051
Candidates = frozen,
5152
AuditPayload = CreateAuditPayload("student", count, frozen, weighted, historyCache, new
@@ -73,12 +74,26 @@ public VerificationDrawInput CreatePrizeVerificationInput(
7374
throw new InvalidOperationException("The prepared prize pool cannot satisfy this draw.");
7475

7576
var frozen = FreezeCandidates(weighted);
77+
var hasInternalRules = weighted.Any(candidate => GetBehindSceneSettings(candidate.Candidate) is { IsAttachSettingsEnabled: true });
78+
var samplingMode = ConfigData.LotterySettings.DrawType == LotteryDrawType.Count && !hasInternalRules
79+
? VerificationSamplingMode.InventoryPermutation
80+
: VerificationSamplingMode.WeightedWithoutReplacement;
7681
return new VerificationDrawInput
7782
{
7883
Kind = VerificationDrawKind.Prize,
84+
SamplingMode = samplingMode,
7985
Count = count,
8086
Candidates = frozen,
81-
AuditPayload = CreateAuditPayload("prize", count, frozen, weighted, historyCache)
87+
AuditPayload = CreateAuditPayload("prize", count, frozen, weighted, historyCache, new
88+
{
89+
samplingAlgorithm = samplingMode == VerificationSamplingMode.InventoryPermutation
90+
? "inventory-partial-permutation"
91+
: "weighted-without-replacement",
92+
inventoryEntriesEqualWeight = samplingMode == VerificationSamplingMode.InventoryPermutation,
93+
internalRulesRequireWeightedFallback = samplingMode == VerificationSamplingMode.WeightedWithoutReplacement
94+
&& ConfigData.LotterySettings.DrawType == LotteryDrawType.Count
95+
&& hasInternalRules
96+
})
8297
};
8398
}
8499

SecRandom.Core/Services/Draw/DrawEngine.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public DrawResult<Prize> DrawPrize(int count, Func<Prize, bool> filter)
201201
if (count > weightedCandidates.Count)
202202
throw new RepeatLimitExhaustedException();
203203

204-
var result = DrawWithBehindSceneWeights(weightedCandidates, count);
204+
var result = DrawPrizeCandidates(weightedCandidates, count);
205205
LogDrawResult("奖品抽取", result.Status, count, usable.Count, result.Result.Count);
206206
return result;
207207
}
@@ -234,7 +234,7 @@ public DrawResult<Prize> DrawPrizeWithTemporaryCounts(
234234
if (count > weightedCandidates.Count)
235235
throw new RepeatLimitExhaustedException();
236236

237-
var result = DrawWithBehindSceneWeights(weightedCandidates, count);
237+
var result = DrawPrizeCandidates(weightedCandidates, count);
238238
LogDrawResult("奖品抽取", result.Status, count, usable.Count, result.Result.Count);
239239
return result;
240240
}
@@ -279,7 +279,7 @@ private List<WeightedCandidate<Prize>> BuildPrizeCandidates(
279279
{
280280
var remainingCount = Math.Max(0, prize.Count - (historyCache.GetValueOrDefault(prize)?.TotalCount ?? 0));
281281
for (var i = 0; i < remainingCount; i++)
282-
result.Add(new WeightedCandidate<Prize> { Candidate = prize, Weight = prize.Weight });
282+
result.Add(new WeightedCandidate<Prize> { Candidate = prize, Weight = 1.0 });
283283
}
284284

285285
return result;
@@ -288,6 +288,29 @@ private List<WeightedCandidate<Prize>> BuildPrizeCandidates(
288288
return prizes.Select(p => new WeightedCandidate<Prize> { Candidate = p, Weight = p.Weight }).ToList();
289289
}
290290

291+
private DrawResult<Prize> DrawPrizeCandidates(IReadOnlyList<WeightedCandidate<Prize>> candidates, int count)
292+
{
293+
if (ConfigData.LotterySettings.DrawType != LotteryDrawType.Count
294+
|| candidates.Any(candidate => GetBehindSceneSettings(candidate.Candidate) is { IsAttachSettingsEnabled: true }))
295+
return DrawWithBehindSceneWeights(candidates, count);
296+
297+
if (count > candidates.Count)
298+
return new DrawResult<Prize> { Status = DrawStatus.NoEligibleCandidates };
299+
300+
var tickets = candidates.Select(candidate => candidate.Candidate).ToList();
301+
for (var index = 0; index < count; index++)
302+
{
303+
var selectedIndex = index + _randomSource.NextInt32(tickets.Count - index);
304+
(tickets[index], tickets[selectedIndex]) = (tickets[selectedIndex], tickets[index]);
305+
}
306+
307+
return new DrawResult<Prize>
308+
{
309+
Status = DrawStatus.Success,
310+
Result = tickets.Take(count).ToList()
311+
};
312+
}
313+
291314
private DrawResult<TCandidate> DrawWithBehindSceneWeights<TCandidate>(
292315
IReadOnlyList<WeightedCandidate<TCandidate>> weightedCandidates,
293316
int count)

0 commit comments

Comments
 (0)