Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Configuration for the unknowns rescan background worker.
/// </summary>
public sealed class UnknownsRescanOptions
{
public const string SectionName = "Signals:UnknownsRescan";
/// <summary>
/// Whether the rescan worker is enabled. Default: true
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Poll interval for checking due items. Default: 60 seconds
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Maximum HOT items to process per poll cycle. Default: 50
/// </summary>
public int HotBatchSize { get; set; } = 50;
/// <summary>
/// Maximum WARM items to process per poll cycle. Default: 100
/// </summary>
public int WarmBatchSize { get; set; } = 100;
/// <summary>
/// Maximum COLD items to process in weekly batch. Default: 500
/// </summary>
public int ColdBatchSize { get; set; } = 500;
/// <summary>
/// Day of week for COLD batch processing. Default: Sunday
/// </summary>
public DayOfWeek ColdBatchDay { get; set; } = DayOfWeek.Sunday;
/// <summary>
/// Hour (UTC) for COLD batch processing. Default: 3 (3 AM UTC)
/// </summary>
public int ColdBatchHourUtc { get; set; } = 3;
}

View File

@@ -251,6 +251,102 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
return true;
}
private static bool TryParseFlatGraph(JsonElement root, out CallgraphParseResult result)
{
result = default!;
// Flat graph format: array of edges only, nodes derived from edge endpoints
if (root.ValueKind != JsonValueKind.Array)
{
return false;
}
var edges = new List<CallgraphEdge>();
var uniqueNodeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var edgeElement in root.EnumerateArray())
{
var source = GetString(edgeElement, "source", "from");
var target = GetString(edgeElement, "target", "to");
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target))
{
continue;
}
uniqueNodeIds.Add(source.Trim());
uniqueNodeIds.Add(target.Trim());
edges.Add(new CallgraphEdge
{
SourceId = source.Trim(),
TargetId = target.Trim(),
Type = GetString(edgeElement, "type", "kind") ?? "call",
Purl = GetString(edgeElement, "purl"),
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
});
}
if (edges.Count == 0)
{
return false;
}
var nodes = new List<CallgraphNode>();
foreach (var nodeId in uniqueNodeIds)
{
nodes.Add(new CallgraphNode { Id = nodeId, Name = nodeId, Kind = "function" });
}
result = new CallgraphParseResult(
nodes,
edges,
Array.Empty<CallgraphRoot>(),
"1.0",
"1.0",
null);
return true;
}
private static IReadOnlyList<CallgraphEntrypoint> ParseEntrypoints(JsonElement root)
{
if (!root.TryGetProperty("entrypoints", out var entrypointsEl) || entrypointsEl.ValueKind != JsonValueKind.Array)
{
return Array.Empty<CallgraphEntrypoint>();
}
var entrypoints = new List<CallgraphEntrypoint>(entrypointsEl.GetArrayLength());
var order = 0;
foreach (var ep in entrypointsEl.EnumerateArray())
{
var nodeId = GetString(ep, "nodeId", "node_id");
if (string.IsNullOrWhiteSpace(nodeId))
{
continue;
}
var kindStr = GetString(ep, "kind") ?? "unknown";
var phaseStr = GetString(ep, "phase") ?? "runtime";
var frameworkStr = GetString(ep, "framework") ?? "unknown";
entrypoints.Add(new CallgraphEntrypoint
{
NodeId = nodeId.Trim(),
Kind = Enum.TryParse<EntrypointKind>(kindStr, true, out var kind) ? kind : EntrypointKind.Unknown,
Phase = Enum.TryParse<EntrypointPhase>(phaseStr, true, out var phase) ? phase : EntrypointPhase.Runtime,
Framework = Enum.TryParse<EntrypointFramework>(frameworkStr, true, out var framework) ? framework : EntrypointFramework.Unknown,
Route = GetString(ep, "route"),
HttpMethod = GetString(ep, "httpMethod", "http_method"),
Source = GetString(ep, "source"),
Order = order++
});
}
return entrypoints;
}
private static IReadOnlyList<CallgraphRoot> ParseRoots(JsonElement root)
{
if (!root.TryGetProperty("roots", out var rootsEl) || rootsEl.ValueKind != JsonValueKind.Array)

View File

@@ -28,4 +28,18 @@ public interface IUnknownsRepository
UnknownsBand band,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Queries unknowns with optional band filter and pagination.
/// </summary>
Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
UnknownsBand? band,
int limit,
int offset,
CancellationToken cancellationToken);
/// <summary>
/// Gets a single unknown by its ID.
/// </summary>
Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken);
}

View File

@@ -94,6 +94,44 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(results);
}
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
UnknownsBand? band,
int limit,
int offset,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var query = _store.Values.SelectMany(x => x);
if (band.HasValue)
{
query = query.Where(u => u.Band == band.Value);
}
var results = query
.OrderByDescending(u => u.Score)
.ThenBy(u => u.Id, StringComparer.OrdinalIgnoreCase)
.Skip(offset)
.Take(limit)
.Select(Clone)
.ToList();
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(results);
}
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
cancellationToken.ThrowIfCancellationRequested();
var item = _store.Values
.SelectMany(x => x)
.FirstOrDefault(u => string.Equals(u.Id, id, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(item is not null ? Clone(item) : null);
}
private static UnknownSymbolDocument Clone(UnknownSymbolDocument source) => new()
{
Id = source.Id,

View File

@@ -743,6 +743,91 @@ signalsGroup.MapGet("/unknowns/{subjectKey}", async Task<IResult> (
return items.Count == 0 ? Results.NotFound() : Results.Ok(items);
}).WithName("SignalsUnknownsGet");
signalsGroup.MapGet("/unknowns", async Task<IResult> (
HttpContext context,
SignalsOptions options,
IUnknownsRepository repository,
SignalsSealedModeMonitor sealedModeMonitor,
[FromQuery] string? band,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
CancellationToken cancellationToken = default) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
{
return authFailure ?? Results.Unauthorized();
}
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
limit = Math.Clamp(limit, 1, 1000);
offset = Math.Max(0, offset);
UnknownsBand? bandFilter = null;
if (!string.IsNullOrWhiteSpace(band) && Enum.TryParse<UnknownsBand>(band, ignoreCase: true, out var parsedBand))
{
bandFilter = parsedBand;
}
var items = await repository.QueryAsync(bandFilter, limit, offset, cancellationToken).ConfigureAwait(false);
return Results.Ok(new
{
items,
count = items.Count,
limit,
offset,
band = bandFilter?.ToString().ToLowerInvariant()
});
}).WithName("SignalsUnknownsQuery");
signalsGroup.MapGet("/unknowns/{id}/explain", async Task<IResult> (
HttpContext context,
SignalsOptions options,
string id,
IUnknownsRepository repository,
IUnknownsScoringService scoringService,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
{
return authFailure ?? Results.Unauthorized();
}
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
if (string.IsNullOrWhiteSpace(id))
{
return Results.BadRequest(new { error = "id is required." });
}
var unknown = await repository.GetByIdAsync(id.Trim(), cancellationToken).ConfigureAwait(false);
if (unknown is null)
{
return Results.NotFound(new { error = $"Unknown with id '{id}' not found." });
}
return Results.Ok(new
{
id = unknown.Id,
subjectKey = unknown.SubjectKey,
band = unknown.Band.ToString().ToLowerInvariant(),
score = unknown.Score,
normalizationTrace = unknown.NormalizationTrace,
flags = unknown.Flags,
nextScheduledRescan = unknown.NextScheduledRescan,
rescanAttempts = unknown.RescanAttempts,
createdAt = unknown.CreatedAt,
updatedAt = unknown.UpdatedAt
});
}).WithName("SignalsUnknownsExplain");
signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
HttpContext context,
SignalsOptions options,

View File

@@ -330,20 +330,6 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
return ordered.ToString();
}
private static string JoinDict(IReadOnlyDictionary<string, string>? values)
{
if (values is null)
{
return string.Empty;
}
var ordered = new StringBuilder();
foreach (var kv in values.OrderBy(k => k.Key, StringComparer.Ordinal))
{
ordered.Append(kv.Key).Append('=').Append(kv.Value).Append(';');
}
return ordered.ToString();
}
}
/// <summary>

View File

@@ -0,0 +1,64 @@
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Orchestrates rescan operations for unknowns.
/// </summary>
public interface IRescanOrchestrator
{
/// <summary>
/// Triggers a rescan for a single unknown item.
/// </summary>
Task<RescanResult> TriggerRescanAsync(
UnknownSymbolDocument unknown,
RescanPriority priority,
CancellationToken cancellationToken = default);
/// <summary>
/// Triggers a batch rescan for multiple unknown items.
/// </summary>
Task<BatchRescanResult> TriggerBatchRescanAsync(
IReadOnlyList<UnknownSymbolDocument> unknowns,
RescanPriority priority,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Priority level for rescan operations.
/// </summary>
public enum RescanPriority
{
/// <summary>
/// Immediate processing for HOT items.
/// </summary>
Immediate,
/// <summary>
/// Scheduled processing for WARM items.
/// </summary>
Scheduled,
/// <summary>
/// Batch processing for COLD items.
/// </summary>
Batch
}
/// <summary>
/// Result of a single rescan operation.
/// </summary>
public sealed record RescanResult(
string UnknownId,
bool Success,
string? ErrorMessage = null,
DateTimeOffset? NextScheduledRescan = null);
/// <summary>
/// Result of a batch rescan operation.
/// </summary>
public sealed record BatchRescanResult(
int TotalRequested,
int SuccessCount,
int FailureCount,
IReadOnlyList<RescanResult> Results);

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Logging-only implementation of <see cref="IRescanOrchestrator"/>.
/// Placeholder until actual rescan integration is implemented.
/// </summary>
public sealed class LoggingRescanOrchestrator : IRescanOrchestrator
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<LoggingRescanOrchestrator> _logger;
public LoggingRescanOrchestrator(
TimeProvider timeProvider,
ILogger<LoggingRescanOrchestrator> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<RescanResult> TriggerRescanAsync(
UnknownSymbolDocument unknown,
RescanPriority priority,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknown);
_logger.LogInformation(
"Rescan triggered for unknown {UnknownId} with priority {Priority} (Band={Band}, Score={Score:F2})",
unknown.Id,
priority,
unknown.Band,
unknown.Score);
// Calculate next rescan time based on priority
var nextRescan = priority switch
{
RescanPriority.Immediate => _timeProvider.GetUtcNow().AddMinutes(15),
RescanPriority.Scheduled => _timeProvider.GetUtcNow().AddHours(24),
_ => _timeProvider.GetUtcNow().AddDays(7)
};
return Task.FromResult(new RescanResult(
unknown.Id,
Success: true,
NextScheduledRescan: nextRescan));
}
public Task<BatchRescanResult> TriggerBatchRescanAsync(
IReadOnlyList<UnknownSymbolDocument> unknowns,
RescanPriority priority,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknowns);
_logger.LogInformation(
"Batch rescan triggered for {Count} unknowns with priority {Priority}",
unknowns.Count,
priority);
var results = new List<RescanResult>(unknowns.Count);
var nextRescan = priority switch
{
RescanPriority.Immediate => _timeProvider.GetUtcNow().AddMinutes(15),
RescanPriority.Scheduled => _timeProvider.GetUtcNow().AddHours(24),
_ => _timeProvider.GetUtcNow().AddDays(7)
};
foreach (var unknown in unknowns)
{
results.Add(new RescanResult(
unknown.Id,
Success: true,
NextScheduledRescan: nextRescan));
}
return Task.FromResult(new BatchRescanResult(
TotalRequested: unknowns.Count,
SuccessCount: unknowns.Count,
FailureCount: 0,
Results: results));
}
}

View File

@@ -0,0 +1,107 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Signals.Services;
/// <summary>
/// Metrics for unknowns rescan operations and band distribution.
/// </summary>
internal static class UnknownsRescanMetrics
{
private static readonly Meter Meter = new("StellaOps.Signals.Rescan", "1.0.0");
// ===== RESCAN COUNTERS =====
public static readonly Counter<long> RescansTriggered = Meter.CreateCounter<long>(
"stellaops_unknowns_rescans_triggered_total",
description: "Total rescans triggered by band");
public static readonly Counter<long> RescansSucceeded = Meter.CreateCounter<long>(
"stellaops_unknowns_rescans_succeeded_total",
description: "Total successful rescans by band");
public static readonly Counter<long> RescansFailed = Meter.CreateCounter<long>(
"stellaops_unknowns_rescans_failed_total",
description: "Total failed rescans by band");
// ===== BATCH COUNTERS =====
public static readonly Counter<long> HotBatchesProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_hot_batches_processed_total",
description: "Total HOT band batch processing cycles");
public static readonly Counter<long> WarmBatchesProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_warm_batches_processed_total",
description: "Total WARM band batch processing cycles");
public static readonly Counter<long> ColdBatchesProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_cold_batches_processed_total",
description: "Total COLD band weekly batch runs");
// ===== TIMING HISTOGRAMS =====
public static readonly Histogram<double> RescanDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_unknowns_rescan_duration_seconds",
unit: "s",
description: "Duration of individual rescan operations");
public static readonly Histogram<double> BatchDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_unknowns_rescan_batch_duration_seconds",
unit: "s",
description: "Duration of rescan batch cycles");
// ===== BAND DISTRIBUTION =====
public static readonly ObservableGauge<int> HotCount = Meter.CreateObservableGauge(
"stellaops_unknowns_band_hot_count",
() => _hotCount,
description: "Current count of HOT band unknowns");
public static readonly ObservableGauge<int> WarmCount = Meter.CreateObservableGauge(
"stellaops_unknowns_band_warm_count",
() => _warmCount,
description: "Current count of WARM band unknowns");
public static readonly ObservableGauge<int> ColdCount = Meter.CreateObservableGauge(
"stellaops_unknowns_band_cold_count",
() => _coldCount,
description: "Current count of COLD band unknowns");
// Band distribution state (updated by scoring service)
private static int _hotCount;
private static int _warmCount;
private static int _coldCount;
/// <summary>
/// Updates the band distribution gauges.
/// </summary>
public static void SetBandDistribution(int hot, int warm, int cold)
{
Interlocked.Exchange(ref _hotCount, hot);
Interlocked.Exchange(ref _warmCount, warm);
Interlocked.Exchange(ref _coldCount, cold);
}
/// <summary>
/// Records a rescan trigger with band tag.
/// </summary>
public static void RecordRescanTriggered(string band)
{
RescansTriggered.Add(1, new KeyValuePair<string, object?>("band", band));
}
/// <summary>
/// Records a successful rescan with band tag.
/// </summary>
public static void RecordRescanSuccess(string band)
{
RescansSucceeded.Add(1, new KeyValuePair<string, object?>("band", band));
}
/// <summary>
/// Records a failed rescan with band tag.
/// </summary>
public static void RecordRescanFailure(string band)
{
RescansFailed.Add(1, new KeyValuePair<string, object?>("band", band));
}
}

View File

@@ -0,0 +1,263 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
/// <summary>
/// Background worker that processes unknowns rescans based on band scheduling.
/// HOT items are processed immediately, WARM items on schedule, COLD items in weekly batches.
/// </summary>
public sealed class UnknownsRescanWorker : BackgroundService
{
private readonly IUnknownsRepository _repository;
private readonly IRescanOrchestrator _orchestrator;
private readonly IOptions<UnknownsRescanOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UnknownsRescanWorker> _logger;
public UnknownsRescanWorker(
IUnknownsRepository repository,
IRescanOrchestrator orchestrator,
IOptions<UnknownsRescanOptions> options,
TimeProvider timeProvider,
ILogger<UnknownsRescanWorker> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("Unknowns rescan worker is disabled.");
return;
}
_logger.LogInformation(
"Unknowns rescan worker started. Poll interval: {PollInterval}",
opts.PollInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessHotBandAsync(opts, stoppingToken).ConfigureAwait(false);
await ProcessWarmBandAsync(opts, stoppingToken).ConfigureAwait(false);
await ProcessColdBandAsync(opts, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in unknowns rescan worker cycle.");
}
try
{
await Task.Delay(opts.PollInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Unknowns rescan worker stopping.");
}
private async Task ProcessHotBandAsync(UnknownsRescanOptions opts, CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
var hotItems = await _repository.GetDueForRescanAsync(
UnknownsBand.Hot,
opts.HotBatchSize,
cancellationToken).ConfigureAwait(false);
if (hotItems.Count == 0)
{
return;
}
_logger.LogInformation(
"Processing {Count} HOT unknowns for immediate rescan.",
hotItems.Count);
foreach (var item in hotItems)
{
UnknownsRescanMetrics.RecordRescanTriggered("hot");
try
{
var result = await _orchestrator.TriggerRescanAsync(
item,
RescanPriority.Immediate,
cancellationToken).ConfigureAwait(false);
if (result.Success)
{
UnknownsRescanMetrics.RecordRescanSuccess("hot");
_logger.LogDebug(
"HOT unknown {UnknownId} rescan triggered successfully.",
item.Id);
}
else
{
UnknownsRescanMetrics.RecordRescanFailure("hot");
_logger.LogWarning(
"HOT unknown {UnknownId} rescan failed: {Error}",
item.Id,
result.ErrorMessage);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
UnknownsRescanMetrics.RecordRescanFailure("hot");
_logger.LogError(ex, "Failed to trigger rescan for HOT unknown {UnknownId}.", item.Id);
}
}
sw.Stop();
UnknownsRescanMetrics.HotBatchesProcessed.Add(1);
UnknownsRescanMetrics.BatchDurationSeconds.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("band", "hot"));
}
private async Task ProcessWarmBandAsync(UnknownsRescanOptions opts, CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
var warmItems = await _repository.GetDueForRescanAsync(
UnknownsBand.Warm,
opts.WarmBatchSize,
cancellationToken).ConfigureAwait(false);
if (warmItems.Count == 0)
{
return;
}
_logger.LogInformation(
"Processing {Count} WARM unknowns for scheduled rescan.",
warmItems.Count);
foreach (var item in warmItems)
{
UnknownsRescanMetrics.RecordRescanTriggered("warm");
try
{
var result = await _orchestrator.TriggerRescanAsync(
item,
RescanPriority.Scheduled,
cancellationToken).ConfigureAwait(false);
if (result.Success)
{
UnknownsRescanMetrics.RecordRescanSuccess("warm");
_logger.LogDebug(
"WARM unknown {UnknownId} rescan scheduled.",
item.Id);
}
else
{
UnknownsRescanMetrics.RecordRescanFailure("warm");
_logger.LogWarning(
"WARM unknown {UnknownId} rescan scheduling failed: {Error}",
item.Id,
result.ErrorMessage);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
UnknownsRescanMetrics.RecordRescanFailure("warm");
_logger.LogError(ex, "Failed to schedule rescan for WARM unknown {UnknownId}.", item.Id);
}
}
sw.Stop();
UnknownsRescanMetrics.WarmBatchesProcessed.Add(1);
UnknownsRescanMetrics.BatchDurationSeconds.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("band", "warm"));
}
private async Task ProcessColdBandAsync(UnknownsRescanOptions opts, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
// COLD items are processed in weekly batches on the configured day and hour
if (now.DayOfWeek != opts.ColdBatchDay)
{
return;
}
// Only process once per day during the configured hour
if (now.Hour != opts.ColdBatchHourUtc)
{
return;
}
var sw = Stopwatch.StartNew();
var coldItems = await _repository.GetDueForRescanAsync(
UnknownsBand.Cold,
opts.ColdBatchSize,
cancellationToken).ConfigureAwait(false);
if (coldItems.Count == 0)
{
_logger.LogDebug("No COLD unknowns due for weekly batch processing.");
return;
}
_logger.LogInformation(
"Processing weekly COLD batch: {Count} unknowns.",
coldItems.Count);
try
{
foreach (var item in coldItems)
{
UnknownsRescanMetrics.RecordRescanTriggered("cold");
}
var result = await _orchestrator.TriggerBatchRescanAsync(
coldItems,
RescanPriority.Batch,
cancellationToken).ConfigureAwait(false);
// Record success/failure metrics
for (var i = 0; i < result.SuccessCount; i++)
{
UnknownsRescanMetrics.RecordRescanSuccess("cold");
}
for (var i = 0; i < result.FailureCount; i++)
{
UnknownsRescanMetrics.RecordRescanFailure("cold");
}
_logger.LogInformation(
"COLD batch completed: {Success}/{Total} succeeded.",
result.SuccessCount,
result.TotalRequested);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
foreach (var item in coldItems)
{
UnknownsRescanMetrics.RecordRescanFailure("cold");
}
_logger.LogError(ex, "Failed to process COLD batch rescan.");
}
sw.Stop();
UnknownsRescanMetrics.ColdBatchesProcessed.Add(1);
UnknownsRescanMetrics.BatchDurationSeconds.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("band", "cold"));
}
}

View File

@@ -264,5 +264,30 @@ public class ReachabilityScoringServiceTests
{
return Task.FromResult(Stored.Count);
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = Stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
Stored[existing] = item;
else
Stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
Stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
}

View File

@@ -0,0 +1,514 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class UnknownsDecayServiceTests
{
private readonly MockTimeProvider _timeProvider;
private readonly InMemoryUnknownsRepository _unknownsRepo;
private readonly InMemoryDeploymentRefsRepository _deploymentRefs;
private readonly InMemoryGraphMetricsRepository _graphMetrics;
private readonly UnknownsScoringOptions _scoringOptions;
private readonly UnknownsDecayOptions _decayOptions;
public UnknownsDecayServiceTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new InMemoryUnknownsRepository();
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_scoringOptions = new UnknownsScoringOptions();
_decayOptions = new UnknownsDecayOptions();
}
private (UnknownsDecayService DecayService, UnknownsScoringService ScoringService) CreateServices()
{
var scoringService = new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(_scoringOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(_decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.Instance);
return (decayService, scoringService);
}
#region ApplyDecayAsync Tests
[Fact]
public async Task ApplyDecayAsync_EmptySubject_ReturnsZeroCounts()
{
var (decayService, _) = CreateServices();
var result = await decayService.ApplyDecayAsync("empty|1.0.0", CancellationToken.None);
Assert.Equal("empty|1.0.0", result.SubjectKey);
Assert.Equal(0, result.ProcessedCount);
Assert.Equal(0, result.HotCount);
Assert.Equal(0, result.WarmCount);
Assert.Equal(0, result.ColdCount);
Assert.Equal(0, result.BandChanges);
}
[Fact]
public async Task ApplyDecayAsync_SingleUnknown_UpdatesAndPersists()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10),
Band = UnknownsBand.Cold
};
await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(1, result.ProcessedCount);
Assert.Equal(subjectKey, result.SubjectKey);
// Verify the unknown was updated in the repository
var updated = await _unknownsRepo.GetBySubjectAsync(subjectKey, CancellationToken.None);
Assert.Single(updated);
Assert.True(updated[0].UpdatedAt >= now);
}
[Fact]
public async Task ApplyDecayAsync_BandChangesTracked()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
// Create unknown that will change from COLD to HOT due to high staleness and flags
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20),
Band = UnknownsBand.Cold // Initially cold
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
await _unknownsRepo.UpsertAsync(subjectKey, new[] { unknown }, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
// Band should have changed from COLD to HOT
if (result.HotCount > 0)
{
Assert.Equal(1, result.BandChanges);
}
}
[Fact]
public async Task ApplyDecayAsync_MultipleUnknowns_ProcessesAll()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
var unknowns = new[]
{
new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1),
Band = UnknownsBand.Cold
},
new UnknownSymbolDocument
{
Id = "unknown-2",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10),
Band = UnknownsBand.Warm
},
new UnknownSymbolDocument
{
Id = "unknown-3",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags { NoProvenanceAnchor = true, VersionRange = true },
CreatedAt = now.AddDays(-20),
Band = UnknownsBand.Hot
}
};
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(3, result.ProcessedCount);
Assert.Equal(result.HotCount + result.WarmCount + result.ColdCount, result.ProcessedCount);
}
#endregion
#region RunNightlyDecayBatchAsync Tests
[Fact]
public async Task RunNightlyDecayBatchAsync_ProcessesAllSubjects()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u1",
SubjectKey = "subject-1|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u2",
SubjectKey = "subject-2|1.0.0",
LastAnalyzedAt = now.AddDays(-3),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-5)
}
}, CancellationToken.None);
var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None);
Assert.Equal(2, result.TotalSubjects);
Assert.Equal(2, result.TotalUnknowns);
Assert.True(result.Duration >= TimeSpan.Zero);
}
[Fact]
public async Task RunNightlyDecayBatchAsync_RespectsMaxSubjectsLimit()
{
var decayOptions = new UnknownsDecayOptions { MaxSubjectsPerBatch = 1 };
var scoringService = new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(_scoringOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
var decayService = new UnknownsDecayService(
_unknownsRepo,
scoringService,
MsOptions.Options.Create(_scoringOptions),
MsOptions.Options.Create(decayOptions),
_timeProvider,
NullLogger<UnknownsDecayService>.Instance);
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
await _unknownsRepo.UpsertAsync("subject-1|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u1",
SubjectKey = "subject-1|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
await _unknownsRepo.UpsertAsync("subject-2|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = "u2",
SubjectKey = "subject-2|1.0.0",
LastAnalyzedAt = now.AddDays(-3),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-5)
}
}, CancellationToken.None);
var result = await decayService.RunNightlyDecayBatchAsync(CancellationToken.None);
// Should only process 1 subject due to limit
Assert.Equal(1, result.TotalSubjects);
Assert.Equal(1, result.TotalUnknowns);
}
[Fact]
public async Task RunNightlyDecayBatchAsync_CancellationRespected()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknowns in multiple subjects
for (int i = 0; i < 10; i++)
{
await _unknownsRepo.UpsertAsync($"subject-{i}|1.0.0", new[]
{
new UnknownSymbolDocument
{
Id = $"u{i}",
SubjectKey = $"subject-{i}|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
}
}, CancellationToken.None);
}
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
decayService.RunNightlyDecayBatchAsync(cts.Token));
}
#endregion
#region ApplyDecayToUnknownAsync Tests
[Fact]
public async Task ApplyDecayToUnknownAsync_UpdatesScoringFields()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10),
Score = 0,
Band = UnknownsBand.Cold
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var result = await decayService.ApplyDecayToUnknownAsync(unknown, CancellationToken.None);
// Verify scoring fields were updated
Assert.True(result.Score > 0);
Assert.True(result.PopularityScore > 0);
Assert.True(result.StalenessScore > 0);
Assert.True(result.UncertaintyScore > 0);
Assert.NotNull(result.NextScheduledRescan);
Assert.NotNull(result.NormalizationTrace);
}
[Fact]
public async Task ApplyDecayToUnknownAsync_SetsNextRescanBasedOnBand()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
// Create unknown that will be scored as COLD
var coldUnknown = new UnknownSymbolDocument
{
Id = "cold-unknown",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now, // Fresh
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
};
var result = await decayService.ApplyDecayToUnknownAsync(coldUnknown, CancellationToken.None);
Assert.Equal(UnknownsBand.Cold, result.Band);
Assert.Equal(now.AddDays(_scoringOptions.ColdRescanDays), result.NextScheduledRescan);
}
#endregion
#region Decay Result Aggregation Tests
[Fact]
public async Task ApplyDecayAsync_ResultCountsAreAccurate()
{
var (decayService, _) = CreateServices();
var now = _timeProvider.GetUtcNow();
const string subjectKey = "test|1.0.0";
// Create unknowns that will end up in different bands
var unknowns = new List<UnknownSymbolDocument>();
// This will be COLD (fresh, no flags)
unknowns.Add(new UnknownSymbolDocument
{
Id = "cold-1",
SubjectKey = subjectKey,
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
});
// Add more with varying staleness and flags
for (int i = 0; i < 5; i++)
{
unknowns.Add(new UnknownSymbolDocument
{
Id = $"unknown-{i}",
SubjectKey = subjectKey,
LastAnalyzedAt = now.AddDays(-i * 2),
Flags = new UnknownFlags
{
NoProvenanceAnchor = i > 2,
VersionRange = i > 3
},
CreatedAt = now.AddDays(-i * 2 - 5)
});
}
await _unknownsRepo.UpsertAsync(subjectKey, unknowns, CancellationToken.None);
var result = await decayService.ApplyDecayAsync(subjectKey, CancellationToken.None);
Assert.Equal(6, result.ProcessedCount);
Assert.Equal(6, result.HotCount + result.WarmCount + result.ColdCount);
Assert.True(result.ColdCount >= 1); // At least the fresh one should be cold
}
#endregion
#region Test Infrastructure
private sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public MockTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly List<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = _stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
_stored[existing] = item;
else
_stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken)
{
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
}
#endregion
}

View File

@@ -78,5 +78,30 @@ public class UnknownsIngestionServiceTests
{
return Task.FromResult(Stored.Count);
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = Stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
Stored[existing] = item;
else
Stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
Stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
Stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
}

View File

@@ -0,0 +1,534 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class UnknownsScoringServiceTests
{
private readonly MockTimeProvider _timeProvider;
private readonly InMemoryUnknownsRepository _unknownsRepo;
private readonly InMemoryDeploymentRefsRepository _deploymentRefs;
private readonly InMemoryGraphMetricsRepository _graphMetrics;
private readonly UnknownsScoringOptions _defaultOptions;
public UnknownsScoringServiceTests()
{
_timeProvider = new MockTimeProvider(new DateTimeOffset(2025, 12, 15, 12, 0, 0, TimeSpan.Zero));
_unknownsRepo = new InMemoryUnknownsRepository();
_deploymentRefs = new InMemoryDeploymentRefsRepository();
_graphMetrics = new InMemoryGraphMetricsRepository();
_defaultOptions = new UnknownsScoringOptions();
}
private UnknownsScoringService CreateService(UnknownsScoringOptions? options = null)
{
return new UnknownsScoringService(
_unknownsRepo,
_deploymentRefs,
_graphMetrics,
MsOptions.Options.Create(options ?? _defaultOptions),
_timeProvider,
NullLogger<UnknownsScoringService>.Instance);
}
#region Staleness Exponential Decay Tests
[Fact]
public async Task ScoreUnknown_ExponentialDecay_FreshEvidence_LowStaleness()
{
// Fresh evidence (analyzed today) should have low staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-1",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now, // Just analyzed
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-10)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Staleness should be close to 0 for fresh evidence
Assert.True(scored.StalenessScore < 0.05, $"Expected staleness < 0.05, got {scored.StalenessScore}");
Assert.Equal(0, scored.DaysSinceLastAnalysis);
}
[Fact]
public async Task ScoreUnknown_ExponentialDecay_StaleEvidence_HighStaleness()
{
// Old evidence (14 days) should have high staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-2",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-14), // 14 days old (tau default)
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-20)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// At t = tau, staleness should be significant (normalized based on exponential decay)
Assert.True(scored.StalenessScore > 0.5, $"Expected staleness > 0.5 at tau, got {scored.StalenessScore}");
Assert.Equal(14, scored.DaysSinceLastAnalysis);
}
[Fact]
public async Task ScoreUnknown_ExponentialDecay_NeverAnalyzed_MaxStaleness()
{
// Never analyzed should have maximum staleness
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "unknown-3",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = null, // Never analyzed
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-30)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Never analyzed = maximum staleness (1.0)
Assert.Equal(1.0, scored.StalenessScore);
Assert.Equal(_defaultOptions.StalenessMaxDays, scored.DaysSinceLastAnalysis);
}
[Theory]
[InlineData(0, 0.0)] // Fresh
[InlineData(7, 0.35)] // Half tau - moderate staleness
[InlineData(14, 0.70)] // At tau - significant staleness
[InlineData(28, 0.95)] // 2x tau - near max staleness
public async Task ScoreUnknown_ExponentialDecay_VerifyFormula(int daysOld, double expectedMinStaleness)
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = $"unknown-{daysOld}",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-daysOld),
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-daysOld - 5)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Staleness should be at least the expected minimum
Assert.True(scored.StalenessScore >= expectedMinStaleness * 0.8,
$"At {daysOld} days, expected staleness >= {expectedMinStaleness * 0.8}, got {scored.StalenessScore}");
Assert.Equal(daysOld, scored.DaysSinceLastAnalysis);
}
#endregion
#region Band Assignment Tests
[Fact]
public async Task ScoreUnknown_BandAssignment_HotThreshold()
{
// High score should assign HOT band
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with high uncertainty flags to boost score
var unknown = new UnknownSymbolDocument
{
Id = "hot-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true, // +0.30
VersionRange = true, // +0.25
ConflictingFeeds = true, // +0.20
MissingVector = true // +0.15
},
CreatedAt = now.AddDays(-20)
};
// Set up deployments for popularity
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// With high uncertainty (1.0) and high staleness, weighted score should hit HOT
Assert.Equal(UnknownsBand.Hot, scored.Band);
Assert.True(scored.Score >= _defaultOptions.HotThreshold,
$"Expected score >= {_defaultOptions.HotThreshold} for HOT, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_WarmThreshold()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with moderate factors
var unknown = new UnknownSymbolDocument
{
Id = "warm-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true, // +0.30
VersionRange = true // +0.25
},
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Should be in WARM band
Assert.Equal(UnknownsBand.Warm, scored.Band);
Assert.True(scored.Score >= _defaultOptions.WarmThreshold,
$"Expected score >= {_defaultOptions.WarmThreshold} for WARM, got {scored.Score}");
Assert.True(scored.Score < _defaultOptions.HotThreshold,
$"Expected score < {_defaultOptions.HotThreshold} for WARM, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_ColdThreshold()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
// Create unknown with low factors
var unknown = new UnknownSymbolDocument
{
Id = "cold-unknown",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now, // Fresh evidence
Flags = new UnknownFlags(), // No flags
CreatedAt = now.AddDays(-1)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 1);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Should be in COLD band with minimal factors
Assert.Equal(UnknownsBand.Cold, scored.Band);
Assert.True(scored.Score < _defaultOptions.WarmThreshold,
$"Expected score < {_defaultOptions.WarmThreshold} for COLD, got {scored.Score}");
}
[Fact]
public async Task ScoreUnknown_BandAssignment_CustomThresholds()
{
var customOptions = new UnknownsScoringOptions
{
HotThreshold = 0.80,
WarmThreshold = 0.50
};
var service = CreateService(customOptions);
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "custom-unknown",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true
},
CreatedAt = now.AddDays(-10)
};
var scored = await service.ScoreUnknownAsync(unknown, customOptions, CancellationToken.None);
// With custom thresholds, verify correct band assignment
if (scored.Score >= 0.80)
Assert.Equal(UnknownsBand.Hot, scored.Band);
else if (scored.Score >= 0.50)
Assert.Equal(UnknownsBand.Warm, scored.Band);
else
Assert.Equal(UnknownsBand.Cold, scored.Band);
}
#endregion
#region Weight Formula Tests
[Fact]
public async Task ScoreUnknown_WeightedFormula_VerifyComponents()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "formula-test",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
SymbolId = "sym-1",
CallgraphId = "cg-1",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
_graphMetrics.SetMetrics("sym-1", "cg-1", new GraphMetrics(Degree: 10, Betweenness: 500.0));
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
// Verify normalization trace captures all weights
Assert.NotNull(scored.NormalizationTrace);
Assert.Equal(_defaultOptions.WeightPopularity, scored.NormalizationTrace!.Weights["wP"]);
Assert.Equal(_defaultOptions.WeightExploitPotential, scored.NormalizationTrace.Weights["wE"]);
Assert.Equal(_defaultOptions.WeightUncertainty, scored.NormalizationTrace.Weights["wU"]);
Assert.Equal(_defaultOptions.WeightCentrality, scored.NormalizationTrace.Weights["wC"]);
Assert.Equal(_defaultOptions.WeightStaleness, scored.NormalizationTrace.Weights["wS"]);
// Verify individual scores are in valid range
Assert.InRange(scored.PopularityScore, 0.0, 1.0);
Assert.InRange(scored.ExploitPotentialScore, 0.0, 1.0);
Assert.InRange(scored.UncertaintyScore, 0.0, 1.0);
Assert.InRange(scored.CentralityScore, 0.0, 1.0);
Assert.InRange(scored.StalenessScore, 0.0, 1.0);
// Verify final score is clamped
Assert.InRange(scored.Score, 0.0, 1.0);
}
[Fact]
public async Task ScoreUnknown_WeightedFormula_WeightsSumToOne()
{
// Verify default weights sum to 1.0
var sum = _defaultOptions.WeightPopularity +
_defaultOptions.WeightExploitPotential +
_defaultOptions.WeightUncertainty +
_defaultOptions.WeightCentrality +
_defaultOptions.WeightStaleness;
Assert.Equal(1.0, sum, 5);
}
#endregion
#region Rescan Scheduling Tests
[Fact]
public async Task ScoreUnknown_RescanScheduling_HotBand()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "hot-rescan",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now.AddDays(-14),
Flags = new UnknownFlags
{
NoProvenanceAnchor = true,
VersionRange = true,
ConflictingFeeds = true,
MissingVector = true
},
CreatedAt = now.AddDays(-20)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 100);
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
if (scored.Band == UnknownsBand.Hot)
{
var expectedRescan = now.AddMinutes(_defaultOptions.HotRescanMinutes);
Assert.Equal(expectedRescan, scored.NextScheduledRescan);
}
}
[Fact]
public async Task ScoreUnknown_RescanScheduling_ColdBand()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown = new UnknownSymbolDocument
{
Id = "cold-rescan",
SubjectKey = "test|1.0.0",
LastAnalyzedAt = now,
Flags = new UnknownFlags(),
CreatedAt = now.AddDays(-1)
};
var scored = await service.ScoreUnknownAsync(unknown, _defaultOptions, CancellationToken.None);
Assert.Equal(UnknownsBand.Cold, scored.Band);
var expectedRescan = now.AddDays(_defaultOptions.ColdRescanDays);
Assert.Equal(expectedRescan, scored.NextScheduledRescan);
}
#endregion
#region Determinism Tests
[Fact]
public async Task ScoreUnknown_Determinism_SameInputsSameOutput()
{
var service = CreateService();
var now = _timeProvider.GetUtcNow();
var unknown1 = new UnknownSymbolDocument
{
Id = "determinism-1",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
var unknown2 = new UnknownSymbolDocument
{
Id = "determinism-2",
SubjectKey = "test|1.0.0",
Purl = "pkg:npm/test@1.0.0",
LastAnalyzedAt = now.AddDays(-7),
Flags = new UnknownFlags { NoProvenanceAnchor = true },
CreatedAt = now.AddDays(-10)
};
_deploymentRefs.SetDeploymentCount("pkg:npm/test@1.0.0", 50);
var scored1 = await service.ScoreUnknownAsync(unknown1, _defaultOptions, CancellationToken.None);
var scored2 = await service.ScoreUnknownAsync(unknown2, _defaultOptions, CancellationToken.None);
// Same inputs must produce identical scores
Assert.Equal(scored1.Score, scored2.Score);
Assert.Equal(scored1.Band, scored2.Band);
Assert.Equal(scored1.PopularityScore, scored2.PopularityScore);
Assert.Equal(scored1.StalenessScore, scored2.StalenessScore);
Assert.Equal(scored1.UncertaintyScore, scored2.UncertaintyScore);
}
#endregion
#region Test Infrastructure
private sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public MockTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly List<UnknownSymbolDocument> _stored = new();
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
_stored.RemoveAll(x => x.SubjectKey == subjectKey);
_stored.AddRange(items);
return Task.CompletedTask;
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.SubjectKey == subjectKey).ToList());
}
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
{
return Task.FromResult(_stored.Count(x => x.SubjectKey == subjectKey));
}
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
var existing = _stored.FindIndex(x => x.Id == item.Id);
if (existing >= 0)
_stored[existing] = item;
else
_stored.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(
_stored.Select(x => x.SubjectKey).Distinct().ToList());
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<UnknownSymbolDocument>>(
_stored.Where(x => x.Band == band).Take(limit).ToList());
}
}
private sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
{
private readonly Dictionary<string, int> _counts = new();
public void SetDeploymentCount(string purl, int count) => _counts[purl] = count;
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken)
{
return Task.FromResult(_counts.TryGetValue(purl, out var count) ? count : 0);
}
public Task<IReadOnlyList<string>> GetDeploymentIdsAsync(string purl, int limit, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
}
private sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
{
private readonly Dictionary<string, GraphMetrics> _metrics = new();
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
{
_metrics[$"{symbolId}:{callgraphId}"] = metrics;
}
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken)
{
_metrics.TryGetValue($"{symbolId}:{callgraphId}", out var metrics);
return Task.FromResult(metrics);
}
}
#endregion
}