added custom format gap selection strategy

This commit is contained in:
Flaminel committed 2026-10-23 01:29:36 +03:00
1 parent 57f7c7e611
commit 37a124fc73
15 files changed
+224 -26

No files matched your search

@@ -131,9 +131,10 @@ public sealed class SeekerPreviewController : ControllerBase
}
InstanceFunnelStat funnel = ToFunnel(evaluation);
SelectionStrategy strategy = SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig);
List<SeekerSearchCandidate> nextUp = waitingUntil is null
? SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, NextUpCount)
? SeekerUnitEvaluator.SelectNext(evaluation, strategy, NextUpCount)
: [];
HashSet<long> nextUpIds = nextUp.Select(c => c.ItemId).ToHashSet();
@@ -147,8 +148,8 @@ public sealed class SeekerPreviewController : ControllerBase
Funnel = funnel,
CycleWaitingUntil = waitingUntil,
RandomPick = randomSeason
|| config.SelectionStrategy is not (SelectionStrategy.OldestSearchFirst or SelectionStrategy.NewestFirst)
|| SeekerUnitEvaluator.HasTiedPicks(evaluation, config.SelectionStrategy, nextUpIds),
|| strategy is not (SelectionStrategy.OldestSearchFirst or SelectionStrategy.NewestFirst)
|| SeekerUnitEvaluator.HasTiedPicks(evaluation, strategy, nextUpIds),
NextUp = nextUp
.Select(c => new SeekerPreviewUnitResponse { Name = c.Name, Reason = c.Reason, ContentKind = c.ContentKind })
.ToList(),
@@ -40,4 +40,12 @@ public enum SelectionStrategy
/// Every eligible item has an equal chance of being selected.
/// </summary>
Random,
/// <summary>
/// Weighted random selection combining the custom format gap and search recency.
/// Half the weight is how far the item sits below its custom format cutoff, with missing items furthest below.
/// The other half is search recency, so items searched long ago still come up.
/// Instances that don't use custom format scores fall back to <see cref="BalancedWeighted"/>.
/// </summary>
CustomFormatGapWeighted,
}
@@ -8,13 +8,13 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
public sealed class ItemSelectorTests
{
private static readonly List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> SampleCandidates =
private static readonly List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> SampleCandidates =
[
(1, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero)),
(2, new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero)),
(3, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), null),
(4, new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero)),
(5, null, new DateTimeOffset(2024, 4, 1, 0, 0, 0, TimeSpan.Zero)),
(1, new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero), 0),
(2, new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 0),
(3, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), null, 0),
(4, new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero), 0),
(5, null, new DateTimeOffset(2024, 4, 1, 0, 0, 0, TimeSpan.Zero), 0),
];
#region ItemSelectorFactory Tests
@@ -26,6 +26,7 @@ public sealed class ItemSelectorTests
[InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))]
[InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))]
[InlineData(SelectionStrategy.Random, typeof(RandomSelector))]
[InlineData(SelectionStrategy.CustomFormatGapWeighted, typeof(CustomFormatGapWeightedSelector))]
public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType)
{
var selector = ItemSelectorFactory.Create(strategy);
@@ -244,7 +245,7 @@ public sealed class ItemSelectorTests
public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new NewestWeightedSelector();
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)];
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)];
var result = selector.Select(single, 1);
@@ -300,7 +301,7 @@ public sealed class ItemSelectorTests
public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new OldestSearchWeightedSelector();
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)];
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)];
var result = selector.Select(single, 1);
@@ -356,7 +357,7 @@ public sealed class ItemSelectorTests
public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate()
{
var selector = new BalancedWeightedSelector();
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> single = [(42, DateTimeOffset.UtcNow, null)];
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> single = [(42, DateTimeOffset.UtcNow, null, 0)];
var result = selector.Select(single, 1);
@@ -377,6 +378,72 @@ public sealed class ItemSelectorTests
#endregion
#region CustomFormatGapWeightedSelector Tests
[Fact]
public void CustomFormatGapWeighted_Select_EmptyInput_ReturnsEmptyList()
{
CustomFormatGapWeightedSelector selector = new();
List<long> result = selector.Select([], 5);
result.ShouldBeEmpty();
}
[Fact]
public void CustomFormatGapWeighted_Select_CountExceedsCandidates_ReturnsAll()
{
CustomFormatGapWeightedSelector selector = new();
List<long> result = selector.Select(SampleCandidates, 100);
result.Count.ShouldBe(5);
}
[Fact]
public void CustomFormatGapWeighted_Select_NoDuplicateIds()
{
CustomFormatGapWeightedSelector selector = new();
List<long> result = selector.Select(SampleCandidates, 5);
result.Distinct().Count().ShouldBe(result.Count);
}
[Fact]
public void CustomFormatGapWeighted_Select_LargeGap_WinsMoreOftenThanZeroGap()
{
CustomFormatGapWeightedSelector selector = new();
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates =
[
(1, null, DateTimeOffset.UtcNow, 50),
(2, null, null, 0),
];
int wins = Enumerable.Range(0, 1000).Count(_ => selector.Select(candidates, 1)[0] == 1);
// Weights 0.75 vs 0.5: expect about 600 wins
wins.ShouldBeGreaterThan(530);
}
[Fact]
public void CustomFormatGapWeighted_Select_NoGaps_FavoursTheOldestSearch()
{
CustomFormatGapWeightedSelector selector = new();
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates =
[
(1, null, DateTimeOffset.UtcNow, 0),
(2, null, DateTimeOffset.UtcNow.AddDays(-30), 0),
];
int wins = Enumerable.Range(0, 1000).Count(_ => selector.Select(candidates, 1)[0] == 2);
// Weights 0.5 vs 0.25: expect about 667 wins
wins.ShouldBeGreaterThan(590);
}
#endregion
#region WeightedRandomByRank Tests
[Fact]
@@ -1192,6 +1192,42 @@ public class SeekerUnitEvaluatorTests : IDisposable
picked.ShouldBe([1L, 2L], ignoreOrder: true);
}
[Fact]
public async Task SelectNext_CustomFormatGapWeighted_FavoursTheMissingMovie()
{
// Arrange
SearchableMovie upgradable = Movie(2, hasFile: true);
SetupRadarr([Movie(1), upgradable]);
_radarrClient.GetCutoffUnmetMoviesAsync(Arg.Any<ArrInstance>(), true, Arg.Any<CancellationToken>())
.Returns([upgradable]);
SeekerEvaluation evaluation = await EvaluateRadarrAsync(InstanceConfig(_radarr, c => c.UseCutoff = true));
// Act
int missingPicks = Enumerable.Range(0, 1000)
.Count(_ => SeekerUnitEvaluator.SelectNext(evaluation, SelectionStrategy.CustomFormatGapWeighted, 1)[0].ItemId == 1);
// Assert
// The missing movie takes 60% or 80% of the weight, depending on the shuffled recency rank
missingPicks.ShouldBeGreaterThan(600);
}
[Theory]
[InlineData(SelectionStrategy.CustomFormatGapWeighted, false, SelectionStrategy.BalancedWeighted)]
[InlineData(SelectionStrategy.CustomFormatGapWeighted, true, SelectionStrategy.CustomFormatGapWeighted)]
[InlineData(SelectionStrategy.NewestFirst, false, SelectionStrategy.NewestFirst)]
public void EffectiveStrategy_FallsBackToBalancedOnlyForTheGapStrategyWithoutCustomFormatScores(
SelectionStrategy strategy, bool useCustomFormatScore, SelectionStrategy expected)
{
// Arrange
SeekerInstanceConfig instanceConfig = InstanceConfig(_radarr, c => c.UseCustomFormatScore = useCustomFormatScore);
// Act
SelectionStrategy effective = SeekerUnitEvaluator.EffectiveStrategy(strategy, instanceConfig);
// Assert
effective.ShouldBe(expected);
}
[Fact]
public async Task HasTiedPicks_OldestSearchFirst_NeverSearchedMovies_ReturnsTrue()
{
@@ -571,7 +571,8 @@ public sealed class Seeker : IHandler
}
}
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(evaluation, config.SelectionStrategy, 1);
List<SeekerSearchCandidate> candidates = SeekerUnitEvaluator.SelectNext(
evaluation, SeekerUnitEvaluator.EffectiveStrategy(config.SelectionStrategy, instanceConfig), 1);
foreach (SeekerSearchCandidate candidate in candidates)
{
@@ -8,8 +8,8 @@ public interface IItemSelector
/// <summary>
/// Selects up to <paramref name="count"/> item IDs from the candidates
/// </summary>
/// <param name="candidates">List of (id, dateAdded, lastSearched) tuples</param>
/// <param name="candidates">List of (id, dateAdded, lastSearched, customFormatGap) tuples</param>
/// <param name="count">Maximum number of items to select</param>
/// <returns>Selected item IDs</returns>
List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count);
List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count);
}
@@ -18,6 +18,7 @@ public static class ItemSelectorFactory
SelectionStrategy.NewestWeighted => new NewestWeightedSelector(),
SelectionStrategy.BalancedWeighted => new BalancedWeightedSelector(),
SelectionStrategy.Random => new RandomSelector(),
SelectionStrategy.CustomFormatGapWeighted => new CustomFormatGapWeightedSelector(),
_ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown selection strategy")
};
}
@@ -109,6 +109,16 @@ public sealed class SeekerUnitEvaluator
recentGrabs.Episodes, currentCycleHistory, lastSearchedByItem, cancellationToken);
}
/// <summary>
/// The strategy the instance runs: <see cref="SelectionStrategy.CustomFormatGapWeighted"/> needs custom format scores.
/// </summary>
public static SelectionStrategy EffectiveStrategy(SelectionStrategy strategy, SeekerInstanceConfig instanceConfig)
{
return strategy is SelectionStrategy.CustomFormatGapWeighted && !instanceConfig.UseCustomFormatScore
? SelectionStrategy.BalancedWeighted
: strategy;
}
/// <summary>
/// Picks up to <paramref name="count"/> unsearched units, at most one per movie or series.
/// </summary>
@@ -118,7 +128,7 @@ public sealed class SeekerUnitEvaluator
.GroupBy(u => u.ItemId)
.ToDictionary(g => g.Key, g => g.ToList());
(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)[] selectionCandidates = BuildSelectionCandidates(evaluation, remainingByItem);
(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)[] selectionCandidates = BuildSelectionCandidates(evaluation, remainingByItem);
// The selectors sort stably, so unshuffled ties would favour the arr's lowest ids
Random.Shared.Shuffle(selectionCandidates);
@@ -143,7 +153,7 @@ public sealed class SeekerUnitEvaluator
.Any(g => g.Count() > 1 && g.Any(c => pickedIds.Contains(c.Id)));
}
private static (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)[] BuildSelectionCandidates(
private static (long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)[] BuildSelectionCandidates(
SeekerEvaluation evaluation,
Dictionary<long, List<SeekerSearchUnit>> remainingByItem)
{
@@ -151,12 +161,25 @@ public sealed class SeekerUnitEvaluator
.GroupBy(u => u.ItemId)
.ToDictionary(g => g.Key, g => g.Max(u => u.LastSearchTime));
HashSet<long> missingItemIds = remainingByItem
.Where(p => p.Value.Any(u => u.Reasons.Contains(SeekerSearchReason.Missing)))
.Select(p => p.Key)
.ToHashSet();
// Missing items rank as furthest below the cutoff
int missingGap = Math.Max(1, remainingByItem
.Where(p => !missingItemIds.Contains(p.Key))
.Select(p => p.Value.Max(u => u.CustomFormatGap))
.DefaultIfEmpty(0)
.Max());
return remainingByItem
.Select(p =>
{
DateTimeOffset? cycleSearched = evaluation.LastSearchedByItem.TryGetValue(p.Key, out DateTimeOffset at) ? at : null;
DateTimeOffset? lastSearched = new[] { arrSearchedByItem[p.Key], cycleSearched }.Max();
return (p.Key, p.Value[0].Added, lastSearched);
int gap = missingItemIds.Contains(p.Key) ? missingGap : p.Value.Max(u => u.CustomFormatGap);
return (p.Key, p.Value[0].Added, lastSearched, (double)gap);
})
.ToArray();
}
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class BalancedWeightedSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
if (candidates.Count == 0)
{
@@ -0,0 +1,61 @@
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// <summary>
/// Selects items using weighted random sampling that combines the custom format gap and search recency.
/// Half the weight is the item's gap relative to the largest gap in the pool, half is its search recency rank.
/// Items far below their custom format cutoff that haven't been searched recently get the highest combined weight.
/// </summary>
public sealed class CustomFormatGapWeightedSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
if (candidates.Count == 0)
{
return [];
}
count = Math.Min(count, candidates.Count);
int n = candidates.Count;
Dictionary<long, int> searchRanks = candidates
.OrderBy(c => c.LastSearched ?? DateTimeOffset.MinValue)
.Select((c, index) => (c.Id, Rank: n - index))
.ToDictionary(x => x.Id, x => x.Rank);
double maxGap = candidates.Max(c => c.Gap);
// The recency half is at least 1/n, so every weight stays above zero
List<long> selected = new(count);
List<(long Id, double Weight)> pool = candidates
.Select(c => (c.Id, Weight: 0.5 * (maxGap > 0 ? c.Gap / maxGap : 0) + 0.5 * searchRanks[c.Id] / n))
.ToList();
for (int i = 0; i < count && pool.Count > 0; i++)
{
double totalWeight = 0;
for (int j = 0; j < pool.Count; j++)
{
totalWeight += pool[j].Weight;
}
double target = Random.Shared.NextDouble() * totalWeight;
double cumulative = 0;
int selectedIndex = pool.Count - 1;
for (int j = 0; j < pool.Count; j++)
{
cumulative += pool[j].Weight;
if (cumulative >= target)
{
selectedIndex = j;
break;
}
}
selected.Add(pool[selectedIndex].Id);
pool.RemoveAt(selectedIndex);
}
return selected;
}
}
@@ -6,7 +6,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class NewestFirstSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
return candidates
.OrderByDescending(c => c.Added ?? DateTimeOffset.MinValue)
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class NewestWeightedSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
if (candidates.Count == 0)
{
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class OldestSearchFirstSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
return candidates
.OrderBy(c => c.LastSearched ?? DateTimeOffset.MinValue)
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class OldestSearchWeightedSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
if (candidates.Count == 0)
{
@@ -29,7 +29,7 @@ public sealed class OldestSearchWeightedSelector : IItemSelector
/// The item at rank 0 gets the highest weight (N), rank 1 gets (N-1), etc.
/// </summary>
internal static List<long> WeightedRandomByRank(
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> ranked,
List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> ranked,
int count)
{
int n = ranked.Count;
@@ -6,10 +6,10 @@ namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
/// </summary>
public sealed class RandomSelector : IItemSelector
{
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)> candidates, int count)
public List<long> Select(List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)> candidates, int count)
{
count = Math.Min(count, candidates.Count);
var shuffled = new List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched)>(candidates);
var shuffled = new List<(long Id, DateTimeOffset? Added, DateTimeOffset? LastSearched, double Gap)>(candidates);
// Fisher-Yates shuffle
for (int i = 0; i < count; i++)