up
This commit is contained in:
164
src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs
Normal file
164
src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
186
src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs
Normal file
186
src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
52
src/Signals/StellaOps.Signals/Models/UncertaintyDocument.cs
Normal file
52
src/Signals/StellaOps.Signals/Models/UncertaintyDocument.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
src/Signals/StellaOps.Signals/TASKS.md
Normal file
7
src/Signals/StellaOps.Signals/TASKS.md
Normal 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. |
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Tests.GroundTruth;
|
||||
|
||||
/// <summary>
|
||||
/// Ground truth sample manifest.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthManifest
|
||||
{
|
||||
[JsonPropertyName("sampleId")]
|
||||
public required string SampleId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilities")]
|
||||
public IReadOnlyList<GroundTruthVulnerability>? Vulnerabilities { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability reference in manifest.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthVulnerability
|
||||
{
|
||||
[JsonPropertyName("vulnId")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedSymbol")]
|
||||
public required string AffectedSymbol { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ground truth document for reachability validation.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthDocument
|
||||
{
|
||||
[JsonPropertyName("schema")]
|
||||
public required string Schema { get; init; }
|
||||
|
||||
[JsonPropertyName("sampleId")]
|
||||
public required string SampleId { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("generator")]
|
||||
public required GroundTruthGenerator Generator { get; init; }
|
||||
|
||||
[JsonPropertyName("targets")]
|
||||
public required IReadOnlyList<GroundTruthTarget> Targets { get; init; }
|
||||
|
||||
[JsonPropertyName("entryPoints")]
|
||||
public required IReadOnlyList<GroundTruthEntryPoint> EntryPoints { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedUncertainty")]
|
||||
public GroundTruthUncertainty? ExpectedUncertainty { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedGateDecisions")]
|
||||
public IReadOnlyList<GroundTruthGateDecision>? ExpectedGateDecisions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generator metadata.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthGenerator
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("annotator")]
|
||||
public string? Annotator { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target symbol with expected outcomes.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthTarget
|
||||
{
|
||||
[JsonPropertyName("symbolId")]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("display")]
|
||||
public string? Display { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("expected")]
|
||||
public required GroundTruthExpected Expected { get; init; }
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public required string Reasoning { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected outcomes for a target.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthExpected
|
||||
{
|
||||
[JsonPropertyName("latticeState")]
|
||||
public required string LatticeState { get; init; }
|
||||
|
||||
[JsonPropertyName("bucket")]
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
[JsonPropertyName("reachable")]
|
||||
public bool? Reachable { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("pathLength")]
|
||||
public int? PathLength { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string>? Path { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry point definition.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthEntryPoint
|
||||
{
|
||||
[JsonPropertyName("symbolId")]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("display")]
|
||||
public string? Display { get; init; }
|
||||
|
||||
[JsonPropertyName("phase")]
|
||||
public required string Phase { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected uncertainty state.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthUncertainty
|
||||
{
|
||||
[JsonPropertyName("states")]
|
||||
public IReadOnlyList<GroundTruthUncertaintyState>? States { get; init; }
|
||||
|
||||
[JsonPropertyName("aggregateTier")]
|
||||
public required string AggregateTier { get; init; }
|
||||
|
||||
[JsonPropertyName("riskScore")]
|
||||
public double RiskScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual uncertainty state.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthUncertaintyState
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
[JsonPropertyName("entropy")]
|
||||
public required double Entropy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected gate decision.
|
||||
/// </summary>
|
||||
public sealed record GroundTruthGateDecision
|
||||
{
|
||||
[JsonPropertyName("vulnId")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
[JsonPropertyName("targetSymbol")]
|
||||
public required string TargetSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("requestedStatus")]
|
||||
public required string RequestedStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedDecision")]
|
||||
public required string ExpectedDecision { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedBlockedBy")]
|
||||
public string? ExpectedBlockedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedReason")]
|
||||
public string? ExpectedReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Lattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests.GroundTruth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that validate ground truth samples against lattice and uncertainty tier logic.
|
||||
/// </summary>
|
||||
public class GroundTruthValidatorTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all ground truth samples have valid lattice state codes.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_HasValidLatticeStates(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
foreach (var target in document.Targets)
|
||||
{
|
||||
var state = ReachabilityLatticeStateExtensions.FromCode(target.Expected.LatticeState);
|
||||
|
||||
// Verify the state is valid (not defaulting to Unknown for invalid input)
|
||||
Assert.True(
|
||||
target.Expected.LatticeState == "U" || state != ReachabilityLatticeState.Unknown,
|
||||
$"Invalid lattice state '{target.Expected.LatticeState}' for target {target.SymbolId} in {samplePath}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that lattice state and bucket are consistent.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_LatticeStateMatchesBucket(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
foreach (var target in document.Targets)
|
||||
{
|
||||
var state = ReachabilityLatticeStateExtensions.FromCode(target.Expected.LatticeState);
|
||||
var expectedBucket = state.ToV0Bucket();
|
||||
|
||||
Assert.True(
|
||||
target.Expected.Bucket == expectedBucket,
|
||||
$"Bucket mismatch for {target.SymbolId} in {samplePath}: " +
|
||||
$"expected '{target.Expected.Bucket}' for state '{target.Expected.LatticeState}' but ToV0Bucket returns '{expectedBucket}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that uncertainty tiers are valid.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_HasValidUncertaintyTiers(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
if (document.ExpectedUncertainty is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var validTiers = new[] { "T1", "T2", "T3", "T4" };
|
||||
Assert.Contains(document.ExpectedUncertainty.AggregateTier, validTiers);
|
||||
|
||||
if (document.ExpectedUncertainty.States is not null)
|
||||
{
|
||||
var validCodes = new[] { "U1", "U2", "U3", "U4" };
|
||||
foreach (var state in document.ExpectedUncertainty.States)
|
||||
{
|
||||
Assert.Contains(state.Code, validCodes);
|
||||
Assert.InRange(state.Entropy, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that gate decisions reference valid target symbols.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_GateDecisionsReferenceValidTargets(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
if (document.ExpectedGateDecisions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetSymbols = document.Targets.Select(t => t.SymbolId).ToHashSet();
|
||||
|
||||
foreach (var decision in document.ExpectedGateDecisions)
|
||||
{
|
||||
Assert.True(
|
||||
targetSymbols.Contains(decision.TargetSymbol),
|
||||
$"Gate decision references unknown target '{decision.TargetSymbol}' in {samplePath}");
|
||||
|
||||
var validDecisions = new[] { "allow", "block", "warn" };
|
||||
Assert.Contains(decision.ExpectedDecision, validDecisions);
|
||||
|
||||
var validStatuses = new[] { "affected", "not_affected", "under_investigation", "fixed" };
|
||||
Assert.Contains(decision.RequestedStatus, validStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that reachable targets have paths, unreachable do not.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_PathConsistencyWithReachability(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
foreach (var target in document.Targets)
|
||||
{
|
||||
if (target.Expected.Reachable == true)
|
||||
{
|
||||
Assert.True(
|
||||
target.Expected.PathLength.HasValue && target.Expected.PathLength > 0,
|
||||
$"Reachable target '{target.SymbolId}' should have pathLength > 0 in {samplePath}");
|
||||
|
||||
Assert.True(
|
||||
target.Expected.Path is not null && target.Expected.Path.Count > 0,
|
||||
$"Reachable target '{target.SymbolId}' should have non-empty path in {samplePath}");
|
||||
}
|
||||
else if (target.Expected.Reachable == false)
|
||||
{
|
||||
Assert.True(
|
||||
target.Expected.PathLength is null || target.Expected.PathLength == 0,
|
||||
$"Unreachable target '{target.SymbolId}' should have null or 0 pathLength in {samplePath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that entry points have valid phases.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_EntryPointsHaveValidPhases(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
var validPhases = new[] { "load", "init", "runtime", "main", "fini" };
|
||||
|
||||
foreach (var entry in document.EntryPoints)
|
||||
{
|
||||
Assert.Contains(entry.Phase, validPhases);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all targets have reasoning explanations.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(GetGroundTruthSamples))]
|
||||
public void GroundTruth_AllTargetsHaveReasoning(string samplePath, GroundTruthDocument document)
|
||||
{
|
||||
foreach (var target in document.Targets)
|
||||
{
|
||||
Assert.False(
|
||||
string.IsNullOrWhiteSpace(target.Reasoning),
|
||||
$"Target '{target.SymbolId}' missing reasoning in {samplePath}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides ground truth samples from the datasets directory.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> GetGroundTruthSamples()
|
||||
{
|
||||
// Find the datasets directory (relative to test execution)
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var searchDirs = new[]
|
||||
{
|
||||
Path.Combine(currentDir, "datasets", "reachability", "samples"),
|
||||
Path.Combine(currentDir, "..", "..", "..", "..", "..", "..", "datasets", "reachability", "samples"),
|
||||
Path.Combine(currentDir, "..", "..", "..", "..", "..", "..", "..", "datasets", "reachability", "samples"),
|
||||
};
|
||||
|
||||
string? datasetsPath = null;
|
||||
foreach (var dir in searchDirs)
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
datasetsPath = dir;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (datasetsPath is null)
|
||||
{
|
||||
// Return empty if datasets not found (allows tests to pass in CI without samples)
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var groundTruthFile in Directory.EnumerateFiles(datasetsPath, "ground-truth.json", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(datasetsPath, groundTruthFile);
|
||||
var json = File.ReadAllText(groundTruthFile);
|
||||
var document = JsonSerializer.Deserialize<GroundTruthDocument>(json, JsonOptions);
|
||||
|
||||
if (document is not null)
|
||||
{
|
||||
yield return new object[] { relativePath, document };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using StellaOps.Signals.Lattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class ReachabilityLatticeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyReachable)]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Contested)]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.ConfirmedReachable)]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.RuntimeUnobserved, ReachabilityLatticeState.ConfirmedUnreachable)]
|
||||
[InlineData(ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.StaticallyUnreachable, ReachabilityLatticeState.Contested)]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.ConfirmedUnreachable, ReachabilityLatticeState.Contested)]
|
||||
[InlineData(ReachabilityLatticeState.Contested, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Contested)]
|
||||
public void Join_ReturnsExpectedState(ReachabilityLatticeState a, ReachabilityLatticeState b, ReachabilityLatticeState expected)
|
||||
{
|
||||
var result = ReachabilityLattice.Join(a, b);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ReachabilityLatticeState.Unknown, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Unknown)]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedReachable, ReachabilityLatticeState.RuntimeObserved, ReachabilityLatticeState.RuntimeObserved)]
|
||||
[InlineData(ReachabilityLatticeState.Contested, ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.StaticallyReachable)]
|
||||
[InlineData(ReachabilityLatticeState.Contested, ReachabilityLatticeState.Unknown, ReachabilityLatticeState.Unknown)]
|
||||
public void Meet_ReturnsExpectedState(ReachabilityLatticeState a, ReachabilityLatticeState b, ReachabilityLatticeState expected)
|
||||
{
|
||||
var result = ReachabilityLattice.Meet(a, b);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_IsCommutative()
|
||||
{
|
||||
var states = Enum.GetValues<ReachabilityLatticeState>();
|
||||
foreach (var a in states)
|
||||
{
|
||||
foreach (var b in states)
|
||||
{
|
||||
Assert.Equal(ReachabilityLattice.Join(a, b), ReachabilityLattice.Join(b, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Meet_IsCommutative()
|
||||
{
|
||||
var states = Enum.GetValues<ReachabilityLatticeState>();
|
||||
foreach (var a in states)
|
||||
{
|
||||
foreach (var b in states)
|
||||
{
|
||||
Assert.Equal(ReachabilityLattice.Meet(a, b), ReachabilityLattice.Meet(b, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinAll_WithEmptySequence_ReturnsUnknown()
|
||||
{
|
||||
var result = ReachabilityLattice.JoinAll(Array.Empty<ReachabilityLatticeState>());
|
||||
Assert.Equal(ReachabilityLatticeState.Unknown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinAll_StopsEarlyOnContested()
|
||||
{
|
||||
var states = new[] { ReachabilityLatticeState.StaticallyReachable, ReachabilityLatticeState.Contested, ReachabilityLatticeState.Unknown };
|
||||
var result = ReachabilityLattice.JoinAll(states);
|
||||
Assert.Equal(ReachabilityLatticeState.Contested, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false, false, ReachabilityLatticeState.StaticallyReachable)]
|
||||
[InlineData(false, false, false, ReachabilityLatticeState.StaticallyUnreachable)]
|
||||
[InlineData(null, false, false, ReachabilityLatticeState.Unknown)]
|
||||
[InlineData(true, true, true, ReachabilityLatticeState.ConfirmedReachable)]
|
||||
[InlineData(false, true, false, ReachabilityLatticeState.ConfirmedUnreachable)]
|
||||
[InlineData(false, true, true, ReachabilityLatticeState.Contested)]
|
||||
public void FromEvidence_ReturnsExpectedState(bool? staticReachable, bool hasRuntimeEvidence, bool runtimeObserved, ReachabilityLatticeState expected)
|
||||
{
|
||||
var result = ReachabilityLattice.FromEvidence(staticReachable, hasRuntimeEvidence, runtimeObserved);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("entrypoint", false, ReachabilityLatticeState.ConfirmedReachable)]
|
||||
[InlineData("direct", false, ReachabilityLatticeState.StaticallyReachable)]
|
||||
[InlineData("direct", true, ReachabilityLatticeState.ConfirmedReachable)]
|
||||
[InlineData("runtime", false, ReachabilityLatticeState.RuntimeObserved)]
|
||||
[InlineData("unreachable", false, ReachabilityLatticeState.StaticallyUnreachable)]
|
||||
[InlineData("unreachable", true, ReachabilityLatticeState.Contested)]
|
||||
[InlineData("unknown", false, ReachabilityLatticeState.Unknown)]
|
||||
public void FromV0Bucket_ReturnsExpectedState(string bucket, bool hasRuntimeHits, ReachabilityLatticeState expected)
|
||||
{
|
||||
var result = ReachabilityLattice.FromV0Bucket(bucket, hasRuntimeHits);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
public class ReachabilityLatticeStateExtensionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ReachabilityLatticeState.Unknown, "U")]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyReachable, "SR")]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyUnreachable, "SU")]
|
||||
[InlineData(ReachabilityLatticeState.RuntimeObserved, "RO")]
|
||||
[InlineData(ReachabilityLatticeState.RuntimeUnobserved, "RU")]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedReachable, "CR")]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedUnreachable, "CU")]
|
||||
[InlineData(ReachabilityLatticeState.Contested, "X")]
|
||||
public void ToCode_ReturnsExpectedCode(ReachabilityLatticeState state, string expectedCode)
|
||||
{
|
||||
Assert.Equal(expectedCode, state.ToCode());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("U", ReachabilityLatticeState.Unknown)]
|
||||
[InlineData("SR", ReachabilityLatticeState.StaticallyReachable)]
|
||||
[InlineData("SU", ReachabilityLatticeState.StaticallyUnreachable)]
|
||||
[InlineData("RO", ReachabilityLatticeState.RuntimeObserved)]
|
||||
[InlineData("RU", ReachabilityLatticeState.RuntimeUnobserved)]
|
||||
[InlineData("CR", ReachabilityLatticeState.ConfirmedReachable)]
|
||||
[InlineData("CU", ReachabilityLatticeState.ConfirmedUnreachable)]
|
||||
[InlineData("X", ReachabilityLatticeState.Contested)]
|
||||
[InlineData("invalid", ReachabilityLatticeState.Unknown)]
|
||||
[InlineData("", ReachabilityLatticeState.Unknown)]
|
||||
[InlineData(null, ReachabilityLatticeState.Unknown)]
|
||||
public void FromCode_ReturnsExpectedState(string? code, ReachabilityLatticeState expected)
|
||||
{
|
||||
Assert.Equal(expected, ReachabilityLatticeStateExtensions.FromCode(code));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedUnreachable, "unreachable")]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyUnreachable, "unreachable")]
|
||||
[InlineData(ReachabilityLatticeState.RuntimeUnobserved, "unreachable")]
|
||||
[InlineData(ReachabilityLatticeState.ConfirmedReachable, "runtime")]
|
||||
[InlineData(ReachabilityLatticeState.RuntimeObserved, "runtime")]
|
||||
[InlineData(ReachabilityLatticeState.StaticallyReachable, "direct")]
|
||||
[InlineData(ReachabilityLatticeState.Unknown, "unknown")]
|
||||
[InlineData(ReachabilityLatticeState.Contested, "unknown")]
|
||||
public void ToV0Bucket_ReturnsExpectedBucket(ReachabilityLatticeState state, string expectedBucket)
|
||||
{
|
||||
Assert.Equal(expectedBucket, state.ToV0Bucket());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,6 +82,8 @@ public class ReachabilityScoringServiceTests
|
||||
Assert.Contains("target", state.Evidence.RuntimeHits);
|
||||
|
||||
Assert.Equal(0.405, fact.Score, 3);
|
||||
Assert.Equal(0.405, fact.RiskScore, 3);
|
||||
Assert.Null(fact.Uncertainty);
|
||||
Assert.Equal("1", fact.Metadata?["fact.version"]);
|
||||
Assert.False(string.IsNullOrWhiteSpace(fact.Metadata?["fact.digest"]));
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ public class ReachabilityUnionIngestionServiceTests
|
||||
var tempRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "signals-union-test-" + Guid.NewGuid().ToString("N")));
|
||||
var signalsOptions = new SignalsOptions();
|
||||
signalsOptions.Storage.RootPath = tempRoot.FullName;
|
||||
signalsOptions.Mongo.ConnectionString = "mongodb://localhost";
|
||||
signalsOptions.Mongo.Database = "stub";
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(signalsOptions);
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using StellaOps.Signals.Lattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class UncertaintyTierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(UncertaintyTier.T1, 0.50)]
|
||||
[InlineData(UncertaintyTier.T2, 0.25)]
|
||||
[InlineData(UncertaintyTier.T3, 0.10)]
|
||||
[InlineData(UncertaintyTier.T4, 0.00)]
|
||||
public void GetRiskModifier_ReturnsExpectedValue(UncertaintyTier tier, double expected)
|
||||
{
|
||||
Assert.Equal(expected, tier.GetRiskModifier());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UncertaintyTier.T1, true)]
|
||||
[InlineData(UncertaintyTier.T2, false)]
|
||||
[InlineData(UncertaintyTier.T3, false)]
|
||||
[InlineData(UncertaintyTier.T4, false)]
|
||||
public void BlocksNotAffected_ReturnsExpected(UncertaintyTier tier, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, tier.BlocksNotAffected());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UncertaintyTier.T1, true)]
|
||||
[InlineData(UncertaintyTier.T2, true)]
|
||||
[InlineData(UncertaintyTier.T3, false)]
|
||||
[InlineData(UncertaintyTier.T4, false)]
|
||||
public void RequiresWarning_ReturnsExpected(UncertaintyTier tier, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, tier.RequiresWarning());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(UncertaintyTier.T1, "High")]
|
||||
[InlineData(UncertaintyTier.T2, "Medium")]
|
||||
[InlineData(UncertaintyTier.T3, "Low")]
|
||||
[InlineData(UncertaintyTier.T4, "Negligible")]
|
||||
public void ToDisplayName_ReturnsExpected(UncertaintyTier tier, string expected)
|
||||
{
|
||||
Assert.Equal(expected, tier.ToDisplayName());
|
||||
}
|
||||
}
|
||||
|
||||
public class UncertaintyTierCalculatorTests
|
||||
{
|
||||
// U1 (MissingSymbolResolution) tier calculation
|
||||
[Theory]
|
||||
[InlineData("U1", 0.7, UncertaintyTier.T1)]
|
||||
[InlineData("U1", 0.8, UncertaintyTier.T1)]
|
||||
[InlineData("U1", 0.4, UncertaintyTier.T2)]
|
||||
[InlineData("U1", 0.5, UncertaintyTier.T2)]
|
||||
[InlineData("U1", 0.3, UncertaintyTier.T3)]
|
||||
[InlineData("U1", 0.0, UncertaintyTier.T3)]
|
||||
public void CalculateTier_U1_ReturnsExpected(string code, double entropy, UncertaintyTier expected)
|
||||
{
|
||||
Assert.Equal(expected, UncertaintyTierCalculator.CalculateTier(code, entropy));
|
||||
}
|
||||
|
||||
// U2 (MissingPurl) tier calculation
|
||||
[Theory]
|
||||
[InlineData("U2", 0.5, UncertaintyTier.T2)]
|
||||
[InlineData("U2", 0.6, UncertaintyTier.T2)]
|
||||
[InlineData("U2", 0.4, UncertaintyTier.T3)]
|
||||
[InlineData("U2", 0.0, UncertaintyTier.T3)]
|
||||
public void CalculateTier_U2_ReturnsExpected(string code, double entropy, UncertaintyTier expected)
|
||||
{
|
||||
Assert.Equal(expected, UncertaintyTierCalculator.CalculateTier(code, entropy));
|
||||
}
|
||||
|
||||
// U3 (UntrustedAdvisory) tier calculation
|
||||
[Theory]
|
||||
[InlineData("U3", 0.6, UncertaintyTier.T3)]
|
||||
[InlineData("U3", 0.8, UncertaintyTier.T3)]
|
||||
[InlineData("U3", 0.5, UncertaintyTier.T4)]
|
||||
[InlineData("U3", 0.0, UncertaintyTier.T4)]
|
||||
public void CalculateTier_U3_ReturnsExpected(string code, double entropy, UncertaintyTier expected)
|
||||
{
|
||||
Assert.Equal(expected, UncertaintyTierCalculator.CalculateTier(code, entropy));
|
||||
}
|
||||
|
||||
// U4 (Unknown) always T1
|
||||
[Theory]
|
||||
[InlineData("U4", 0.0, UncertaintyTier.T1)]
|
||||
[InlineData("U4", 0.5, UncertaintyTier.T1)]
|
||||
[InlineData("U4", 1.0, UncertaintyTier.T1)]
|
||||
public void CalculateTier_U4_AlwaysReturnsT1(string code, double entropy, UncertaintyTier expected)
|
||||
{
|
||||
Assert.Equal(expected, UncertaintyTierCalculator.CalculateTier(code, entropy));
|
||||
}
|
||||
|
||||
// Unknown code defaults to T4
|
||||
[Theory]
|
||||
[InlineData("Unknown", 0.5, UncertaintyTier.T4)]
|
||||
[InlineData("", 0.5, UncertaintyTier.T4)]
|
||||
public void CalculateTier_UnknownCode_ReturnsT4(string code, double entropy, UncertaintyTier expected)
|
||||
{
|
||||
Assert.Equal(expected, UncertaintyTierCalculator.CalculateTier(code, entropy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateAggregateTier_WithEmptySequence_ReturnsT4()
|
||||
{
|
||||
var result = UncertaintyTierCalculator.CalculateAggregateTier(Array.Empty<(string, double)>());
|
||||
Assert.Equal(UncertaintyTier.T4, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateAggregateTier_ReturnsMaxSeverity()
|
||||
{
|
||||
var states = new[] { ("U1", 0.3), ("U2", 0.6), ("U3", 0.5) }; // T3, T2, T4
|
||||
var result = UncertaintyTierCalculator.CalculateAggregateTier(states);
|
||||
Assert.Equal(UncertaintyTier.T2, result); // Maximum severity (lowest enum value)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateAggregateTier_StopsAtT1()
|
||||
{
|
||||
var states = new[] { ("U4", 1.0), ("U1", 0.3) }; // T1, T3
|
||||
var result = UncertaintyTierCalculator.CalculateAggregateTier(states);
|
||||
Assert.Equal(UncertaintyTier.T1, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.5, UncertaintyTier.T4, 0.1, 0.5, 0.525)] // No tier modifier for T4, but entropy boost applies
|
||||
[InlineData(0.5, UncertaintyTier.T3, 0.1, 0.5, 0.575)] // +10% + entropy boost
|
||||
[InlineData(0.5, UncertaintyTier.T2, 0.1, 0.5, 0.65)] // +25% + entropy boost
|
||||
[InlineData(0.5, UncertaintyTier.T1, 0.1, 0.5, 0.775)] // +50% + entropy boost
|
||||
public void CalculateRiskScore_AppliesModifiers(
|
||||
double baseScore,
|
||||
UncertaintyTier tier,
|
||||
double meanEntropy,
|
||||
double entropyMultiplier,
|
||||
double expected)
|
||||
{
|
||||
var result = UncertaintyTierCalculator.CalculateRiskScore(
|
||||
baseScore, tier, meanEntropy, entropyMultiplier, 0.5);
|
||||
Assert.Equal(expected, result, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRiskScore_ClampsToCeiling()
|
||||
{
|
||||
var result = UncertaintyTierCalculator.CalculateRiskScore(
|
||||
0.9, UncertaintyTier.T1, 0.8, 0.5, 0.5);
|
||||
Assert.Equal(1.0, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUnknownState_ReturnsU4WithMaxEntropy()
|
||||
{
|
||||
var (code, name, entropy) = UncertaintyTierCalculator.CreateUnknownState();
|
||||
Assert.Equal("U4", code);
|
||||
Assert.Equal("Unknown", name);
|
||||
Assert.Equal(1.0, entropy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 100, 0.1)]
|
||||
[InlineData(50, 100, 0.5)]
|
||||
[InlineData(0, 100, 0.0)]
|
||||
[InlineData(0, 0, 0.0)]
|
||||
public void CreateMissingSymbolState_CalculatesEntropy(int unknowns, int total, double expectedEntropy)
|
||||
{
|
||||
var (code, name, entropy) = UncertaintyTierCalculator.CreateMissingSymbolState(unknowns, total);
|
||||
Assert.Equal("U1", code);
|
||||
Assert.Equal("MissingSymbolResolution", name);
|
||||
Assert.Equal(expectedEntropy, entropy, 5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user