Create a DistributionSuggestionHelper<TUserId> with an EngineConfig, then call Generate(...) or GenerateAsync(...).
using RzR.ItemDistribution.Models.Config;
using RzR.ItemDistribution.Models.Domain;
using RzR.ItemDistribution.Public;
// 1. Configure the engine
var sut = new DistributionSuggestionHelper<Guid>(new EngineConfig
{
UsePriorityAndActivity = true, // enable priority & activity scoring
FullWorkDayHours = 8, // hours in a full working day
MaxAllowedInProcessDocuments = 15, // max docs a full-day user can process
MaxPerUser = null // optional cap per user per run
});
// 2. Build the user list
var users = new List<UserLoad<Guid>>
{
new UserLoad<Guid>(
userId: Guid.NewGuid(),
currentLoad: 1,
capacity: 15,
priority: 5,
lastActivityDate: DateTime.Now.AddDays(-1),
workingHours: 8),
new UserLoad<Guid>(
userId: Guid.NewGuid(),
currentLoad: 8,
capacity: 15,
priority: 5,
lastActivityDate: DateTime.Now.AddDays(-1.2),
workingHours: 8)
};
// 3. Distribute 1 item
var result = sut.Generate(users, documents: 1);
if (result.IsSuccess)
{
var r = result.Response;
Console.WriteLine($"Primary: {r.PrimaryUserId}");
Console.WriteLine($"Alternative: {r.AlternativeUserId}");
Console.WriteLine($"Total: {r.TotalAllocated}");
}| Property | Type | Default | Description |
|---|---|---|---|
MaxPerUser |
int? |
null |
Maximum items allocated to a single user in one run. Must be > 0 when set. |
UsePriorityAndActivity |
bool |
false |
When true, enables the priority & activity scoring step after load scoring. |
FullWorkDayHours |
decimal |
8 |
Hours in a standard full working day. Required with MaxAllowedInProcessDocuments. |
MaxAllowedInProcessDocuments |
int |
0 |
Max documents a full-day user can process. When > 0, enables working-hours capacity adjustment. |
Each user is described by an immutable UserLoad<TUserId>:
| Property | Type | Description |
|---|---|---|
UserId |
TUserId |
Unique identifier of the user. |
CurrentLoad |
decimal |
Number of items currently assigned to / in process by this user. |
Capacity |
decimal |
Maximum number of items this user can handle. |
Priority |
short |
Selection priority — lower value = higher priority (closer to 0 wins). Default 0. |
LastActivityDate |
DateTime |
User's last recorded activity date/time. |
WorkingHours |
decimal |
Hours worked per day (e.g. 4 for part-time). Default 0 = capacity used as-is. |
The Generate / GenerateAsync methods return IResult<DistributionResult<TUserId>>:
| Property | Type | Description |
|---|---|---|
PrimaryUserId |
TUserId? |
Top-ranked user — the primary recommendation. null when nothing could be allocated. |
AlternativeUserId |
TUserId? |
Second-ranked user — a deterministic fallback. null when fewer than 2 users received items. |
Allocation |
IDictionary<TUserId, int> |
Full map of every user → how many items the engine assigned. The dictionary is a defensive copy; mutating it does not affect subsequent runs. |
TotalAllocated |
int |
Sum of all values in Allocation. Always ≤ RequestedDocuments. |
RequestedDocuments |
int |
The document count passed to Generate / GenerateAsync for this run. |
UnallocatedDocuments |
int |
Max(0, RequestedDocuments − TotalAllocated). Greater than 0 when total user capacity was exhausted before all requested items were placed. |
IsFullyAllocated |
bool |
true when every requested document was placed (UnallocatedDocuments == 0). |
Trace |
IReadOnlyList<DistributionTrace<TUserId>> |
Audit trail written by each pipeline step (useful for diagnostics). Exposed as a read-only collection. |
To detect a partial allocation:
if (!result.Response.IsFullyAllocated)
Console.WriteLine($"{result.Response.UnallocatedDocuments} item(s) could not be placed — capacity exhausted.");The engine executes the following steps in order:
LoadScoringStep— scores each user as(Capacity − CurrentLoad) / Capacity.WorkingHoursCapacityStep(whenMaxAllowedInProcessDocuments > 0) — recalculates effective capacity for part-time users:effectiveCapacity = Truncate((WorkingHours × MaxAllowedDocs) / FullWorkDayHours). This effective capacity acts as a hard allocation cap: the user cannot receive more items than this value regardless of their rawCapacity. Users withWorkingHours = 0are skipped and their rawCapacityis used as-is.PriorityActivityScoringStep(whenUsePriorityAndActivity = true) — multiplicatively adjusts scores using priority and activity recency.FairAllocationStep— distributes items one at a time. Each round it picks the eligible user with the highestscore × remaining-headroom(headroom = effectiveCapacity − currentLoad − alreadyAllocated this run). When two users tie on that product, the one with the lowerUserIdwins deterministically. This greedy strategy favors the least-loaded, most-available user; it does not round-robin. Constraints (e.g.MaxPerUserConstraint) are enforced before each placement.
var result = await sut.GenerateAsync(users, documents: 5, cancellationToken);The async overload runs the pipeline on a thread-pool thread. Pass a CancellationToken to allow cooperative cancellation. The token is checked between each pipeline step; if it is already cancelled when GenerateAsync is called, the method returns a failed IResult rather than throwing OperationCanceledException.
The following inputs are rejected before the pipeline runs. Each case produces a failed IResult<DistributionResult<TUserId>> whose message describes the problem:
usersisnull— returns a failure with a descriptive message.documentsis negative — returns a failure with a descriptive message.- The
userssequence contains duplicateUserIdvalues — returns a failure. - A
UserLoad<TUserId>is constructed with a negativecurrentLoad,capacity, orworkingHours— the constructor throwsArgumentOutOfRangeException, which the helper catches and surfaces as a failedIResult.
Constructor-level configuration errors throw immediately (not wrapped in IResult):
MaxPerUseris set to a value ≤ 0 — throwsArgumentOutOfRangeException.MaxAllowedInProcessDocuments > 0butFullWorkDayHours ≤ 0— throwsArgumentOutOfRangeException.configisnull— throwsArgumentNullException.
Use EngineConfig<TUserId> (a subclass of EngineConfig) to inject custom pipeline logic without forking the engine.
Pipeline ordering contract: custom steps added via AdditionalSteps run after the built-in scoring and capacity steps and before the terminal FairAllocationStep, which always runs last. Custom constraints added via AdditionalConstraints are merged with the built-in MaxPerUserConstraint (when MaxPerUser is set) and passed to FairAllocationStep together.
When both collections are empty or null, the pipeline behaves identically to the non-generic EngineConfig constructor.
// Custom constraint — blocks a specific user from receiving any items.
public class BlockUserConstraint<TUserId> : IConstraint<TUserId>
{
private readonly TUserId _blockedUser;
public BlockUserConstraint(TUserId blockedUser) => _blockedUser = blockedUser;
public bool CanAllocate(TUserId userId, int currentAllocated)
=> !userId.Equals(_blockedUser);
}
// Custom pipeline step — zeroes a user's score so they are ranked last.
public class ZeroScoreStep<TUserId> : IDistributionStep<TUserId>
where TUserId : struct
{
private readonly TUserId _targetUser;
public ZeroScoreStep(TUserId targetUser) => _targetUser = targetUser;
public IResult Execute(DistributionContext<TUserId> context)
{
if (context.Scores.ContainsKey(_targetUser))
context.Scores[_targetUser] = 0m;
return Result.Success();
}
}
// Wire them up via EngineConfig<TUserId>
var blockedId = Guid.NewGuid();
var deprioritisedId = Guid.NewGuid();
var helper = new DistributionSuggestionHelper<Guid>(new EngineConfig<Guid>
{
MaxPerUser = 10,
AdditionalConstraints = new List<IConstraint<Guid>>
{
new BlockUserConstraint<Guid>(blockedId)
},
AdditionalSteps = new List<IDistributionStep<Guid>>
{
new ZeroScoreStep<Guid>(deprioritisedId)
}
});
var result = helper.Generate(users, documents: 20);This section walks through every change needed to move from the old DistributionSuggestionHelper singleton to the new generic, pipeline-based API.
- using ItemDistribution.Helpers;
- using ItemDistribution.Models.Dto;
+ using RzR.ItemDistribution.Models.Config;
+ using RzR.ItemDistribution.Models.Domain;
+ using RzR.ItemDistribution.Public;- var helper = DistributionSuggestionHelper.Instance;
+ var helper = new DistributionSuggestionHelper<Guid>(new EngineConfig
+ {
+ UsePriorityAndActivity = true,
+ FullWorkDayHours = 8, // was DistributionParams.FullWorkDayHours
+ MaxAllowedInProcessDocuments = 15 // was DistributionParams.MaxAllowedInProcessDocuments
+ });
FullWorkDayHoursandMaxAllowedInProcessDocumentsmove from per-call parameters to engine-level configuration.AvoidDuplicateResultis removed — the alternative user is now always deterministic (second-ranked).
- new UserInfoOptions<Guid>
- {
- UserId = id,
- UserName = "Alice",
- InProcessDocuments = 3,
- WorkingHours = 8,
- UserPriority = 5,
- LastActivityDate = DateTime.Now.AddDays(-1)
- }
+ new UserLoad<Guid>(
+ userId: id,
+ currentLoad: 3, // was InProcessDocuments
+ capacity: 15, // was derived from MaxAllowedInProcessDocuments
+ priority: 5, // was UserPriority
+ lastActivityDate: DateTime.Now.AddDays(-1),
+ workingHours: 8)| v1 property | v2 parameter | Notes |
|---|---|---|
UserId |
userId |
Same. |
UserName |
(removed) | Not needed by the engine. Track externally if needed for display. |
InProcessDocuments |
currentLoad |
Renamed. Same semantics. |
| (derived at runtime) | capacity |
Now explicit — set it to MaxAllowedInProcessDocuments or the user's individual cap. |
UserPriority |
priority |
Renamed. Same semantics (lower = higher priority). |
LastActivityDate |
lastActivityDate |
Same. |
WorkingHours |
workingHours |
Same. |
- var result = helper.GenerateNewDistributionSuggestion(
- new DistributionParams<Guid>
- {
- FullWorkDayHours = 8,
- MaxAllowedInProcessDocuments = 15,
- EligibleRepartitionUsers = users,
- AvoidDuplicateResult = true
- });
+ var result = helper.Generate(users, documents: 1);For async:
- var result = await helper.GenerateNewDistributionSuggestionAsync(params);
+ var result = await helper.GenerateAsync(users, documents: 1, cancellationToken);- if (result.IsSuccess)
- {
- var suggested = result.Response.SuggestionUser.UserName;
- var altUser = result.Response.AlternativeUser?.UserName;
- var inProcess = result.Response.SuggestionUser.InProcessItems;
- }
+ if (result.IsSuccess)
+ {
+ var primaryId = result.Response.PrimaryUserId; // TUserId?
+ var altId = result.Response.AlternativeUserId; // TUserId?
+ var total = result.Response.TotalAllocated; // int
+ var allocation = result.Response.Allocation; // IDictionary<TUserId, int>
+ var trace = result.Response.Trace; // IReadOnlyList<DistributionTrace<TUserId>>
+ }| v1 property | v2 property | Notes |
|---|---|---|
Response.SuggestionUser.SuggestionUserId |
Response.PrimaryUserId |
Top-ranked user. |
Response.AlternativeUser.SuggestionUserId |
Response.AlternativeUserId |
Second-ranked (deterministic, not random). |
Response.SuggestionUser.UserName |
(removed) | Track names externally via a Dictionary<TUserId, string>. |
Response.SuggestionUser.InProcessItems |
(removed) | Already known from UserLoad.CurrentLoad. |
| (not available) | Response.Allocation |
Full distribution map — new in v2. |
| (not available) | Response.TotalAllocated |
Sum of allocated items — new in v2. |
| (not available) | Response.Trace |
Audit trail — new in v2. |
- helper.Dispose();
// DistributionSuggestionHelper<TUserId> is not IDisposable — no cleanup needed.The new API exposes IDistributionSuggestionHelper<TUserId> for dependency injection:
services.AddSingleton<IDistributionSuggestionHelper<Guid>>(
new DistributionSuggestionHelper<Guid>(new EngineConfig
{
UsePriorityAndActivity = true,
FullWorkDayHours = 8,
MaxAllowedInProcessDocuments = 15
}));