up
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic implementation of <see cref="IEventsPublisher"/> using StellaOps.Messaging abstractions.
|
||||
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
|
||||
/// </summary>
|
||||
internal sealed class MessagingEventsPublisher : IEventsPublisher
|
||||
{
|
||||
private readonly SignalsEventsOptions _options;
|
||||
private readonly ILogger<MessagingEventsPublisher> _logger;
|
||||
private readonly ReachabilityFactEventBuilder _eventBuilder;
|
||||
private readonly IEventStream<ReachabilityFactUpdatedEnvelope> _eventStream;
|
||||
private readonly IEventStream<ReachabilityFactUpdatedEnvelope>? _deadLetterStream;
|
||||
private readonly TimeSpan _publishTimeout;
|
||||
private readonly long? _maxStreamLength;
|
||||
|
||||
public MessagingEventsPublisher(
|
||||
SignalsOptions options,
|
||||
IEventStreamFactory eventStreamFactory,
|
||||
ReachabilityFactEventBuilder eventBuilder,
|
||||
ILogger<MessagingEventsPublisher> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(eventStreamFactory);
|
||||
|
||||
_options = options.Events ?? throw new InvalidOperationException("Signals events configuration is required.");
|
||||
_eventBuilder = eventBuilder ?? throw new ArgumentNullException(nameof(eventBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_publishTimeout = _options.PublishTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(_options.PublishTimeoutSeconds)
|
||||
: TimeSpan.Zero;
|
||||
|
||||
_maxStreamLength = _options.MaxStreamLength > 0
|
||||
? _options.MaxStreamLength
|
||||
: null;
|
||||
|
||||
var streamName = string.IsNullOrWhiteSpace(_options.Stream) ? "signals.fact.updated.v1" : _options.Stream;
|
||||
|
||||
_eventStream = eventStreamFactory.Create<ReachabilityFactUpdatedEnvelope>(new EventStreamOptions
|
||||
{
|
||||
StreamName = streamName,
|
||||
MaxLength = _maxStreamLength,
|
||||
ApproximateTrimming = true,
|
||||
});
|
||||
|
||||
// Create dead letter stream if configured
|
||||
if (!string.IsNullOrWhiteSpace(_options.DeadLetterStream))
|
||||
{
|
||||
_deadLetterStream = eventStreamFactory.Create<ReachabilityFactUpdatedEnvelope>(new EventStreamOptions
|
||||
{
|
||||
StreamName = _options.DeadLetterStream,
|
||||
MaxLength = _maxStreamLength,
|
||||
ApproximateTrimming = true,
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Initialized messaging events publisher for stream {Stream}.", streamName);
|
||||
}
|
||||
|
||||
public async Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fact);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var envelope = _eventBuilder.Build(fact);
|
||||
|
||||
try
|
||||
{
|
||||
var publishOptions = new EventPublishOptions
|
||||
{
|
||||
IdempotencyKey = envelope.EventId,
|
||||
TenantId = envelope.Tenant,
|
||||
MaxStreamLength = _maxStreamLength,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["event_id"] = envelope.EventId,
|
||||
["subject_key"] = envelope.SubjectKey,
|
||||
["digest"] = envelope.Digest,
|
||||
["fact_version"] = envelope.FactVersion.ToString(CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
var publishTask = _eventStream.PublishAsync(envelope, publishOptions, cancellationToken);
|
||||
|
||||
if (_publishTimeout > TimeSpan.Zero)
|
||||
{
|
||||
await publishTask.AsTask().WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish reachability event to stream {Stream}.", _options.Stream);
|
||||
await TryPublishDeadLetterAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryPublishDeadLetterAsync(ReachabilityFactUpdatedEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_deadLetterStream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dlqOptions = new EventPublishOptions
|
||||
{
|
||||
IdempotencyKey = envelope.EventId,
|
||||
MaxStreamLength = _maxStreamLength,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["error"] = "publish-failed"
|
||||
}
|
||||
};
|
||||
|
||||
var dlqTask = _deadLetterStream.PublishAsync(envelope, dlqOptions, cancellationToken);
|
||||
|
||||
if (_publishTimeout > TimeSpan.Zero)
|
||||
{
|
||||
await dlqTask.AsTask().WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await dlqTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish reachability event to DLQ stream {Stream}.", _options.DeadLetterStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,10 @@ internal static class ReachabilityFactDigestCalculator
|
||||
EntryPoints: NormalizeList(fact.EntryPoints),
|
||||
States: NormalizeStates(fact.States),
|
||||
RuntimeFacts: NormalizeRuntimeFacts(fact.RuntimeFacts),
|
||||
UncertaintyStates: NormalizeUncertaintyStates(fact.Uncertainty),
|
||||
Metadata: NormalizeMetadata(fact.Metadata),
|
||||
Score: fact.Score,
|
||||
RiskScore: fact.RiskScore,
|
||||
UnknownsCount: fact.UnknownsCount,
|
||||
UnknownsPressure: fact.UnknownsPressure,
|
||||
ComputedAt: fact.ComputedAt);
|
||||
@@ -122,6 +124,44 @@ internal static class ReachabilityFactDigestCalculator
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<CanonicalUncertaintyState> NormalizeUncertaintyStates(UncertaintyDocument? uncertainty)
|
||||
{
|
||||
if (uncertainty?.States is not { Count: > 0 })
|
||||
{
|
||||
return new List<CanonicalUncertaintyState>();
|
||||
}
|
||||
|
||||
return uncertainty.States
|
||||
.Where(s => s is not null && !string.IsNullOrWhiteSpace(s.Code))
|
||||
.Select(s => new CanonicalUncertaintyState(
|
||||
Code: s.Code.Trim(),
|
||||
Name: s.Name?.Trim() ?? string.Empty,
|
||||
Entropy: Math.Clamp(s.Entropy, 0.0, 1.0),
|
||||
Evidence: NormalizeUncertaintyEvidence(s.Evidence),
|
||||
Timestamp: s.Timestamp))
|
||||
.OrderBy(s => s.Code, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<CanonicalUncertaintyEvidence> NormalizeUncertaintyEvidence(IEnumerable<UncertaintyEvidenceDocument>? evidence)
|
||||
{
|
||||
if (evidence is null)
|
||||
{
|
||||
return new List<CanonicalUncertaintyEvidence>();
|
||||
}
|
||||
|
||||
return evidence
|
||||
.Select(e => new CanonicalUncertaintyEvidence(
|
||||
Type: e.Type?.Trim() ?? string.Empty,
|
||||
SourceId: e.SourceId?.Trim() ?? string.Empty,
|
||||
Detail: e.Detail?.Trim() ?? string.Empty))
|
||||
.OrderBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Detail, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record CanonicalReachabilityFact(
|
||||
string CallgraphId,
|
||||
string SubjectKey,
|
||||
@@ -129,8 +169,10 @@ internal static class ReachabilityFactDigestCalculator
|
||||
List<string> EntryPoints,
|
||||
List<CanonicalState> States,
|
||||
List<CanonicalRuntimeFact> RuntimeFacts,
|
||||
List<CanonicalUncertaintyState> UncertaintyStates,
|
||||
SortedDictionary<string, string?> Metadata,
|
||||
double Score,
|
||||
double RiskScore,
|
||||
int UnknownsCount,
|
||||
double UnknownsPressure,
|
||||
DateTimeOffset ComputedAt);
|
||||
@@ -167,4 +209,16 @@ internal static class ReachabilityFactDigestCalculator
|
||||
int HitCount,
|
||||
DateTimeOffset? ObservedAt,
|
||||
SortedDictionary<string, string?> Metadata);
|
||||
|
||||
private sealed record CanonicalUncertaintyState(
|
||||
string Code,
|
||||
string Name,
|
||||
double Entropy,
|
||||
List<CanonicalUncertaintyEvidence> Evidence,
|
||||
DateTimeOffset? Timestamp);
|
||||
|
||||
private sealed record CanonicalUncertaintyEvidence(
|
||||
string Type,
|
||||
string SourceId,
|
||||
string Detail);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ internal sealed class ReachabilityFactEventBuilder
|
||||
var (reachable, unreachable) = CountStates(fact);
|
||||
var runtimeFactsCount = fact.RuntimeFacts?.Count ?? 0;
|
||||
var avgConfidence = fact.States.Count > 0 ? fact.States.Average(s => s.Confidence) : 0;
|
||||
var uncertaintyStates = fact.Uncertainty?.States ?? new List<UncertaintyStateDocument>();
|
||||
var uncertaintyCodes = uncertaintyStates
|
||||
.Where(s => s is not null && !string.IsNullOrWhiteSpace(s.Code))
|
||||
.Select(s => s.Code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var avgEntropy = uncertaintyStates.Count > 0 ? uncertaintyStates.Average(s => s.Entropy) : 0;
|
||||
var maxEntropy = uncertaintyStates.Count > 0 ? uncertaintyStates.Max(s => s.Entropy) : 0;
|
||||
var topBucket = fact.States.Count > 0
|
||||
? fact.States
|
||||
.GroupBy(s => s.Bucket, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -72,11 +81,16 @@ internal sealed class ReachabilityFactEventBuilder
|
||||
Weight: topBucket?.Average(s => s.Weight) ?? 0,
|
||||
StateCount: fact.States.Count,
|
||||
FactScore: fact.Score,
|
||||
RiskScore: fact.RiskScore,
|
||||
UnknownsCount: fact.UnknownsCount,
|
||||
UnknownsPressure: fact.UnknownsPressure,
|
||||
UncertaintyCount: uncertaintyStates.Count,
|
||||
MaxEntropy: maxEntropy,
|
||||
AverageEntropy: avgEntropy,
|
||||
AverageConfidence: avgConfidence,
|
||||
ComputedAtUtc: fact.ComputedAt,
|
||||
Targets: fact.States.Select(s => s.Target).ToArray());
|
||||
Targets: fact.States.Select(s => s.Target).ToArray(),
|
||||
UncertaintyCodes: uncertaintyCodes);
|
||||
}
|
||||
|
||||
private static (int reachable, int unreachable) CountStates(ReachabilityFactDocument fact)
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Lattice;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
@@ -93,6 +94,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
|
||||
var runtimeHits = runtimeHitSet.OrderBy(h => h, StringComparer.Ordinal).ToList();
|
||||
|
||||
var computedAt = timeProvider.GetUtcNow();
|
||||
var states = new List<ReachabilityStateDocument>(targets.Count);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
@@ -111,12 +113,23 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
|
||||
runtimeEvidence = runtimeEvidence.OrderBy(hit => hit, StringComparer.Ordinal).ToList();
|
||||
|
||||
// Compute v1 lattice state from bucket and runtime evidence
|
||||
var hasRuntimeEvidence = runtimeEvidence.Count > 0;
|
||||
var latticeState = ReachabilityLattice.FromV0Bucket(bucket, hasRuntimeEvidence);
|
||||
|
||||
// Get previous lattice state for transition tracking
|
||||
var existingState = existingFact?.States?.FirstOrDefault(s =>
|
||||
string.Equals(s.Target, target, StringComparison.Ordinal));
|
||||
var previousLatticeState = existingState?.LatticeState;
|
||||
|
||||
states.Add(new ReachabilityStateDocument
|
||||
{
|
||||
Target = target,
|
||||
Reachable = reachable,
|
||||
Confidence = confidence,
|
||||
Bucket = bucket,
|
||||
LatticeState = latticeState.ToCode(),
|
||||
PreviousLatticeState = previousLatticeState,
|
||||
Weight = weight,
|
||||
Score = score,
|
||||
Path = path ?? new List<string>(),
|
||||
@@ -127,7 +140,8 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
.Select(edge => $"{edge.From} -> {edge.To}")
|
||||
.OrderBy(edge => edge, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
}
|
||||
},
|
||||
LatticeTransitionAt = previousLatticeState != latticeState.ToCode() ? computedAt : existingState?.LatticeTransitionAt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,6 +156,10 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
var pressurePenalty = Math.Min(scoringOptions.UnknownsPenaltyCeiling, pressure);
|
||||
var finalScore = baseScore * (1 - pressurePenalty);
|
||||
|
||||
var uncertaintyStates = MergeUncertaintyStates(existingFact?.Uncertainty?.States, unknownsCount, pressure, states.Count, computedAt);
|
||||
var (uncertainty, aggregateTier) = BuildUncertaintyDocument(uncertaintyStates, baseScore, computedAt);
|
||||
var riskScore = ComputeRiskScoreWithTiers(baseScore, uncertaintyStates, aggregateTier);
|
||||
|
||||
var document = new ReachabilityFactDocument
|
||||
{
|
||||
CallgraphId = request.CallgraphId,
|
||||
@@ -149,10 +167,12 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
EntryPoints = entryPoints,
|
||||
States = states,
|
||||
Metadata = request.Metadata,
|
||||
Uncertainty = uncertainty,
|
||||
Score = finalScore,
|
||||
RiskScore = riskScore,
|
||||
UnknownsCount = unknownsCount,
|
||||
UnknownsPressure = pressure,
|
||||
ComputedAt = timeProvider.GetUtcNow(),
|
||||
ComputedAt = computedAt,
|
||||
SubjectKey = subjectKey,
|
||||
RuntimeFacts = existingFact?.RuntimeFacts
|
||||
};
|
||||
@@ -180,6 +200,138 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private static List<UncertaintyStateDocument> MergeUncertaintyStates(
|
||||
IReadOnlyList<UncertaintyStateDocument>? existingStates,
|
||||
int unknownsCount,
|
||||
double unknownsPressure,
|
||||
int totalSymbols,
|
||||
DateTimeOffset computedAtUtc)
|
||||
{
|
||||
var merged = new Dictionary<string, UncertaintyStateDocument>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (existingStates is not null)
|
||||
{
|
||||
foreach (var state in existingStates)
|
||||
{
|
||||
if (state is null || string.IsNullOrWhiteSpace(state.Code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[state.Code.Trim()] = NormalizeState(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownsCount > 0)
|
||||
{
|
||||
var entropy = Math.Clamp(unknownsPressure, 0.0, 1.0);
|
||||
var tier = UncertaintyTierCalculator.CalculateTier("U1", entropy);
|
||||
|
||||
merged["U1"] = new UncertaintyStateDocument
|
||||
{
|
||||
Code = "U1",
|
||||
Name = "MissingSymbolResolution",
|
||||
Entropy = entropy,
|
||||
Tier = tier.ToString(),
|
||||
Timestamp = computedAtUtc,
|
||||
Evidence = new List<UncertaintyEvidenceDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "UnknownsRegistry",
|
||||
SourceId = "signals.unknowns",
|
||||
Detail = FormattableString.Invariant($"unknownsCount={unknownsCount};totalSymbols={totalSymbols};unknownsPressure={unknownsPressure:0.######}")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return merged.Values
|
||||
.OrderBy(s => s.Code, StringComparer.Ordinal)
|
||||
.Select(NormalizeState)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static (UncertaintyDocument? Document, UncertaintyTier AggregateTier) BuildUncertaintyDocument(
|
||||
List<UncertaintyStateDocument> states,
|
||||
double baseScore,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
if (states.Count == 0)
|
||||
{
|
||||
return (null, UncertaintyTier.T4);
|
||||
}
|
||||
|
||||
// Calculate aggregate tier
|
||||
var tierInputs = states.Select(s => (s.Code, s.Entropy)).ToList();
|
||||
var aggregateTier = UncertaintyTierCalculator.CalculateAggregateTier(tierInputs);
|
||||
|
||||
// Calculate mean entropy
|
||||
var meanEntropy = states.Average(s => s.Entropy);
|
||||
|
||||
// Calculate risk score with tier modifiers
|
||||
var riskScore = UncertaintyTierCalculator.CalculateRiskScore(baseScore, aggregateTier, meanEntropy);
|
||||
|
||||
var document = new UncertaintyDocument
|
||||
{
|
||||
States = states,
|
||||
AggregateTier = aggregateTier.ToString(),
|
||||
RiskScore = riskScore,
|
||||
ComputedAt = computedAt
|
||||
};
|
||||
|
||||
return (document, aggregateTier);
|
||||
}
|
||||
|
||||
private static UncertaintyStateDocument NormalizeState(UncertaintyStateDocument state)
|
||||
{
|
||||
var evidence = state.Evidence is { Count: > 0 }
|
||||
? state.Evidence
|
||||
.Where(e => e is not null && (!string.IsNullOrWhiteSpace(e.Type) || !string.IsNullOrWhiteSpace(e.SourceId) || !string.IsNullOrWhiteSpace(e.Detail)))
|
||||
.Select(e => new UncertaintyEvidenceDocument
|
||||
{
|
||||
Type = e.Type?.Trim() ?? string.Empty,
|
||||
SourceId = e.SourceId?.Trim() ?? string.Empty,
|
||||
Detail = e.Detail?.Trim() ?? string.Empty
|
||||
})
|
||||
.OrderBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Detail, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: new List<UncertaintyEvidenceDocument>();
|
||||
|
||||
var code = state.Code?.Trim() ?? string.Empty;
|
||||
var entropy = Math.Clamp(state.Entropy, 0.0, 1.0);
|
||||
var tier = UncertaintyTierCalculator.CalculateTier(code, entropy);
|
||||
|
||||
return new UncertaintyStateDocument
|
||||
{
|
||||
Code = code,
|
||||
Name = state.Name?.Trim() ?? string.Empty,
|
||||
Entropy = entropy,
|
||||
Tier = state.Tier ?? tier.ToString(),
|
||||
Timestamp = state.Timestamp,
|
||||
Evidence = evidence
|
||||
};
|
||||
}
|
||||
|
||||
private double ComputeRiskScoreWithTiers(
|
||||
double baseScore,
|
||||
IReadOnlyList<UncertaintyStateDocument> uncertaintyStates,
|
||||
UncertaintyTier aggregateTier)
|
||||
{
|
||||
var meanEntropy = uncertaintyStates.Count > 0
|
||||
? uncertaintyStates.Average(s => s.Entropy)
|
||||
: 0.0;
|
||||
|
||||
return UncertaintyTierCalculator.CalculateRiskScore(
|
||||
baseScore,
|
||||
aggregateTier,
|
||||
meanEntropy,
|
||||
scoringOptions.UncertaintyEntropyMultiplier,
|
||||
scoringOptions.UncertaintyBoostCeiling);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(ReachabilityRecomputeRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CallgraphId))
|
||||
|
||||
Reference in New Issue
Block a user