This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,164 @@
namespace StellaOps.Signals.Lattice;
/// <summary>
/// Implements the v1 reachability lattice operations (join and meet).
/// The lattice is a bounded lattice with Unknown at bottom and Contested at top.
/// </summary>
public static class ReachabilityLattice
{
// Pre-computed join table for O(1) lookups
// Rows and columns indexed by enum value (0-7)
private static readonly ReachabilityLatticeState[,] JoinTable = new ReachabilityLatticeState[8, 8]
{
// U SR SU RO RU CR CU X
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested }, // U
{ ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested }, // SR
{ ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested }, // SU
{ ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested }, // RO
{ ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested }, // RU
{ ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested }, // CR
{ ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested }, // CU
{ ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Contested } // X
};
// Pre-computed meet table for O(1) lookups
private static readonly ReachabilityLatticeState[,] MeetTable = new ReachabilityLatticeState[8, 8]
{
// U SR SU RO RU CR CU X
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown }, // U
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable }, // SR
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.StaticallyUnreachable }, // SU
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeObserved }, // RO
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.RuntimeUnobserved }, // RU
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.ConfirmedReachable }, // CR
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.ConfirmedUnreachable }, // CU
{ ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested } // X
};
/// <summary>
/// Computes the join (least upper bound) of two lattice states.
/// Used when combining evidence from multiple sources.
/// </summary>
/// <param name="a">First state</param>
/// <param name="b">Second state</param>
/// <returns>The least upper bound of a and b</returns>
public static ReachabilityLatticeState Join(ReachabilityLatticeState a, ReachabilityLatticeState b)
{
return JoinTable[(int)a, (int)b];
}
/// <summary>
/// Computes the join of multiple lattice states.
/// </summary>
public static ReachabilityLatticeState JoinAll(IEnumerable<ReachabilityLatticeState> states)
{
var result = ReachabilityLatticeState.Unknown;
foreach (var state in states)
{
result = Join(result, state);
if (result == ReachabilityLatticeState.Contested)
{
break; // Contested is top, no need to continue
}
}
return result;
}
/// <summary>
/// Computes the meet (greatest lower bound) of two lattice states.
/// Used for conservative intersection (e.g., multi-entry-point consensus).
/// </summary>
/// <param name="a">First state</param>
/// <param name="b">Second state</param>
/// <returns>The greatest lower bound of a and b</returns>
public static ReachabilityLatticeState Meet(ReachabilityLatticeState a, ReachabilityLatticeState b)
{
return MeetTable[(int)a, (int)b];
}
/// <summary>
/// Computes the meet of multiple lattice states.
/// </summary>
public static ReachabilityLatticeState MeetAll(IEnumerable<ReachabilityLatticeState> states)
{
var result = ReachabilityLatticeState.Contested; // Start with top
var hasAny = false;
foreach (var state in states)
{
hasAny = true;
result = Meet(result, state);
if (result == ReachabilityLatticeState.Unknown)
{
break; // Unknown is bottom, no need to continue
}
}
return hasAny ? result : ReachabilityLatticeState.Unknown;
}
/// <summary>
/// Determines if state a is less than or equal to state b in the lattice ordering.
/// </summary>
public static bool LessThanOrEqual(ReachabilityLatticeState a, ReachabilityLatticeState b)
{
return Join(a, b) == b;
}
/// <summary>
/// Determines the lattice state from static analysis and runtime evidence.
/// </summary>
/// <param name="staticReachable">Whether static analysis found a path</param>
/// <param name="hasRuntimeEvidence">Whether runtime probes were active</param>
/// <param name="runtimeObserved">Whether runtime execution was observed</param>
/// <returns>The appropriate lattice state</returns>
public static ReachabilityLatticeState FromEvidence(
bool? staticReachable,
bool hasRuntimeEvidence,
bool runtimeObserved)
{
// Determine static state
var staticState = staticReachable switch
{
true => ReachabilityLatticeState.StaticallyReachable,
false => ReachabilityLatticeState.StaticallyUnreachable,
null => ReachabilityLatticeState.Unknown
};
// If no runtime evidence, return static state only
if (!hasRuntimeEvidence)
{
return staticState;
}
// Determine runtime state
var runtimeState = runtimeObserved
? ReachabilityLatticeState.RuntimeObserved
: ReachabilityLatticeState.RuntimeUnobserved;
// Join static and runtime
return Join(staticState, runtimeState);
}
/// <summary>
/// Computes the lattice state from v0 bucket and runtime evidence.
/// Used for backward compatibility during transition.
/// </summary>
public static ReachabilityLatticeState FromV0Bucket(
string bucket,
bool hasRuntimeHits)
{
var bucketLower = bucket?.ToLowerInvariant() ?? "unknown";
return bucketLower switch
{
"entrypoint" => ReachabilityLatticeState.ConfirmedReachable,
"direct" => hasRuntimeHits
? ReachabilityLatticeState.ConfirmedReachable
: ReachabilityLatticeState.StaticallyReachable,
"runtime" => ReachabilityLatticeState.RuntimeObserved,
"unreachable" => hasRuntimeHits
? ReachabilityLatticeState.Contested // Static says unreachable but runtime hit
: ReachabilityLatticeState.StaticallyUnreachable,
_ => ReachabilityLatticeState.Unknown
};
}
}

View File

@@ -0,0 +1,134 @@
namespace StellaOps.Signals.Lattice;
/// <summary>
/// Represents the v1 reachability lattice states.
/// States form a bounded lattice with Unknown at bottom and Contested at top.
/// </summary>
public enum ReachabilityLatticeState
{
/// <summary>
/// Bottom element - no evidence available.
/// </summary>
Unknown = 0,
/// <summary>
/// Static analysis suggests path exists.
/// </summary>
StaticallyReachable = 1,
/// <summary>
/// Static analysis finds no path.
/// </summary>
StaticallyUnreachable = 2,
/// <summary>
/// Runtime probe/hit confirms execution.
/// </summary>
RuntimeObserved = 3,
/// <summary>
/// Runtime probe active but no hit observed.
/// </summary>
RuntimeUnobserved = 4,
/// <summary>
/// Both static and runtime agree reachable.
/// </summary>
ConfirmedReachable = 5,
/// <summary>
/// Both static and runtime agree unreachable.
/// </summary>
ConfirmedUnreachable = 6,
/// <summary>
/// Top element - static and runtime evidence conflict.
/// </summary>
Contested = 7
}
/// <summary>
/// Extension methods for lattice state operations.
/// </summary>
public static class ReachabilityLatticeStateExtensions
{
/// <summary>
/// Gets the short code for the lattice state.
/// </summary>
public static string ToCode(this ReachabilityLatticeState state) => state switch
{
ReachabilityLatticeState.Unknown => "U",
ReachabilityLatticeState.StaticallyReachable => "SR",
ReachabilityLatticeState.StaticallyUnreachable => "SU",
ReachabilityLatticeState.RuntimeObserved => "RO",
ReachabilityLatticeState.RuntimeUnobserved => "RU",
ReachabilityLatticeState.ConfirmedReachable => "CR",
ReachabilityLatticeState.ConfirmedUnreachable => "CU",
ReachabilityLatticeState.Contested => "X",
_ => "U"
};
/// <summary>
/// Parses a code string to a lattice state.
/// </summary>
public static ReachabilityLatticeState FromCode(string code) => code?.ToUpperInvariant() switch
{
"U" => ReachabilityLatticeState.Unknown,
"SR" => ReachabilityLatticeState.StaticallyReachable,
"SU" => ReachabilityLatticeState.StaticallyUnreachable,
"RO" => ReachabilityLatticeState.RuntimeObserved,
"RU" => ReachabilityLatticeState.RuntimeUnobserved,
"CR" => ReachabilityLatticeState.ConfirmedReachable,
"CU" => ReachabilityLatticeState.ConfirmedUnreachable,
"X" => ReachabilityLatticeState.Contested,
_ => ReachabilityLatticeState.Unknown
};
/// <summary>
/// Gets whether the state indicates reachability (any evidence of reachability).
/// </summary>
public static bool ImpliesReachable(this ReachabilityLatticeState state) => state switch
{
ReachabilityLatticeState.StaticallyReachable => true,
ReachabilityLatticeState.RuntimeObserved => true,
ReachabilityLatticeState.ConfirmedReachable => true,
_ => false
};
/// <summary>
/// Gets whether the state indicates unreachability (any evidence of unreachability).
/// </summary>
public static bool ImpliesUnreachable(this ReachabilityLatticeState state) => state switch
{
ReachabilityLatticeState.StaticallyUnreachable => true,
ReachabilityLatticeState.RuntimeUnobserved => true,
ReachabilityLatticeState.ConfirmedUnreachable => true,
_ => false
};
/// <summary>
/// Gets whether the state is confirmed (has both static and runtime evidence agreeing).
/// </summary>
public static bool IsConfirmed(this ReachabilityLatticeState state) => state switch
{
ReachabilityLatticeState.ConfirmedReachable => true,
ReachabilityLatticeState.ConfirmedUnreachable => true,
_ => false
};
/// <summary>
/// Maps lattice state to v0 bucket for backward compatibility.
/// </summary>
public static string ToV0Bucket(this ReachabilityLatticeState state) => state switch
{
ReachabilityLatticeState.Unknown => "unknown",
ReachabilityLatticeState.StaticallyReachable => "direct",
ReachabilityLatticeState.StaticallyUnreachable => "unreachable",
ReachabilityLatticeState.RuntimeObserved => "runtime",
ReachabilityLatticeState.RuntimeUnobserved => "unreachable",
ReachabilityLatticeState.ConfirmedReachable => "runtime",
ReachabilityLatticeState.ConfirmedUnreachable => "unreachable",
ReachabilityLatticeState.Contested => "unknown",
_ => "unknown"
};
}

View File

@@ -0,0 +1,186 @@
namespace StellaOps.Signals.Lattice;
/// <summary>
/// Represents the uncertainty tier classification.
/// Higher tiers indicate more severe uncertainty requiring stricter policy gates.
/// </summary>
public enum UncertaintyTier
{
/// <summary>
/// Negligible uncertainty (entropy 0.0-0.09). Normal processing.
/// </summary>
T4 = 4,
/// <summary>
/// Low uncertainty (entropy 0.1-0.39). Allow with advisory note.
/// </summary>
T3 = 3,
/// <summary>
/// Medium uncertainty (entropy 0.4-0.69). Warn on decisions, flag for review.
/// </summary>
T2 = 2,
/// <summary>
/// High uncertainty (entropy 0.7-1.0). Block definitive decisions, require human review.
/// </summary>
T1 = 1
}
/// <summary>
/// Extension methods for uncertainty tier operations.
/// </summary>
public static class UncertaintyTierExtensions
{
/// <summary>
/// Gets the risk modifier for this tier.
/// </summary>
public static double GetRiskModifier(this UncertaintyTier tier) => tier switch
{
UncertaintyTier.T1 => 0.50,
UncertaintyTier.T2 => 0.25,
UncertaintyTier.T3 => 0.10,
UncertaintyTier.T4 => 0.00,
_ => 0.00
};
/// <summary>
/// Gets whether this tier blocks definitive VEX decisions (not_affected).
/// </summary>
public static bool BlocksNotAffected(this UncertaintyTier tier) => tier == UncertaintyTier.T1;
/// <summary>
/// Gets whether this tier requires warning on VEX decisions.
/// </summary>
public static bool RequiresWarning(this UncertaintyTier tier) =>
tier == UncertaintyTier.T1 || tier == UncertaintyTier.T2;
/// <summary>
/// Gets the tier display name.
/// </summary>
public static string ToDisplayName(this UncertaintyTier tier) => tier switch
{
UncertaintyTier.T1 => "High",
UncertaintyTier.T2 => "Medium",
UncertaintyTier.T3 => "Low",
UncertaintyTier.T4 => "Negligible",
_ => "Unknown"
};
}
/// <summary>
/// Calculates uncertainty tiers from uncertainty states.
/// </summary>
public static class UncertaintyTierCalculator
{
/// <summary>
/// Known uncertainty state codes.
/// </summary>
public static class UncertaintyCodes
{
public const string MissingSymbolResolution = "U1";
public const string MissingPurl = "U2";
public const string UntrustedAdvisory = "U3";
public const string Unknown = "U4";
}
/// <summary>
/// Calculates the tier for a single uncertainty state.
/// </summary>
public static UncertaintyTier CalculateTier(string code, double entropy)
{
return code?.ToUpperInvariant() switch
{
"U1" => entropy switch // MissingSymbolResolution
{
>= 0.7 => UncertaintyTier.T1,
>= 0.4 => UncertaintyTier.T2,
_ => UncertaintyTier.T3
},
"U2" => entropy switch // MissingPurl
{
>= 0.5 => UncertaintyTier.T2,
_ => UncertaintyTier.T3
},
"U3" => entropy switch // UntrustedAdvisory
{
>= 0.6 => UncertaintyTier.T3,
_ => UncertaintyTier.T4
},
"U4" => UncertaintyTier.T1, // Unknown always T1
_ => UncertaintyTier.T4 // Unknown codes default to negligible
};
}
/// <summary>
/// Calculates the aggregate tier from multiple uncertainty states.
/// Returns the maximum (most severe) tier.
/// </summary>
public static UncertaintyTier CalculateAggregateTier(
IEnumerable<(string Code, double Entropy)> states)
{
var maxTier = UncertaintyTier.T4;
foreach (var (code, entropy) in states)
{
var tier = CalculateTier(code, entropy);
if ((int)tier < (int)maxTier) // Lower enum value = higher severity
{
maxTier = tier;
}
if (maxTier == UncertaintyTier.T1)
{
break; // Already at maximum severity
}
}
return maxTier;
}
/// <summary>
/// Calculates the risk score with tier-based modifiers.
/// </summary>
/// <param name="baseScore">Base reachability score (0-1)</param>
/// <param name="aggregateTier">Aggregate uncertainty tier</param>
/// <param name="meanEntropy">Mean entropy across all uncertainty states</param>
/// <param name="entropyMultiplier">Multiplier for entropy boost (default 0.5)</param>
/// <param name="boostCeiling">Maximum entropy boost (default 0.5)</param>
/// <returns>Risk score clamped to [0, 1]</returns>
public static double CalculateRiskScore(
double baseScore,
UncertaintyTier aggregateTier,
double meanEntropy,
double entropyMultiplier = 0.5,
double boostCeiling = 0.5)
{
var tierModifier = aggregateTier.GetRiskModifier();
var entropyBoost = Math.Min(meanEntropy * entropyMultiplier, boostCeiling);
var riskScore = baseScore * (1.0 + tierModifier + entropyBoost);
return Math.Clamp(riskScore, 0.0, 1.0);
}
/// <summary>
/// Creates a U4 (Unknown) uncertainty state for subjects with no analysis.
/// </summary>
public static (string Code, string Name, double Entropy) CreateUnknownState()
{
return (UncertaintyCodes.Unknown, "Unknown", 1.0);
}
/// <summary>
/// Creates a U1 (MissingSymbolResolution) uncertainty state.
/// </summary>
/// <param name="unknownsCount">Number of unresolved symbols</param>
/// <param name="totalSymbols">Total symbols in callgraph</param>
public static (string Code, string Name, double Entropy) CreateMissingSymbolState(
int unknownsCount,
int totalSymbols)
{
var entropy = totalSymbols > 0
? (double)unknownsCount / totalSymbols
: 0.0;
return (UncertaintyCodes.MissingSymbolResolution, "MissingSymbolResolution", entropy);
}
}

View File

@@ -21,8 +21,12 @@ public sealed class ReachabilityFactDocument
public ContextFacts? ContextFacts { get; set; }
public UncertaintyDocument? Uncertainty { get; set; }
public double Score { get; set; }
public double RiskScore { get; set; }
public int UnknownsCount { get; set; }
public double UnknownsPressure { get; set; }
@@ -42,6 +46,16 @@ public sealed class ReachabilityStateDocument
public string Bucket { get; set; } = "unknown";
/// <summary>
/// v1 lattice state code (U, SR, SU, RO, RU, CR, CU, X).
/// </summary>
public string? LatticeState { get; set; }
/// <summary>
/// Previous lattice state before this transition (for audit trail).
/// </summary>
public string? PreviousLatticeState { get; set; }
public double Weight { get; set; }
public double Score { get; set; }
@@ -49,6 +63,11 @@ public sealed class ReachabilityStateDocument
public List<string> Path { get; set; } = new();
public ReachabilityEvidenceDocument Evidence { get; set; } = new();
/// <summary>
/// Timestamp of the last lattice state transition.
/// </summary>
public DateTimeOffset? LatticeTransitionAt { get; set; }
}
public sealed class ReachabilityEvidenceDocument

View File

@@ -14,8 +14,13 @@ public sealed record ReachabilityFactUpdatedEvent(
double Weight,
int StateCount,
double FactScore,
double RiskScore,
int UnknownsCount,
double UnknownsPressure,
int UncertaintyCount,
double MaxEntropy,
double AverageEntropy,
double AverageConfidence,
DateTimeOffset ComputedAtUtc,
string[] Targets);
string[] Targets,
string[] UncertaintyCodes);

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signals.Models;
public sealed class UncertaintyDocument
{
public List<UncertaintyStateDocument> States { get; set; } = new();
/// <summary>
/// Aggregate tier (T1=High, T2=Medium, T3=Low, T4=Negligible).
/// Computed as the maximum severity tier across all states.
/// </summary>
public string? AggregateTier { get; set; }
/// <summary>
/// Risk score incorporating tier-based modifiers and entropy boost.
/// </summary>
public double? RiskScore { get; set; }
/// <summary>
/// Timestamp when the uncertainty was computed.
/// </summary>
public DateTimeOffset? ComputedAt { get; set; }
}
public sealed class UncertaintyStateDocument
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public double Entropy { get; set; }
/// <summary>
/// Tier for this specific state (T1-T4).
/// </summary>
public string? Tier { get; set; }
public List<UncertaintyEvidenceDocument> Evidence { get; set; } = new();
public DateTimeOffset? Timestamp { get; set; }
}
public sealed class UncertaintyEvidenceDocument
{
public string Type { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}

View File

@@ -37,6 +37,16 @@ public sealed class SignalsScoringOptions
/// </summary>
public double UnknownsPenaltyCeiling { get; set; } = 0.35;
/// <summary>
/// Multiplier applied to average uncertainty entropy when computing a risk score boost (k in docs/uncertainty/README.md).
/// </summary>
public double UncertaintyEntropyMultiplier { get; set; } = 0.5;
/// <summary>
/// Maximum boost applied from uncertainty entropy when computing risk score.
/// </summary>
public double UncertaintyBoostCeiling { get; set; } = 0.5;
/// <summary>
/// Multipliers applied per reachability bucket. Keys are case-insensitive.
/// Defaults mirror policy scoring config guidance in docs/11_DATA_SCHEMAS.md.
@@ -58,6 +68,8 @@ public sealed class SignalsScoringOptions
EnsurePercent(nameof(MaxConfidence), MaxConfidence);
EnsurePercent(nameof(MinConfidence), MinConfidence);
EnsurePercent(nameof(UnknownsPenaltyCeiling), UnknownsPenaltyCeiling);
EnsurePercent(nameof(UncertaintyEntropyMultiplier), UncertaintyEntropyMultiplier);
EnsurePercent(nameof(UncertaintyBoostCeiling), UncertaintyBoostCeiling);
foreach (var (key, value) in ReachabilityBuckets)
{
EnsurePercent($"ReachabilityBuckets[{key}]", value);

View File

@@ -41,7 +41,9 @@ internal sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepo
RuntimeFacts = source.RuntimeFacts?.Select(CloneRuntime).ToList(),
Metadata = source.Metadata is null ? null : new Dictionary<string, string?>(source.Metadata, StringComparer.OrdinalIgnoreCase),
ContextFacts = source.ContextFacts,
Uncertainty = CloneUncertainty(source.Uncertainty),
Score = source.Score,
RiskScore = source.RiskScore,
UnknownsCount = source.UnknownsCount,
UnknownsPressure = source.UnknownsPressure,
ComputedAt = source.ComputedAt,
@@ -81,4 +83,33 @@ internal sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepo
ObservedAt = source.ObservedAt,
Metadata = source.Metadata is null ? null : new Dictionary<string, string?>(source.Metadata, StringComparer.OrdinalIgnoreCase)
};
private static UncertaintyDocument? CloneUncertainty(UncertaintyDocument? source)
{
if (source is null)
{
return null;
}
return new UncertaintyDocument
{
States = source.States?.Select(CloneUncertaintyState).ToList() ?? new List<UncertaintyStateDocument>()
};
}
private static UncertaintyStateDocument CloneUncertaintyState(UncertaintyStateDocument source) => new()
{
Code = source.Code,
Name = source.Name,
Entropy = source.Entropy,
Timestamp = source.Timestamp,
Evidence = source.Evidence?.Select(CloneUncertaintyEvidence).ToList() ?? new List<UncertaintyEvidenceDocument>()
};
private static UncertaintyEvidenceDocument CloneUncertaintyEvidence(UncertaintyEvidenceDocument source) => new()
{
Type = source.Type,
SourceId = source.SourceId,
Detail = source.Detail
};
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -10,12 +10,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
# Signals · Local Tasks
This file mirrors sprint work for the Signals module.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `UNCERTAINTY-SCHEMA-401-024` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DOING | Add uncertainty states + entropy-derived `riskScore` to reachability facts and update events/tests. |