up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-15 09:51:11 +02:00
parent 41864227d2
commit b1f40945b7
44 changed files with 2368 additions and 31 deletions

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Configuration for unknowns decay batch processing.
/// </summary>
public sealed class UnknownsDecayOptions
{
public const string SectionName = "Signals:UnknownsDecay";
/// <summary>
/// Time of day (UTC hour) for nightly decay batch. Default: 2 (2 AM UTC).
/// </summary>
public int NightlyBatchHourUtc { get; set; } = 2;
/// <summary>
/// Maximum subjects per batch run. Default: 10000.
/// </summary>
public int MaxSubjectsPerBatch { get; set; } = 10_000;
}

View File

@@ -66,6 +66,11 @@ public sealed class UnknownsScoringOptions
/// </summary>
public int StalenessMaxDays { get; set; } = 14;
/// <summary>
/// Staleness time constant (tau) in days for exponential decay. Default: 14
/// </summary>
public double StalenessTauDays { get; set; } = 14.0;
// ===== BAND THRESHOLDS =====
/// <summary>
@@ -80,6 +85,11 @@ public sealed class UnknownsScoringOptions
// ===== RESCAN SCHEDULING =====
/// <summary>
/// Minutes until HOT items are rescanned. Default: 15
/// </summary>
public int HotRescanMinutes { get; set; } = 15;
/// <summary>
/// Hours until WARM items are rescanned. Default: 24
/// </summary>

View File

@@ -8,6 +8,12 @@ namespace StellaOps.Signals.Persistence;
public sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly ConcurrentDictionary<string, List<UnknownSymbolDocument>> _store = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryUnknownsRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
{
@@ -59,12 +65,23 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var keys = _store.Keys
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(keys);
}
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
UnknownsBand band,
int limit,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var results = _store.Values
.SelectMany(x => x)

View File

@@ -132,6 +132,16 @@ builder.Services.AddSingleton<IReachabilityFactRepository>(sp =>
return new ReachabilityFactCacheDecorator(inner, cache);
});
builder.Services.AddSingleton<IUnknownsRepository, InMemoryUnknownsRepository>();
builder.Services.AddOptions<UnknownsScoringOptions>()
.Bind(builder.Configuration.GetSection(UnknownsScoringOptions.SectionName));
builder.Services.AddOptions<UnknownsDecayOptions>()
.Bind(builder.Configuration.GetSection(UnknownsDecayOptions.SectionName));
builder.Services.AddSingleton<IDeploymentRefsRepository, InMemoryDeploymentRefsRepository>();
builder.Services.AddSingleton<IGraphMetricsRepository, InMemoryGraphMetricsRepository>();
builder.Services.AddSingleton<IUnknownsScoringService, UnknownsScoringService>();
builder.Services.AddSingleton<IUnknownsDecayService, UnknownsDecayService>();
builder.Services.AddSingleton<ISignalRefreshService, SignalRefreshService>();
builder.Services.AddHostedService<NightlyDecayWorker>();
builder.Services.AddSingleton<IReachabilityStoreRepository, InMemoryReachabilityStoreRepository>();
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
{

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Signals.Services;
/// <summary>
/// Handles signal refresh events that reset decay.
/// </summary>
public interface ISignalRefreshService
{
/// <summary>
/// Records a signal refresh event.
/// </summary>
Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default);
}
/// <summary>
/// Signal refresh event types per advisory.
/// </summary>
public sealed class SignalRefreshEvent
{
/// <summary>
/// Subject key for the unknown.
/// </summary>
public required string SubjectKey { get; init; }
/// <summary>
/// Unknown ID being refreshed.
/// </summary>
public required string UnknownId { get; init; }
/// <summary>
/// Type of signal refresh.
/// </summary>
public required SignalRefreshType RefreshType { get; init; }
/// <summary>
/// Weight of this signal type.
/// </summary>
public double Weight { get; init; }
/// <summary>
/// Additional context.
/// </summary>
public IReadOnlyDictionary<string, string>? Context { get; init; }
}
public enum SignalRefreshType
{
UnknownsIngested,
ReachabilityRecomputed,
RuntimeFactsIngested,
ProvenanceAnchored,
VexUpdated
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Service for computing confidence decay on unknowns.
/// </summary>
public interface IUnknownsDecayService
{
/// <summary>
/// Applies decay to all unknowns in a subject and recomputes bands.
/// </summary>
Task<DecayResult> ApplyDecayAsync(
string subjectKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Applies decay to a single unknown.
/// </summary>
Task<UnknownSymbolDocument> ApplyDecayToUnknownAsync(
UnknownSymbolDocument unknown,
CancellationToken cancellationToken = default);
/// <summary>
/// Recomputes all scores and bands for nightly batch.
/// </summary>
Task<BatchDecayResult> RunNightlyDecayBatchAsync(
CancellationToken cancellationToken = default);
}
public sealed record DecayResult(
string SubjectKey,
int ProcessedCount,
int HotCount,
int WarmCount,
int ColdCount,
int BandChanges,
DateTimeOffset ComputedAt);
public sealed record BatchDecayResult(
int TotalSubjects,
int TotalUnknowns,
int TotalBandChanges,
TimeSpan Duration,
DateTimeOffset CompletedAt);

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
public sealed class NightlyDecayWorker : BackgroundService
{
private readonly IUnknownsDecayService _decayService;
private readonly IOptions<UnknownsDecayOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NightlyDecayWorker> _logger;
public NightlyDecayWorker(
IUnknownsDecayService decayService,
IOptions<UnknownsDecayOptions> options,
TimeProvider timeProvider,
ILogger<NightlyDecayWorker> logger)
{
_decayService = decayService ?? throw new ArgumentNullException(nameof(decayService));
_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)
{
while (!stoppingToken.IsCancellationRequested)
{
var opts = _options.Value;
var nextRun = GetNextRunUtc(_timeProvider.GetUtcNow(), opts.NightlyBatchHourUtc);
var delay = nextRun - _timeProvider.GetUtcNow();
if (delay > TimeSpan.Zero)
{
_logger.LogInformation("Next unknowns decay batch scheduled for {NextRun}", nextRun);
await Task.Delay(delay, _timeProvider, stoppingToken).ConfigureAwait(false);
}
try
{
await _decayService.RunNightlyDecayBatchAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Shutdown requested.
}
catch (Exception ex)
{
_logger.LogError(ex, "Nightly unknowns decay batch failed.");
}
}
}
private static DateTimeOffset GetNextRunUtc(DateTimeOffset nowUtc, int hourUtc)
{
var clampedHour = Math.Clamp(hourUtc, 0, 23);
var today = new DateTimeOffset(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, TimeSpan.Zero);
var candidate = today.AddHours(clampedHour);
return candidate <= nowUtc ? candidate.AddDays(1) : candidate;
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Options;
using StellaOps.Signals.Persistence;
namespace StellaOps.Signals.Services;
public sealed class SignalRefreshService : ISignalRefreshService
{
private readonly IUnknownsRepository _repository;
private readonly IUnknownsScoringService _scoringService;
private readonly IOptions<UnknownsScoringOptions> _scoringOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignalRefreshService> _logger;
public SignalRefreshService(
IUnknownsRepository repository,
IUnknownsScoringService scoringService,
IOptions<UnknownsScoringOptions> scoringOptions,
TimeProvider timeProvider,
ILogger<SignalRefreshService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
_scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(refreshEvent);
ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.SubjectKey);
ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.UnknownId);
var now = _timeProvider.GetUtcNow();
var unknowns = await _repository.GetBySubjectAsync(refreshEvent.SubjectKey, cancellationToken).ConfigureAwait(false);
var target = unknowns.FirstOrDefault(u => string.Equals(u.Id, refreshEvent.UnknownId, StringComparison.Ordinal));
if (target is null)
{
_logger.LogWarning(
"Signal refresh ignored: unknown {UnknownId} not found for subject {SubjectKey}",
refreshEvent.UnknownId,
refreshEvent.SubjectKey);
return;
}
target.LastAnalyzedAt = now;
target.UpdatedAt = now;
await _scoringService.ScoreUnknownAsync(target, _scoringOptions.Value, cancellationToken).ConfigureAwait(false);
await _repository.BulkUpdateAsync(new[] { target }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Signal refresh applied: subject={SubjectKey}, unknownId={UnknownId}, type={Type}",
refreshEvent.SubjectKey,
refreshEvent.UnknownId,
refreshEvent.RefreshType);
}
}

View File

@@ -0,0 +1,25 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Signals.Services;
internal static class UnknownsDecayMetrics
{
private static readonly Meter Meter = new("StellaOps.Signals.Decay", "1.0.0");
public static readonly Counter<long> SubjectsProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_subjects_processed_total",
description: "Total subjects processed by unknowns decay batches");
public static readonly Counter<long> UnknownsProcessed = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_unknowns_processed_total",
description: "Total unknowns processed by unknowns decay batches");
public static readonly Counter<long> BandChanges = Meter.CreateCounter<long>(
"stellaops_unknowns_decay_band_changes_total",
description: "Total band changes caused by decay rescoring");
public static readonly Histogram<double> BatchDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_unknowns_decay_batch_duration_seconds",
unit: "s",
description: "Duration of unknowns decay batch runs");
}

View File

@@ -0,0 +1,143 @@
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>
/// Implements time-based confidence decay for unknowns by periodically recomputing staleness and band assignment.
/// </summary>
public sealed class UnknownsDecayService : IUnknownsDecayService
{
private readonly IUnknownsRepository _repository;
private readonly IUnknownsScoringService _scoringService;
private readonly IOptions<UnknownsScoringOptions> _scoringOptions;
private readonly IOptions<UnknownsDecayOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<UnknownsDecayService> _logger;
public UnknownsDecayService(
IUnknownsRepository repository,
IUnknownsScoringService scoringService,
IOptions<UnknownsScoringOptions> scoringOptions,
IOptions<UnknownsDecayOptions> options,
TimeProvider timeProvider,
ILogger<UnknownsDecayService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
_scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DecayResult> ApplyDecayAsync(string subjectKey, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
var now = _timeProvider.GetUtcNow();
var unknowns = await _repository.GetBySubjectAsync(subjectKey, cancellationToken).ConfigureAwait(false);
if (unknowns.Count == 0)
{
return new DecayResult(subjectKey, 0, 0, 0, 0, 0, now);
}
var updated = new List<UnknownSymbolDocument>(unknowns.Count);
var bandChanges = 0;
foreach (var unknown in unknowns)
{
var oldBand = unknown.Band;
var decayed = await ApplyDecayToUnknownAsync(unknown, cancellationToken).ConfigureAwait(false);
updated.Add(decayed);
if (oldBand != decayed.Band)
{
bandChanges++;
}
}
await _repository.BulkUpdateAsync(updated, cancellationToken).ConfigureAwait(false);
var result = new DecayResult(
SubjectKey: subjectKey,
ProcessedCount: updated.Count,
HotCount: updated.Count(u => u.Band == UnknownsBand.Hot),
WarmCount: updated.Count(u => u.Band == UnknownsBand.Warm),
ColdCount: updated.Count(u => u.Band == UnknownsBand.Cold),
BandChanges: bandChanges,
ComputedAt: now);
UnknownsDecayMetrics.SubjectsProcessed.Add(1);
UnknownsDecayMetrics.UnknownsProcessed.Add(result.ProcessedCount);
UnknownsDecayMetrics.BandChanges.Add(result.BandChanges);
_logger.LogInformation(
"Applied unknowns decay for {SubjectKey}: processed={ProcessedCount}, hot={HotCount}, warm={WarmCount}, cold={ColdCount}, bandChanges={BandChanges}",
result.SubjectKey,
result.ProcessedCount,
result.HotCount,
result.WarmCount,
result.ColdCount,
result.BandChanges);
return result;
}
public async Task<UnknownSymbolDocument> ApplyDecayToUnknownAsync(UnknownSymbolDocument unknown, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknown);
var opts = _scoringOptions.Value;
return await _scoringService.ScoreUnknownAsync(unknown, opts, cancellationToken).ConfigureAwait(false);
}
public async Task<BatchDecayResult> RunNightlyDecayBatchAsync(CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var subjects = await _repository.GetAllSubjectKeysAsync(cancellationToken).ConfigureAwait(false);
var maxSubjects = Math.Max(0, _options.Value.MaxSubjectsPerBatch);
if (maxSubjects > 0 && subjects.Count > maxSubjects)
{
subjects = subjects.Take(maxSubjects).ToArray();
}
_logger.LogInformation("Starting nightly unknowns decay batch for {Count} subjects", subjects.Count);
var totalUnknowns = 0;
var totalBandChanges = 0;
foreach (var subjectKey in subjects)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ApplyDecayAsync(subjectKey, cancellationToken).ConfigureAwait(false);
totalUnknowns += result.ProcessedCount;
totalBandChanges += result.BandChanges;
}
var endTime = _timeProvider.GetUtcNow();
var duration = endTime - startTime;
UnknownsDecayMetrics.BatchDurationSeconds.Record(duration.TotalSeconds);
var batchResult = new BatchDecayResult(
TotalSubjects: subjects.Count,
TotalUnknowns: totalUnknowns,
TotalBandChanges: totalBandChanges,
Duration: duration,
CompletedAt: endTime);
_logger.LogInformation(
"Completed nightly unknowns decay batch: subjects={TotalSubjects}, unknowns={TotalUnknowns}, bandChanges={TotalBandChanges}, duration={Duration}",
batchResult.TotalSubjects,
batchResult.TotalUnknowns,
batchResult.TotalBandChanges,
batchResult.Duration);
return batchResult;
}
}

View File

@@ -7,3 +7,4 @@ This file mirrors sprint work for the Signals module.
| `SIG-STORE-401-016` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Added reachability store repository APIs and models; callgraph ingestion now populates the store; Mongo index script at `ops/mongo/indices/reachability_store_indices.js`. |
| `UNCERTAINTY-SCHEMA-401-024` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented uncertainty tiers and scoring integration; see `src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs` and `src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs`. |
| `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. |
| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DOING (2025-12-15) | Implement decay worker/service, signal refresh hook, and deterministic unit/integration tests. |