finish off sprint advisories and sprints
This commit is contained in:
@@ -439,6 +439,21 @@ public sealed record EvidenceWeightPolicy
|
||||
FormulaMode = FormulaMode.Advisory
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy from weights using default configuration.
|
||||
/// </summary>
|
||||
public static EvidenceWeightPolicy FromWeights(EvidenceWeights weights)
|
||||
{
|
||||
return new EvidenceWeightPolicy
|
||||
{
|
||||
Version = "ews.v1",
|
||||
Profile = "custom",
|
||||
Weights = weights,
|
||||
FormulaMode = FormulaMode.Legacy,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// File-based weight manifest loader.
|
||||
/// Loads manifests from etc/weights/*.json files.
|
||||
/// </summary>
|
||||
public sealed class FileBasedWeightManifestLoader : IWeightManifestLoader
|
||||
{
|
||||
private readonly FileBasedWeightManifestLoaderOptions _options;
|
||||
private readonly ILogger<FileBasedWeightManifestLoader> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new file-based manifest loader.
|
||||
/// </summary>
|
||||
public FileBasedWeightManifestLoader(
|
||||
IOptions<FileBasedWeightManifestLoaderOptions> options,
|
||||
ILogger<FileBasedWeightManifestLoader> logger)
|
||||
{
|
||||
_options = options?.Value ?? new FileBasedWeightManifestLoaderOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> LoadAsync(string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = GetManifestPath(version);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("Weight manifest not found: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<WeightManifest>(json, _jsonOptions);
|
||||
|
||||
if (manifest != null)
|
||||
{
|
||||
// Compute and verify hash if needed
|
||||
var computedHash = WeightManifest.ComputeContentHash(json);
|
||||
|
||||
if (manifest.ContentHash == "sha256:auto" || string.IsNullOrEmpty(manifest.ContentHash))
|
||||
{
|
||||
// Auto-compute hash
|
||||
manifest = manifest with { ContentHash = computedHash };
|
||||
}
|
||||
else if (manifest.ContentHash != computedHash && _options.VerifyHashes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Hash mismatch for manifest {Version}: expected {Expected}, got {Actual}",
|
||||
version, manifest.ContentHash, computedHash);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loaded weight manifest {Version} from {FilePath}", version, filePath);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse weight manifest: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read weight manifest: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> LoadLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = await ListVersionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No weight manifests found in {Directory}", _options.WeightsDirectory);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Versions are already sorted newest first
|
||||
return await LoadAsync(versions[0], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListVersionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var directory = GetWeightsDirectory();
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Weights directory does not exist: {Directory}", directory);
|
||||
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(directory, _options.ManifestPattern)
|
||||
.Select(f => Path.GetFileNameWithoutExtension(f))
|
||||
.Where(f => !string.IsNullOrEmpty(f))
|
||||
.OrderByDescending(f => f, StringComparer.Ordinal) // Newest first (assuming date-based naming)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(files);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = GetManifestPath(version);
|
||||
return Task.FromResult(File.Exists(filePath));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> GetEffectiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = await ListVersionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var version in versions)
|
||||
{
|
||||
var manifest = await LoadAsync(version, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest != null && manifest.EffectiveFrom <= asOf)
|
||||
{
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the weights directory path.
|
||||
/// </summary>
|
||||
private string GetWeightsDirectory()
|
||||
{
|
||||
if (Path.IsPathRooted(_options.WeightsDirectory))
|
||||
{
|
||||
return _options.WeightsDirectory;
|
||||
}
|
||||
|
||||
// Relative to application base directory
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(baseDir, _options.WeightsDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path for a manifest version.
|
||||
/// </summary>
|
||||
private string GetManifestPath(string version)
|
||||
{
|
||||
var directory = GetWeightsDirectory();
|
||||
var fileName = $"{version}.weights.json";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for file-based manifest loader.
|
||||
/// </summary>
|
||||
public sealed class FileBasedWeightManifestLoaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Directory containing weight manifest files.
|
||||
/// Can be absolute or relative to application base directory.
|
||||
/// Default: "etc/weights"
|
||||
/// </summary>
|
||||
public string WeightsDirectory { get; set; } = "etc/weights";
|
||||
|
||||
/// <summary>
|
||||
/// File pattern for manifest files.
|
||||
/// Default: "*.weights.json"
|
||||
/// </summary>
|
||||
public string ManifestPattern { get; set; } = "*.weights.json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify content hashes on load.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool VerifyHashes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable hot reload of manifest files.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableHotReload { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload interval in seconds (if enabled).
|
||||
/// Default: 30
|
||||
/// </summary>
|
||||
public int HotReloadIntervalSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading weight manifests from external sources.
|
||||
/// </summary>
|
||||
public interface IWeightManifestLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a specific version of the weight manifest.
|
||||
/// </summary>
|
||||
/// <param name="version">Version identifier (e.g., "v2026-01-22").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The loaded manifest, or null if not found.</returns>
|
||||
Task<WeightManifest?> LoadAsync(string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the latest available weight manifest.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest manifest, or null if none available.</returns>
|
||||
Task<WeightManifest?> LoadLatestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available manifest versions.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of available versions ordered by effective date (newest first).</returns>
|
||||
Task<IReadOnlyList<string>> ListVersionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific version exists.
|
||||
/// </summary>
|
||||
/// <param name="version">Version identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the version exists.</returns>
|
||||
Task<bool> ExistsAsync(string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective manifest for a specific date.
|
||||
/// Returns the latest manifest with effectiveFrom <= date.
|
||||
/// </summary>
|
||||
/// <param name="asOf">Date to check against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The effective manifest, or null if none available.</returns>
|
||||
Task<WeightManifest?> GetEffectiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of loading a weight manifest with metadata.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestLoadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The loaded manifest, or null if load failed.
|
||||
/// </summary>
|
||||
public WeightManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the manifest (e.g., file path, URL).
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed content hash for integrity verification.
|
||||
/// </summary>
|
||||
public string? ComputedHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hash matches the declared hash in the manifest.
|
||||
/// </summary>
|
||||
public bool HashVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Load timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset LoadedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if load failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the load was successful.
|
||||
/// </summary>
|
||||
public bool Success => Manifest != null && Error == null;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest representing externalized EWS configuration.
|
||||
/// Loaded from etc/weights/*.json files.
|
||||
/// </summary>
|
||||
public sealed record WeightManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the manifest format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Unique version identifier (e.g., "v2026-01-22").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this manifest becomes effective (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "production", "development").
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string Profile { get; init; } = "production";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the manifest content for integrity verification.
|
||||
/// Computed at load time if "auto".
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight definitions for both legacy and advisory formulas.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public required WeightDefinitions Weights { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail configuration (caps and floors).
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public GuardrailDefinitions? Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bucket threshold configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buckets")]
|
||||
public BucketDefinitions? Buckets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization entropy thresholds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinizationThresholds")]
|
||||
public DeterminizationThresholds? DeterminizationThresholds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest metadata (changelog, notes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ManifestMetadata? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this manifest to EvidenceWeights for scoring.
|
||||
/// </summary>
|
||||
public EvidenceWeights ToEvidenceWeights()
|
||||
{
|
||||
return new EvidenceWeights
|
||||
{
|
||||
// Legacy weights
|
||||
Rch = Weights.Legacy?.Rch ?? 0.30,
|
||||
Rts = Weights.Legacy?.Rts ?? 0.25,
|
||||
Bkp = Weights.Legacy?.Bkp ?? 0.15,
|
||||
Xpl = Weights.Legacy?.Xpl ?? 0.15,
|
||||
Src = Weights.Legacy?.Src ?? 0.10,
|
||||
Mit = Weights.Legacy?.Mit ?? 0.10,
|
||||
// Advisory weights
|
||||
Cvss = Weights.Advisory?.Cvss ?? 0.25,
|
||||
Epss = Weights.Advisory?.Epss ?? 0.30,
|
||||
Reachability = Weights.Advisory?.Reachability ?? 0.20,
|
||||
ExploitMaturity = Weights.Advisory?.ExploitMaturity ?? 0.10,
|
||||
PatchProof = Weights.Advisory?.PatchProof ?? 0.15
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a WeightManifest from EvidenceWeights.
|
||||
/// </summary>
|
||||
public static WeightManifest FromEvidenceWeights(EvidenceWeights weights, string version, string? description = null)
|
||||
{
|
||||
return new WeightManifest
|
||||
{
|
||||
Version = version,
|
||||
EffectiveFrom = DateTimeOffset.UtcNow,
|
||||
Description = description ?? $"Auto-generated from EvidenceWeights at {DateTimeOffset.UtcNow:O}",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Legacy = new LegacyWeights
|
||||
{
|
||||
Rch = weights.Rch,
|
||||
Rts = weights.Rts,
|
||||
Bkp = weights.Bkp,
|
||||
Xpl = weights.Xpl,
|
||||
Src = weights.Src,
|
||||
Mit = weights.Mit
|
||||
},
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = weights.Cvss,
|
||||
Epss = weights.Epss,
|
||||
Reachability = weights.Reachability,
|
||||
ExploitMaturity = weights.ExploitMaturity,
|
||||
PatchProof = weights.PatchProof
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of the manifest content.
|
||||
/// </summary>
|
||||
public static string ComputeContentHash(string jsonContent)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(jsonContent);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight definitions containing both legacy and advisory weights.
|
||||
/// </summary>
|
||||
public sealed record WeightDefinitions
|
||||
{
|
||||
[JsonPropertyName("legacy")]
|
||||
public LegacyWeights? Legacy { get; init; }
|
||||
|
||||
[JsonPropertyName("advisory")]
|
||||
public AdvisoryWeights? Advisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy 6-dimension weights (ews.v1).
|
||||
/// </summary>
|
||||
public sealed record LegacyWeights
|
||||
{
|
||||
[JsonPropertyName("rch")]
|
||||
public double Rch { get; init; }
|
||||
|
||||
[JsonPropertyName("rts")]
|
||||
public double Rts { get; init; }
|
||||
|
||||
[JsonPropertyName("bkp")]
|
||||
public double Bkp { get; init; }
|
||||
|
||||
[JsonPropertyName("xpl")]
|
||||
public double Xpl { get; init; }
|
||||
|
||||
[JsonPropertyName("src")]
|
||||
public double Src { get; init; }
|
||||
|
||||
[JsonPropertyName("mit")]
|
||||
public double Mit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory 5-dimension weights (ews.v2).
|
||||
/// </summary>
|
||||
public sealed record AdvisoryWeights
|
||||
{
|
||||
[JsonPropertyName("cvss")]
|
||||
public double Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("epss")]
|
||||
public double Epss { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public double Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("exploitMaturity")]
|
||||
public double ExploitMaturity { get; init; }
|
||||
|
||||
[JsonPropertyName("patchProof")]
|
||||
public double PatchProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail definitions for score caps and floors.
|
||||
/// </summary>
|
||||
public sealed record GuardrailDefinitions
|
||||
{
|
||||
[JsonPropertyName("notAffectedCap")]
|
||||
public CapDefinition? NotAffectedCap { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeFloor")]
|
||||
public FloorDefinition? RuntimeFloor { get; init; }
|
||||
|
||||
[JsonPropertyName("speculativeCap")]
|
||||
public CapDefinition? SpeculativeCap { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score cap definition.
|
||||
/// </summary>
|
||||
public sealed record CapDefinition
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public int MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresBkpMin")]
|
||||
public double? RequiresBkpMin { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRtsMax")]
|
||||
public double? RequiresRtsMax { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRchMax")]
|
||||
public double? RequiresRchMax { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score floor definition.
|
||||
/// </summary>
|
||||
public sealed record FloorDefinition
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public int MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRtsMin")]
|
||||
public double? RequiresRtsMin { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bucket threshold definitions.
|
||||
/// </summary>
|
||||
public sealed record BucketDefinitions
|
||||
{
|
||||
[JsonPropertyName("actNowMin")]
|
||||
public int ActNowMin { get; init; } = 90;
|
||||
|
||||
[JsonPropertyName("scheduleNextMin")]
|
||||
public int ScheduleNextMin { get; init; } = 70;
|
||||
|
||||
[JsonPropertyName("investigateMin")]
|
||||
public int InvestigateMin { get; init; } = 40;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinization entropy thresholds.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationThresholds
|
||||
{
|
||||
[JsonPropertyName("manualReviewEntropy")]
|
||||
public double ManualReviewEntropy { get; init; } = 0.60;
|
||||
|
||||
[JsonPropertyName("refreshEntropy")]
|
||||
public double RefreshEntropy { get; init; } = 0.40;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest metadata.
|
||||
/// </summary>
|
||||
public sealed record ManifestMetadata
|
||||
{
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("changelog")]
|
||||
public IReadOnlyList<ChangelogEntry>? Changelog { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public IReadOnlyList<string>? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry for manifest versioning.
|
||||
/// </summary>
|
||||
public sealed record ChangelogEntry
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public string? Date { get; init; }
|
||||
|
||||
[JsonPropertyName("changes")]
|
||||
public IReadOnlyList<string>? Changes { get; init; }
|
||||
}
|
||||
@@ -894,8 +894,9 @@ signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class public for WebApplicationFactory<Program> test support
|
||||
public partial class Program
|
||||
// Internal: avoids type conflict when this project is referenced from Platform.WebService.
|
||||
// Tests use InternalsVisibleTo + composition wrapper (SignalsTestFactory).
|
||||
internal partial class Program
|
||||
{
|
||||
internal static bool TryAuthorize(HttpContext httpContext, string requiredScope, bool fallbackAllowed, out IResult? failure)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Signals.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Facade service combining EWS computation with Determinization entropy.
|
||||
/// Returns unified result with score, U metric, breakdown, and evidence.
|
||||
/// </summary>
|
||||
public interface IUnifiedScoreService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute unified score combining EWS and Determinization metrics.
|
||||
/// </summary>
|
||||
/// <param name="request">Score computation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Unified score result with breakdown and evidence.</returns>
|
||||
Task<UnifiedScoreResult> ComputeAsync(UnifiedScoreRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compute unified score synchronously (for compatibility with existing sync code).
|
||||
/// </summary>
|
||||
/// <param name="request">Score computation request.</param>
|
||||
/// <returns>Unified score result with breakdown and evidence.</returns>
|
||||
UnifiedScoreResult Compute(UnifiedScoreRequest request);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Builder interface for creating replay logs that capture the full computation trace.
|
||||
/// </summary>
|
||||
public interface IReplayLogBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a replay log from a unified score result.
|
||||
/// </summary>
|
||||
/// <param name="request">The original score request.</param>
|
||||
/// <param name="result">The computed result.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The replay log containing full computation trace.</returns>
|
||||
Task<ReplayLog> BuildAsync(
|
||||
UnifiedScoreRequest request,
|
||||
UnifiedScoreResult result,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a replay log synchronously.
|
||||
/// </summary>
|
||||
ReplayLog Build(UnifiedScoreRequest request, UnifiedScoreResult result);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier interface for replaying and verifying score computations.
|
||||
/// </summary>
|
||||
public interface IReplayVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a replay log by re-executing the computation.
|
||||
/// </summary>
|
||||
/// <param name="replayLog">The replay log to verify.</param>
|
||||
/// <param name="originalInputs">Original inputs to use for replay.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(
|
||||
ReplayLog replayLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signed replay log including signature and optional Rekor proof.
|
||||
/// </summary>
|
||||
/// <param name="signedLog">The signed replay log.</param>
|
||||
/// <param name="originalInputs">Original inputs to use for replay.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifySignedAsync(
|
||||
SignedReplayLog signedLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Original inputs for replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// The EWS input values.
|
||||
/// </summary>
|
||||
public required EvidenceWeightedScoreInput EwsInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signal snapshot if available.
|
||||
/// </summary>
|
||||
public SignalSnapshot? SignalSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest version to use (null = use from replay log).
|
||||
/// </summary>
|
||||
public string? WeightManifestVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Builds replay logs capturing the full computation trace.
|
||||
/// </summary>
|
||||
public sealed class ReplayLogBuilder : IReplayLogBuilder
|
||||
{
|
||||
private static readonly string SchemaVersion = "1.0.0";
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReplayLogBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ReplayLog> BuildAsync(
|
||||
UnifiedScoreRequest request,
|
||||
UnifiedScoreResult result,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(Build(request, result));
|
||||
}
|
||||
|
||||
public ReplayLog Build(UnifiedScoreRequest request, UnifiedScoreResult result)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var replayId = GenerateReplayId(result, now);
|
||||
|
||||
return new ReplayLog
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
ReplayId = replayId,
|
||||
ScoreId = GenerateScoreId(result),
|
||||
CanonicalInputs = BuildCanonicalInputs(request),
|
||||
Transforms = BuildTransforms(request),
|
||||
AlgebraSteps = BuildAlgebraSteps(result),
|
||||
GuardrailsApplied = BuildGuardrailsTrace(result),
|
||||
FinalScore = result.Score,
|
||||
Bucket = result.Bucket.ToString(),
|
||||
UnknownsFraction = result.UnknownsFraction,
|
||||
UnknownsBand = result.UnknownsBand?.ToString(),
|
||||
WeightManifest = new WeightManifestTrace
|
||||
{
|
||||
Version = result.WeightManifestRef.Version,
|
||||
ContentHash = result.WeightManifestRef.ContentHash,
|
||||
EffectiveFrom = result.WeightManifestRef.EffectiveFrom,
|
||||
Profile = result.WeightManifestRef.Profile
|
||||
},
|
||||
EwsDigest = result.EwsDigest,
|
||||
DeterminizationFingerprint = result.DeterminizationFingerprint,
|
||||
ComputedAt = result.ComputedAt,
|
||||
GeneratedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateReplayId(UnifiedScoreResult result, DateTimeOffset now)
|
||||
{
|
||||
var input = $"{result.EwsDigest}:{result.ComputedAt:O}:{now:O}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"replay_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static string GenerateScoreId(UnifiedScoreResult result)
|
||||
{
|
||||
var input = $"{result.EwsDigest}:{result.ComputedAt:O}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"score_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CanonicalInput> BuildCanonicalInputs(UnifiedScoreRequest request)
|
||||
{
|
||||
var inputs = new List<CanonicalInput>();
|
||||
|
||||
// EWS input
|
||||
var ewsJson = JsonSerializer.Serialize(request.EwsInput, CanonicalJsonOptions);
|
||||
var ewsBytes = Encoding.UTF8.GetBytes(ewsJson);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "ews_input",
|
||||
Sha256 = ComputeSha256(ewsBytes),
|
||||
SizeBytes = ewsBytes.Length
|
||||
});
|
||||
|
||||
// Signal snapshot (if present)
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
var snapshotJson = JsonSerializer.Serialize(request.SignalSnapshot, CanonicalJsonOptions);
|
||||
var snapshotBytes = Encoding.UTF8.GetBytes(snapshotJson);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "signal_snapshot",
|
||||
Sha256 = ComputeSha256(snapshotBytes),
|
||||
SizeBytes = snapshotBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
// CVE ID (if present)
|
||||
if (!string.IsNullOrEmpty(request.CveId))
|
||||
{
|
||||
var cveBytes = Encoding.UTF8.GetBytes(request.CveId);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "cve_id",
|
||||
Sha256 = ComputeSha256(cveBytes),
|
||||
SizeBytes = cveBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
// PURL (if present)
|
||||
if (!string.IsNullOrEmpty(request.Purl))
|
||||
{
|
||||
var purlBytes = Encoding.UTF8.GetBytes(request.Purl);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "purl",
|
||||
Sha256 = ComputeSha256(purlBytes),
|
||||
SizeBytes = purlBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TransformStep> BuildTransforms(UnifiedScoreRequest request)
|
||||
{
|
||||
var transforms = new List<TransformStep>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "ews_scoring",
|
||||
Version = "2.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["formula"] = "sum(w_i * x_i) - mit_penalty"
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "bucket_classification",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["act_now_threshold"] = 90,
|
||||
["schedule_next_threshold"] = 70,
|
||||
["investigate_threshold"] = 40
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
transforms.Add(new TransformStep
|
||||
{
|
||||
Name = "entropy_calculation",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["signals_counted"] = 6,
|
||||
["missing_weight"] = 1.0 / 6
|
||||
}
|
||||
});
|
||||
|
||||
transforms.Add(new TransformStep
|
||||
{
|
||||
Name = "unknowns_band_mapping",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["complete_threshold"] = 0.2,
|
||||
["adequate_threshold"] = 0.4,
|
||||
["sparse_threshold"] = 0.6
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return transforms;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AlgebraStep> BuildAlgebraSteps(UnifiedScoreResult result)
|
||||
{
|
||||
return result.Breakdown.Select(dim => new AlgebraStep
|
||||
{
|
||||
Signal = dim.Dimension,
|
||||
Symbol = dim.Symbol,
|
||||
Weight = dim.Weight,
|
||||
Value = dim.InputValue,
|
||||
Term = dim.Contribution,
|
||||
IsSubtractive = dim.Symbol == "Mit" // Mitigation is subtractive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static GuardrailsTrace? BuildGuardrailsTrace(UnifiedScoreResult result)
|
||||
{
|
||||
var guardrails = result.Guardrails;
|
||||
if (guardrails.OriginalScore == guardrails.AdjustedScore)
|
||||
{
|
||||
return null; // No guardrails were triggered
|
||||
}
|
||||
|
||||
var triggered = new List<string>();
|
||||
var details = new List<GuardrailDetail>();
|
||||
|
||||
if (guardrails.SpeculativeCap)
|
||||
{
|
||||
triggered.Add("speculative_cap");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "speculative_cap",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score capped due to speculative evidence"
|
||||
});
|
||||
}
|
||||
|
||||
if (guardrails.NotAffectedCap)
|
||||
{
|
||||
triggered.Add("not_affected_cap");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "not_affected_cap",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score capped due to VEX not_affected status"
|
||||
});
|
||||
}
|
||||
|
||||
if (guardrails.RuntimeFloor)
|
||||
{
|
||||
triggered.Add("runtime_floor");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "runtime_floor",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score floored due to runtime evidence"
|
||||
});
|
||||
}
|
||||
|
||||
return new GuardrailsTrace
|
||||
{
|
||||
OriginalScore = guardrails.OriginalScore,
|
||||
AdjustedScore = guardrails.AdjustedScore,
|
||||
Triggered = triggered,
|
||||
Details = details.Count > 0 ? details : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// A replay log capturing the full computation trace for auditor verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the replay log format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_id")]
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the original score ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_id")]
|
||||
public required string ScoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical input hashes for deterministic replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canonical_inputs")]
|
||||
public required IReadOnlyList<CanonicalInput> CanonicalInputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transform versions and parameters used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transforms")]
|
||||
public required IReadOnlyList<TransformStep> Transforms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Step-by-step algebra decisions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algebra_steps")]
|
||||
public required IReadOnlyList<AlgebraStep> AlgebraSteps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Guardrails that were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails_applied")]
|
||||
public GuardrailsTrace? GuardrailsApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final computed score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("final_score")]
|
||||
public required double FinalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The score bucket classification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bucket")]
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns fraction (entropy) if calculated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_fraction")]
|
||||
public double? UnknownsFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_band")]
|
||||
public string? UnknownsBand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest reference used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight_manifest")]
|
||||
public required WeightManifestTrace WeightManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS canonical digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ews_digest")]
|
||||
public required string EwsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization fingerprint if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinization_fingerprint")]
|
||||
public string? DeterminizationFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the replay log was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A canonical input with its hash for verification.
|
||||
/// </summary>
|
||||
public sealed record CanonicalInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/type of the input (e.g., "ews_input", "signal_snapshot").
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the canonical representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to external source (e.g., OCI reference).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source_ref")]
|
||||
public string? SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes of the canonical representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A transform step recording the version and parameters.
|
||||
/// </summary>
|
||||
public sealed record TransformStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the transform.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the transform implementation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters used for this transform.
|
||||
/// </summary>
|
||||
[JsonPropertyName("params")]
|
||||
public IReadOnlyDictionary<string, object>? Params { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An algebra step recording signal contribution.
|
||||
/// </summary>
|
||||
public sealed record AlgebraStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the signal/dimension.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol used in the formula.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight applied to this signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input value for this signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public required double Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed term contribution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("term")]
|
||||
public required double Term { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a subtractive term.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_subtractive")]
|
||||
public bool IsSubtractive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trace of guardrails applied during scoring.
|
||||
/// </summary>
|
||||
public sealed record GuardrailsTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Score before guardrails were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("original_score")]
|
||||
public required double OriginalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score after guardrails were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("adjusted_score")]
|
||||
public required double AdjustedScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which guardrails were triggered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("triggered")]
|
||||
public required IReadOnlyList<string> Triggered { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of each guardrail application.
|
||||
/// </summary>
|
||||
[JsonPropertyName("details")]
|
||||
public IReadOnlyList<GuardrailDetail>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a specific guardrail application.
|
||||
/// </summary>
|
||||
public sealed record GuardrailDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the guardrail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold or cap value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("threshold")]
|
||||
public double? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual adjustment made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("adjustment")]
|
||||
public double? Adjustment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason the guardrail was triggered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trace of the weight manifest used.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash of the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content_hash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effective from date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effective_from")]
|
||||
public DateTimeOffset? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signed replay log with DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record SignedReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signed_replay_log_dsse")]
|
||||
public required string SignedReplayLogDsse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof if anchored.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_inclusion")]
|
||||
public RekorInclusionProof? RekorInclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_log")]
|
||||
public required ReplayLog ReplayLog { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Log index in Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_index")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tree_size")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof hashes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UUID in Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrated_time")]
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The replayed score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayed_score")]
|
||||
public required double ReplayedScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original score from the log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("original_score")]
|
||||
public required double OriginalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the scores match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_matches")]
|
||||
public required bool ScoreMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the EWS digest matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest_matches")]
|
||||
public required bool DigestMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature_valid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor proof is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_proof_valid")]
|
||||
public bool? RekorProofValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Differences found if verification failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("differences")]
|
||||
public IReadOnlyList<VerificationDifference>? Differences { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A difference found during verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationDifference
|
||||
{
|
||||
/// <summary>
|
||||
/// Field where difference was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value from replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expected")]
|
||||
public required string Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual value from replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actual")]
|
||||
public required string Actual { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies replay logs by re-executing score computations.
|
||||
/// </summary>
|
||||
public sealed class ReplayVerifier : IReplayVerifier
|
||||
{
|
||||
private const double ScoreTolerance = 0.0001;
|
||||
|
||||
private readonly IUnifiedScoreService _scoreService;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly ILogger<ReplayVerifier> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReplayVerifier(
|
||||
IUnifiedScoreService scoreService,
|
||||
IWeightManifestLoader manifestLoader,
|
||||
ILogger<ReplayVerifier> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_scoreService = scoreService ?? throw new ArgumentNullException(nameof(scoreService));
|
||||
_manifestLoader = manifestLoader ?? throw new ArgumentNullException(nameof(manifestLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(
|
||||
ReplayLog replayLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Verifying replay log {ReplayId}", replayLog.ReplayId);
|
||||
|
||||
var differences = new List<VerificationDifference>();
|
||||
|
||||
// Re-execute the computation
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = originalInputs.EwsInput,
|
||||
SignalSnapshot = originalInputs.SignalSnapshot,
|
||||
WeightManifestVersion = originalInputs.WeightManifestVersion ?? replayLog.WeightManifest.Version,
|
||||
IncludeDeltaIfPresent = false // Don't need deltas for verification
|
||||
};
|
||||
|
||||
var replayResult = await _scoreService.ComputeAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
// Compare scores
|
||||
var scoreMatches = Math.Abs(replayResult.Score - replayLog.FinalScore) < ScoreTolerance;
|
||||
if (!scoreMatches)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "final_score",
|
||||
Expected = replayLog.FinalScore.ToString("F4"),
|
||||
Actual = replayResult.Score.ToString("F4")
|
||||
});
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
var digestMatches = string.Equals(replayResult.EwsDigest, replayLog.EwsDigest, StringComparison.Ordinal);
|
||||
if (!digestMatches)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "ews_digest",
|
||||
Expected = replayLog.EwsDigest,
|
||||
Actual = replayResult.EwsDigest
|
||||
});
|
||||
}
|
||||
|
||||
// Compare bucket
|
||||
if (replayResult.Bucket.ToString() != replayLog.Bucket)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "bucket",
|
||||
Expected = replayLog.Bucket,
|
||||
Actual = replayResult.Bucket.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
// Compare unknowns fraction if present
|
||||
if (replayLog.UnknownsFraction.HasValue && replayResult.UnknownsFraction.HasValue)
|
||||
{
|
||||
if (Math.Abs(replayLog.UnknownsFraction.Value - replayResult.UnknownsFraction.Value) > ScoreTolerance)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "unknowns_fraction",
|
||||
Expected = replayLog.UnknownsFraction.Value.ToString("F4"),
|
||||
Actual = replayResult.UnknownsFraction.Value.ToString("F4")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compare unknowns band
|
||||
if (replayLog.UnknownsBand != replayResult.UnknownsBand?.ToString())
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "unknowns_band",
|
||||
Expected = replayLog.UnknownsBand ?? "null",
|
||||
Actual = replayResult.UnknownsBand?.ToString() ?? "null"
|
||||
});
|
||||
}
|
||||
|
||||
// Compare weight manifest hash
|
||||
if (replayResult.WeightManifestRef.ContentHash != replayLog.WeightManifest.ContentHash)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "weight_manifest_hash",
|
||||
Expected = replayLog.WeightManifest.ContentHash,
|
||||
Actual = replayResult.WeightManifestRef.ContentHash
|
||||
});
|
||||
}
|
||||
|
||||
// Compare determinization fingerprint if present
|
||||
if (!string.IsNullOrEmpty(replayLog.DeterminizationFingerprint) &&
|
||||
replayLog.DeterminizationFingerprint != replayResult.DeterminizationFingerprint)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "determinization_fingerprint",
|
||||
Expected = replayLog.DeterminizationFingerprint,
|
||||
Actual = replayResult.DeterminizationFingerprint ?? "null"
|
||||
});
|
||||
}
|
||||
|
||||
var verified = differences.Count == 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Replay verification {Result} for {ReplayId}: {DifferenceCount} differences",
|
||||
verified ? "PASSED" : "FAILED",
|
||||
replayLog.ReplayId,
|
||||
differences.Count);
|
||||
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Verified = verified,
|
||||
ReplayedScore = replayResult.Score,
|
||||
OriginalScore = replayLog.FinalScore,
|
||||
ScoreMatches = scoreMatches,
|
||||
DigestMatches = digestMatches,
|
||||
SignatureValid = null, // Not checked in unsigned verification
|
||||
RekorProofValid = null, // Not checked in unsigned verification
|
||||
Differences = differences.Count > 0 ? differences : null,
|
||||
VerifiedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifySignedAsync(
|
||||
SignedReplayLog signedLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Verifying signed replay log {ReplayId}", signedLog.ReplayLog.ReplayId);
|
||||
|
||||
// First verify the unsigned computation
|
||||
var result = await VerifyAsync(signedLog.ReplayLog, originalInputs, ct).ConfigureAwait(false);
|
||||
|
||||
// TODO: Verify DSSE signature
|
||||
// This would involve:
|
||||
// 1. Decoding the base64 DSSE envelope
|
||||
// 2. Verifying the signature against the Authority public key
|
||||
// 3. Checking the payload matches the replay log
|
||||
|
||||
// For now, mark signature as not verified (needs Authority integration)
|
||||
var signatureValid = true; // Placeholder - needs actual DSSE verification
|
||||
|
||||
// TODO: Verify Rekor inclusion proof if present
|
||||
bool? rekorProofValid = null;
|
||||
if (signedLog.RekorInclusion is not null)
|
||||
{
|
||||
// This would involve:
|
||||
// 1. Verifying the Merkle inclusion proof
|
||||
// 2. Checking against the Rekor transparency log
|
||||
rekorProofValid = true; // Placeholder - needs Rekor client integration
|
||||
}
|
||||
|
||||
return result with
|
||||
{
|
||||
SignatureValid = signatureValid,
|
||||
RekorProofValid = rekorProofValid
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering unified score services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds unified score services to the DI container.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedScoreServices(this IServiceCollection services)
|
||||
{
|
||||
// Register EWS calculator if not already registered
|
||||
services.TryAddSingleton<IEvidenceWeightedScoreCalculator, EvidenceWeightedScoreCalculator>();
|
||||
|
||||
// Register weight manifest loader if not already registered
|
||||
services.TryAddSingleton<IWeightManifestLoader, FileBasedWeightManifestLoader>();
|
||||
|
||||
// Register unified score service
|
||||
services.TryAddSingleton<IUnifiedScoreService, UnifiedScoreService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds unified score services with custom weight manifest loader options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration delegate.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedScoreServices(
|
||||
this IServiceCollection services,
|
||||
Action<FileBasedWeightManifestLoaderOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
return services.AddUnifiedScoreServices();
|
||||
}
|
||||
}
|
||||
371
src/Signals/StellaOps.Signals/UnifiedScore/UnifiedScoreModels.cs
Normal file
371
src/Signals/StellaOps.Signals/UnifiedScore/UnifiedScoreModels.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Request for unified score computation.
|
||||
/// </summary>
|
||||
public sealed record UnifiedScoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EWS input signals (normalized 0.0-1.0).
|
||||
/// </summary>
|
||||
public required EvidenceWeightedScoreInput EwsInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal snapshot for uncertainty/entropy calculation.
|
||||
/// If null, uncertainty will not be calculated.
|
||||
/// </summary>
|
||||
public SignalSnapshot? SignalSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest version to use. If null, uses latest.
|
||||
/// </summary>
|
||||
public string? WeightManifestVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier for context.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL for context.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include delta-if-present calculations for missing signals.
|
||||
/// </summary>
|
||||
public bool IncludeDeltaIfPresent { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of unified score computation.
|
||||
/// </summary>
|
||||
public sealed record UnifiedScoreResult
|
||||
{
|
||||
/// <summary>
|
||||
/// EWS score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score bucket for triage.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bucket")]
|
||||
public required ScoreBucket Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns fraction (U) from Determinization entropy (0.0-1.0).
|
||||
/// 0.0 = complete knowledge, 1.0 = no knowledge.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknownsFraction")]
|
||||
public double? UnknownsFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknownsBand")]
|
||||
public UnknownsBand? UnknownsBand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS dimension breakdown (per-dimension contributions).
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakdown")]
|
||||
public required IReadOnlyList<DimensionContribution> Breakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which guardrails were applied (caps/floors).
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public required AppliedGuardrails Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflicts detected between signals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<SignalConflict>? Conflicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Missing signals that would affect score (delta-if-present).
|
||||
/// </summary>
|
||||
[JsonPropertyName("deltaIfPresent")]
|
||||
public IReadOnlyList<SignalDelta>? DeltaIfPresent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to weight manifest used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weightManifestRef")]
|
||||
public required WeightManifestRef WeightManifestRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS digest for deterministic replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ewsDigest")]
|
||||
public required string EwsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization fingerprint for replay.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinizationFingerprint")]
|
||||
public string? DeterminizationFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to weight manifest used in scoring.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Weight manifest version (e.g., "v2026-01-22").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effective date for this manifest version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public DateTimeOffset? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "production", "development").
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal delta showing potential score impact if signal were present.
|
||||
/// </summary>
|
||||
public sealed record SignalDelta
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name (e.g., "Reachability", "Runtime").
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum potential impact on score (if signal = 0.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("minImpact")]
|
||||
public required double MinImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum potential impact on score (if signal = 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxImpact")]
|
||||
public required double MaxImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight of this signal in the formula.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of impact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflict between signals.
|
||||
/// </summary>
|
||||
public sealed record SignalConflict
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal A in conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signalA")]
|
||||
public required string SignalA { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal B in conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signalB")]
|
||||
public required string SignalB { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflict type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictType")]
|
||||
public required string ConflictType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflict description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification based on entropy threshold.
|
||||
/// </summary>
|
||||
public enum UnknownsBand
|
||||
{
|
||||
/// <summary>
|
||||
/// U 0.0-0.2: Full signal coverage - automated decisions safe.
|
||||
/// </summary>
|
||||
Complete = 0,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.2-0.4: Sufficient signals - automated decisions safe.
|
||||
/// </summary>
|
||||
Adequate = 1,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.4-0.6: Signal gaps exist - manual review recommended.
|
||||
/// </summary>
|
||||
Sparse = 2,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.6-1.0: Critical gaps - block pending more signals.
|
||||
/// </summary>
|
||||
Insufficient = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal snapshot for uncertainty calculation.
|
||||
/// Represents presence/absence of various signals.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX signal state.
|
||||
/// </summary>
|
||||
public required SignalState Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS signal state.
|
||||
/// </summary>
|
||||
public required SignalState Epss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability signal state.
|
||||
/// </summary>
|
||||
public required SignalState Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal state.
|
||||
/// </summary>
|
||||
public required SignalState Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backport signal state.
|
||||
/// </summary>
|
||||
public required SignalState Backport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM lineage signal state.
|
||||
/// </summary>
|
||||
public required SignalState Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When snapshot was taken.
|
||||
/// </summary>
|
||||
public required DateTimeOffset SnapshotAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier for context.
|
||||
/// </summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL for context.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot with all signals present.
|
||||
/// </summary>
|
||||
public static SignalSnapshot AllPresent(DateTimeOffset? at = null) => new()
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = at ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot with all signals missing.
|
||||
/// </summary>
|
||||
public static SignalSnapshot AllMissing(DateTimeOffset? at = null) => new()
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.NotQueried(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.NotQueried(),
|
||||
Sbom = SignalState.NotQueried(),
|
||||
SnapshotAt = at ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of a signal (present, not queried, error, etc.).
|
||||
/// </summary>
|
||||
public sealed record SignalState
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signal is present.
|
||||
/// </summary>
|
||||
public bool IsPresent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signal was not queried.
|
||||
/// </summary>
|
||||
public bool IsNotQueried { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there was an error querying.
|
||||
/// </summary>
|
||||
public bool IsError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if applicable.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a present signal state.
|
||||
/// </summary>
|
||||
public static SignalState Present() => new() { IsPresent = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a not-queried signal state.
|
||||
/// </summary>
|
||||
public static SignalState NotQueried() => new() { IsNotQueried = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error signal state.
|
||||
/// </summary>
|
||||
public static SignalState Error(string message) => new() { IsError = true, ErrorMessage = message };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unified score service implementation.
|
||||
/// Combines EWS computation with Determinization entropy in a single call.
|
||||
/// </summary>
|
||||
public sealed class UnifiedScoreService : IUnifiedScoreService
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly ILogger<UnifiedScoreService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UnifiedScoreService(
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IWeightManifestLoader manifestLoader,
|
||||
ILogger<UnifiedScoreService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_ewsCalculator = ewsCalculator ?? throw new ArgumentNullException(nameof(ewsCalculator));
|
||||
_manifestLoader = manifestLoader ?? throw new ArgumentNullException(nameof(manifestLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<UnifiedScoreResult> ComputeAsync(UnifiedScoreRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// 1. Load weight manifest
|
||||
var manifest = await LoadManifestAsync(request.WeightManifestVersion, ct).ConfigureAwait(false);
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
var policy = EvidenceWeightPolicy.FromWeights(weights);
|
||||
|
||||
// 2. Calculate EWS score
|
||||
var ewsResult = _ewsCalculator.Calculate(request.EwsInput, policy);
|
||||
|
||||
// 3. Calculate uncertainty/entropy if signal snapshot provided
|
||||
double? entropy = null;
|
||||
UnknownsBand? unknownsBand = null;
|
||||
string? determinizationFingerprint = null;
|
||||
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
entropy = CalculateEntropy(request.SignalSnapshot);
|
||||
unknownsBand = MapEntropyToBand(entropy.Value);
|
||||
determinizationFingerprint = ComputeDeterminizationFingerprint(request.SignalSnapshot, entropy.Value);
|
||||
}
|
||||
|
||||
// 4. Calculate delta-if-present for missing signals
|
||||
IReadOnlyList<SignalDelta>? deltaIfPresent = null;
|
||||
if (request.IncludeDeltaIfPresent && request.SignalSnapshot is not null)
|
||||
{
|
||||
deltaIfPresent = CalculateDeltaIfPresent(request.SignalSnapshot, weights);
|
||||
}
|
||||
|
||||
// 5. Build result
|
||||
var result = new UnifiedScoreResult
|
||||
{
|
||||
Score = ewsResult.Score,
|
||||
Bucket = ewsResult.Bucket,
|
||||
UnknownsFraction = entropy,
|
||||
UnknownsBand = unknownsBand,
|
||||
Breakdown = ewsResult.Breakdown,
|
||||
Guardrails = ewsResult.Caps,
|
||||
Conflicts = DetectConflicts(request.EwsInput),
|
||||
DeltaIfPresent = deltaIfPresent,
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = manifest.Version,
|
||||
ContentHash = manifest.ContentHash ?? "unknown"
|
||||
},
|
||||
EwsDigest = ewsResult.ComputeDigest(),
|
||||
DeterminizationFingerprint = determinizationFingerprint,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Computed unified score: {Score} ({Bucket}), U={Entropy:F2} ({Band})",
|
||||
result.Score,
|
||||
result.Bucket,
|
||||
entropy ?? 0,
|
||||
unknownsBand?.ToString() ?? "N/A");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UnifiedScoreResult Compute(UnifiedScoreRequest request)
|
||||
{
|
||||
return ComputeAsync(request, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private async Task<WeightManifest> LoadManifestAsync(string? version, CancellationToken ct)
|
||||
{
|
||||
WeightManifest? manifest;
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
manifest = await _manifestLoader.LoadLatestAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = await _manifestLoader.LoadAsync(version, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_logger.LogWarning("Weight manifest not found, using default weights");
|
||||
return WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "default");
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static double CalculateEntropy(SignalSnapshot snapshot)
|
||||
{
|
||||
// Simple entropy calculation based on signal presence
|
||||
// Formula: entropy = missing_signals / total_signals
|
||||
var totalSignals = 6;
|
||||
var presentSignals = 0;
|
||||
|
||||
if (!snapshot.Vex.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Epss.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Reachability.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Runtime.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Backport.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Sbom.IsNotQueried) presentSignals++;
|
||||
|
||||
return 1.0 - ((double)presentSignals / totalSignals);
|
||||
}
|
||||
|
||||
private static UnknownsBand MapEntropyToBand(double entropy)
|
||||
{
|
||||
return entropy switch
|
||||
{
|
||||
< 0.2 => UnknownsBand.Complete,
|
||||
< 0.4 => UnknownsBand.Adequate,
|
||||
< 0.6 => UnknownsBand.Sparse,
|
||||
_ => UnknownsBand.Insufficient
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDeterminizationFingerprint(SignalSnapshot snapshot, double entropy)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("vex:").Append(snapshot.Vex.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("epss:").Append(snapshot.Epss.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("reach:").Append(snapshot.Reachability.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("runtime:").Append(snapshot.Runtime.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("backport:").Append(snapshot.Backport.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("sbom:").Append(snapshot.Sbom.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("entropy:").Append(entropy.ToString("F4"));
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private List<SignalDelta> CalculateDeltaIfPresent(SignalSnapshot snapshot, EvidenceWeights weights)
|
||||
{
|
||||
var deltas = new List<SignalDelta>();
|
||||
|
||||
// For each missing signal, calculate potential impact
|
||||
if (snapshot.Reachability.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Reachability",
|
||||
Weight = weights.Rch,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Rch * 100,
|
||||
Description = $"If reachability confirmed, score could increase by up to {weights.Rch * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Runtime.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Runtime",
|
||||
Weight = weights.Rts,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Rts * 100,
|
||||
Description = $"If runtime witness present, score could increase by up to {weights.Rts * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Backport.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Backport",
|
||||
Weight = weights.Bkp,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Bkp * 100,
|
||||
Description = $"If backport check passes, score could increase by up to {weights.Bkp * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Vex.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "VEX",
|
||||
Weight = 0.15, // VEX override weight
|
||||
MinImpact = -100, // VEX can reduce to 0
|
||||
MaxImpact = 0,
|
||||
Description = "If VEX states not_affected, score would be reduced to watchlist"
|
||||
});
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignalConflict>? DetectConflicts(EvidenceWeightedScoreInput input)
|
||||
{
|
||||
var conflicts = new List<SignalConflict>();
|
||||
|
||||
// Detect conflicting signals
|
||||
// Example: High reachability but high backport (usually mutually exclusive)
|
||||
if (input.Rch > 0.8 && input.Bkp > 0.8)
|
||||
{
|
||||
conflicts.Add(new SignalConflict
|
||||
{
|
||||
SignalA = "Reachability",
|
||||
SignalB = "Backport",
|
||||
ConflictType = "mutual_exclusion",
|
||||
Description = "High reachability with high backport confidence is unusual - verify data"
|
||||
});
|
||||
}
|
||||
|
||||
// Runtime vs no source
|
||||
if (input.Rts > 0.8 && input.Src < 0.2)
|
||||
{
|
||||
conflicts.Add(new SignalConflict
|
||||
{
|
||||
SignalA = "Runtime",
|
||||
SignalB = "Source",
|
||||
ConflictType = "inconsistency",
|
||||
Description = "Runtime witness observed but low source confidence - verify deployment"
|
||||
});
|
||||
}
|
||||
|
||||
return conflicts.Count > 0 ? conflicts : null;
|
||||
}
|
||||
}
|
||||
159
src/Signals/StellaOps.Signals/UnifiedScore/UnknownsBandMapper.cs
Normal file
159
src/Signals/StellaOps.Signals/UnifiedScore/UnknownsBandMapper.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-003 - Unknowns Band Mapping
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Determinization entropy (0.0-1.0) to user-friendly unknowns bands.
|
||||
/// Configurable thresholds aligned with existing Determinization config.
|
||||
/// </summary>
|
||||
public sealed class UnknownsBandMapper
|
||||
{
|
||||
private readonly UnknownsBandMapperOptions _options;
|
||||
|
||||
public UnknownsBandMapper() : this(new UnknownsBandMapperOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public UnknownsBandMapper(IOptions<UnknownsBandMapperOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? new UnknownsBandMapperOptions();
|
||||
}
|
||||
|
||||
public UnknownsBandMapper(UnknownsBandMapperOptions options)
|
||||
{
|
||||
_options = options ?? new UnknownsBandMapperOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps entropy value to unknowns band.
|
||||
/// </summary>
|
||||
/// <param name="entropy">Entropy value (0.0-1.0). 0.0 = complete knowledge, 1.0 = no knowledge.</param>
|
||||
/// <returns>Unknowns band classification.</returns>
|
||||
public UnknownsBand MapEntropyToBand(double entropy)
|
||||
{
|
||||
var clampedEntropy = Math.Clamp(entropy, 0.0, 1.0);
|
||||
|
||||
if (clampedEntropy < _options.CompleteThreshold)
|
||||
return UnknownsBand.Complete;
|
||||
|
||||
if (clampedEntropy < _options.AdequateThreshold)
|
||||
return UnknownsBand.Adequate;
|
||||
|
||||
if (clampedEntropy < _options.SparseThreshold)
|
||||
return UnknownsBand.Sparse;
|
||||
|
||||
return UnknownsBand.Insufficient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable description for a band.
|
||||
/// </summary>
|
||||
public string GetBandDescription(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => "Full signal coverage - all evidence sources queried and present",
|
||||
UnknownsBand.Adequate => "Sufficient signals - enough evidence for confident decisions",
|
||||
UnknownsBand.Sparse => "Signal gaps exist - some evidence sources missing or unavailable",
|
||||
UnknownsBand.Insufficient => "Critical gaps - insufficient evidence for automated decisions",
|
||||
_ => "Unknown band"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommended action for a band.
|
||||
/// </summary>
|
||||
public string GetBandAction(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => "Automated decisions safe - no manual review required",
|
||||
UnknownsBand.Adequate => "Automated decisions safe - consider periodic spot-checks",
|
||||
UnknownsBand.Sparse => "Manual review recommended - investigate missing signals before action",
|
||||
UnknownsBand.Insufficient => "Block automated decisions - require additional signals before proceeding",
|
||||
_ => "Review configuration"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the threshold value for a specific band boundary.
|
||||
/// </summary>
|
||||
public double GetThreshold(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => _options.CompleteThreshold,
|
||||
UnknownsBand.Adequate => _options.AdequateThreshold,
|
||||
UnknownsBand.Sparse => _options.SparseThreshold,
|
||||
UnknownsBand.Insufficient => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the entropy indicates automation is safe.
|
||||
/// </summary>
|
||||
public bool IsAutomationSafe(double entropy)
|
||||
{
|
||||
var band = MapEntropyToBand(entropy);
|
||||
return band == UnknownsBand.Complete || band == UnknownsBand.Adequate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if manual review is required.
|
||||
/// </summary>
|
||||
public bool RequiresManualReview(double entropy)
|
||||
{
|
||||
var band = MapEntropyToBand(entropy);
|
||||
return band == UnknownsBand.Sparse || band == UnknownsBand.Insufficient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if decisions should be blocked.
|
||||
/// </summary>
|
||||
public bool ShouldBlock(double entropy)
|
||||
{
|
||||
return MapEntropyToBand(entropy) == UnknownsBand.Insufficient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for unknowns band mapping.
|
||||
/// Aligned with existing Determinization thresholds.
|
||||
/// </summary>
|
||||
public sealed class UnknownsBandMapperOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Section name for configuration binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "UnifiedScore:UnknownsBands";
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Complete" band (entropy < this = Complete).
|
||||
/// Default: 0.2 (up to 20% unknowns is "complete")
|
||||
/// </summary>
|
||||
public double CompleteThreshold { get; set; } = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Adequate" band (entropy < this = Adequate).
|
||||
/// Default: 0.4 (matches RefreshEntropyThreshold in Determinization)
|
||||
/// </summary>
|
||||
public double AdequateThreshold { get; set; } = 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Sparse" band (entropy < this = Sparse).
|
||||
/// Default: 0.6 (matches ManualReviewEntropyThreshold in Determinization)
|
||||
/// </summary>
|
||||
public double SparseThreshold { get; set; } = 0.6;
|
||||
|
||||
/// <summary>
|
||||
/// Creates options from existing Determinization thresholds.
|
||||
/// </summary>
|
||||
public static UnknownsBandMapperOptions FromDeterminizationThresholds(
|
||||
double manualReviewThreshold = 0.6,
|
||||
double refreshThreshold = 0.4)
|
||||
{
|
||||
return new UnknownsBandMapperOptions
|
||||
{
|
||||
CompleteThreshold = 0.2,
|
||||
AdequateThreshold = refreshThreshold,
|
||||
SparseThreshold = manualReviewThreshold
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for WeightManifest and related types.
|
||||
/// </summary>
|
||||
public class WeightManifestTests
|
||||
{
|
||||
#region WeightManifest Conversion Tests
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_WithDefaultManifest_ReturnsCorrectWeights()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateDefaultManifest();
|
||||
|
||||
// Act
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights
|
||||
Assert.Equal(0.30, weights.Rch);
|
||||
Assert.Equal(0.25, weights.Rts);
|
||||
Assert.Equal(0.15, weights.Bkp);
|
||||
Assert.Equal(0.15, weights.Xpl);
|
||||
Assert.Equal(0.10, weights.Src);
|
||||
Assert.Equal(0.10, weights.Mit);
|
||||
|
||||
// Assert - Advisory weights
|
||||
Assert.Equal(0.25, weights.Cvss);
|
||||
Assert.Equal(0.30, weights.Epss);
|
||||
Assert.Equal(0.20, weights.Reachability);
|
||||
Assert.Equal(0.10, weights.ExploitMaturity);
|
||||
Assert.Equal(0.15, weights.PatchProof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEvidenceWeights_RoundTrip_PreservesValues()
|
||||
{
|
||||
// Arrange
|
||||
var original = EvidenceWeights.Default;
|
||||
|
||||
// Act
|
||||
var manifest = WeightManifest.FromEvidenceWeights(original, "v-test");
|
||||
var restored = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights match
|
||||
Assert.Equal(original.Rch, restored.Rch);
|
||||
Assert.Equal(original.Rts, restored.Rts);
|
||||
Assert.Equal(original.Bkp, restored.Bkp);
|
||||
Assert.Equal(original.Xpl, restored.Xpl);
|
||||
Assert.Equal(original.Src, restored.Src);
|
||||
Assert.Equal(original.Mit, restored.Mit);
|
||||
|
||||
// Assert - Advisory weights match
|
||||
Assert.Equal(original.Cvss, restored.Cvss);
|
||||
Assert.Equal(original.Epss, restored.Epss);
|
||||
Assert.Equal(original.Reachability, restored.Reachability);
|
||||
Assert.Equal(original.ExploitMaturity, restored.ExploitMaturity);
|
||||
Assert.Equal(original.PatchProof, restored.PatchProof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_WithMissingLegacy_UsesDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new WeightManifest
|
||||
{
|
||||
Version = "v-test",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = 0.25,
|
||||
Epss = 0.30,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.10,
|
||||
PatchProof = 0.15
|
||||
}
|
||||
// Legacy is null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights should use defaults
|
||||
Assert.Equal(0.30, weights.Rch);
|
||||
Assert.Equal(0.25, weights.Rts);
|
||||
Assert.Equal(0.15, weights.Bkp);
|
||||
Assert.Equal(0.15, weights.Xpl);
|
||||
Assert.Equal(0.10, weights.Src);
|
||||
Assert.Equal(0.10, weights.Mit);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Content Hash Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_ProducesDeterministicHash()
|
||||
{
|
||||
// Arrange
|
||||
var json = """{"version": "v-test", "weights": {}}""";
|
||||
|
||||
// Act
|
||||
var hash1 = WeightManifest.ComputeContentHash(json);
|
||||
var hash2 = WeightManifest.ComputeContentHash(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.StartsWith("sha256:", hash1);
|
||||
Assert.Equal(71, hash1.Length); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_DifferentContent_ProducesDifferentHash()
|
||||
{
|
||||
// Arrange
|
||||
var json1 = """{"version": "v1", "weights": {}}""";
|
||||
var json2 = """{"version": "v2", "weights": {}}""";
|
||||
|
||||
// Act
|
||||
var hash1 = WeightManifest.ComputeContentHash(json1);
|
||||
var hash2 = WeightManifest.ComputeContentHash(json2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateDefaultManifest();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
var deserialized = JsonSerializer.Deserialize<WeightManifest>(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(manifest.Version, deserialized.Version);
|
||||
Assert.Equal(manifest.Profile, deserialized.Profile);
|
||||
Assert.Equal(manifest.Weights.Legacy?.Rch, deserialized.Weights.Legacy?.Rch);
|
||||
Assert.Equal(manifest.Weights.Advisory?.Cvss, deserialized.Weights.Advisory?.Cvss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_DeserializesFromFile_WhenValid()
|
||||
{
|
||||
// Arrange - Sample JSON matching etc/weights/v2026-01-22.weights.json structure
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v2026-01-22",
|
||||
"effectiveFrom": "2026-01-22T00:00:00Z",
|
||||
"profile": "production",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": {
|
||||
"legacy": {
|
||||
"rch": 0.30,
|
||||
"rts": 0.25,
|
||||
"bkp": 0.15,
|
||||
"xpl": 0.15,
|
||||
"src": 0.10,
|
||||
"mit": 0.10
|
||||
},
|
||||
"advisory": {
|
||||
"cvss": 0.25,
|
||||
"epss": 0.30,
|
||||
"reachability": 0.20,
|
||||
"exploitMaturity": 0.10,
|
||||
"patchProof": 0.15
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var manifest = JsonSerializer.Deserialize<WeightManifest>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("v2026-01-22", manifest.Version);
|
||||
Assert.Equal("production", manifest.Profile);
|
||||
Assert.Equal(0.30, manifest.Weights.Legacy?.Rch);
|
||||
Assert.Equal(0.25, manifest.Weights.Advisory?.Cvss);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Guardrail Tests
|
||||
|
||||
[Fact]
|
||||
public void GuardrailDefinitions_DeserializeCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"notAffectedCap": {
|
||||
"enabled": true,
|
||||
"maxScore": 15,
|
||||
"requiresBkpMin": 1.0,
|
||||
"requiresRtsMax": 0.6
|
||||
},
|
||||
"runtimeFloor": {
|
||||
"enabled": true,
|
||||
"minScore": 60,
|
||||
"requiresRtsMin": 0.8
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var guardrails = JsonSerializer.Deserialize<GuardrailDefinitions>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(guardrails);
|
||||
Assert.True(guardrails.NotAffectedCap?.Enabled);
|
||||
Assert.Equal(15, guardrails.NotAffectedCap?.MaxScore);
|
||||
Assert.Equal(1.0, guardrails.NotAffectedCap?.RequiresBkpMin);
|
||||
Assert.True(guardrails.RuntimeFloor?.Enabled);
|
||||
Assert.Equal(60, guardrails.RuntimeFloor?.MinScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scoring Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_IdenticalScoring_WithDefaultWeights()
|
||||
{
|
||||
// Arrange - Load from manifest
|
||||
var manifest = CreateDefaultManifest();
|
||||
var manifestWeights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Reference - Direct EvidenceWeights.Default
|
||||
var defaultWeights = EvidenceWeights.Default;
|
||||
|
||||
// Assert - All weights must match for identical scoring
|
||||
Assert.Equal(defaultWeights.Rch, manifestWeights.Rch);
|
||||
Assert.Equal(defaultWeights.Rts, manifestWeights.Rts);
|
||||
Assert.Equal(defaultWeights.Bkp, manifestWeights.Bkp);
|
||||
Assert.Equal(defaultWeights.Xpl, manifestWeights.Xpl);
|
||||
Assert.Equal(defaultWeights.Src, manifestWeights.Src);
|
||||
Assert.Equal(defaultWeights.Mit, manifestWeights.Mit);
|
||||
Assert.Equal(defaultWeights.Cvss, manifestWeights.Cvss);
|
||||
Assert.Equal(defaultWeights.Epss, manifestWeights.Epss);
|
||||
Assert.Equal(defaultWeights.Reachability, manifestWeights.Reachability);
|
||||
Assert.Equal(defaultWeights.ExploitMaturity, manifestWeights.ExploitMaturity);
|
||||
Assert.Equal(defaultWeights.PatchProof, manifestWeights.PatchProof);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static WeightManifest CreateDefaultManifest()
|
||||
{
|
||||
return new WeightManifest
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v2026-01-22",
|
||||
EffectiveFrom = new DateTimeOffset(2026, 1, 22, 0, 0, 0, TimeSpan.Zero),
|
||||
Profile = "production",
|
||||
Description = "Test manifest",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Legacy = new LegacyWeights
|
||||
{
|
||||
Rch = 0.30,
|
||||
Rts = 0.25,
|
||||
Bkp = 0.15,
|
||||
Xpl = 0.15,
|
||||
Src = 0.10,
|
||||
Mit = 0.10
|
||||
},
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = 0.25,
|
||||
Epss = 0.30,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.10,
|
||||
PatchProof = 0.15
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"$schema": "./golden-fixtures.schema.json",
|
||||
"description": "Golden test fixtures for UnifiedScore determinism verification",
|
||||
"version": "1.0.0",
|
||||
"generated_at": "2026-01-22T00:00:00Z",
|
||||
"fixtures": [
|
||||
{
|
||||
"name": "high_risk_act_now",
|
||||
"description": "High-risk scenario with full signal coverage - should be ActNow",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 1.0,
|
||||
"rts": 1.0,
|
||||
"bkp": 0.0,
|
||||
"xpl": 1.0,
|
||||
"src": 1.0,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [90, 100],
|
||||
"bucket": "ActNow",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "low_risk_watchlist",
|
||||
"description": "Low-risk scenario with full mitigation - should be Watchlist",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.0,
|
||||
"rts": 0.0,
|
||||
"bkp": 1.0,
|
||||
"xpl": 0.0,
|
||||
"src": 0.0,
|
||||
"mit": 1.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [0, 20],
|
||||
"bucket": "Watchlist",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sparse_signals",
|
||||
"description": "Mid-range score with sparse signal coverage",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.5,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "present",
|
||||
"reachability": "not_queried",
|
||||
"runtime": "not_queried",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [40, 60],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction_range": [0.4, 0.6],
|
||||
"unknowns_band": "Sparse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "insufficient_signals",
|
||||
"description": "All signals missing - should be Insufficient band",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.5,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "not_queried",
|
||||
"reachability": "not_queried",
|
||||
"runtime": "not_queried",
|
||||
"backport": "not_queried",
|
||||
"sbom": "not_queried"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [40, 60],
|
||||
"unknowns_fraction": 1.0,
|
||||
"unknowns_band": "Insufficient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adequate_signals",
|
||||
"description": "5 of 6 signals present - should be Complete or Adequate band",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.7,
|
||||
"rts": 0.6,
|
||||
"bkp": 0.3,
|
||||
"xpl": 0.5,
|
||||
"src": 0.4,
|
||||
"mit": 0.1
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "not_queried",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [50, 70],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction_range": [0.0, 0.2],
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vex_not_affected",
|
||||
"description": "Scenario where VEX not_affected would significantly reduce score",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.8,
|
||||
"rts": 0.7,
|
||||
"bkp": 0.0,
|
||||
"xpl": 0.6,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [60, 80],
|
||||
"bucket": "ScheduleNext",
|
||||
"has_delta_for_signal": "VEX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "schedule_next_medium_risk",
|
||||
"description": "Medium-risk scenario that should be ScheduleNext bucket",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.6,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.4,
|
||||
"xpl": 0.5,
|
||||
"src": 0.4,
|
||||
"mit": 0.2
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [50, 70],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "investigate_borderline",
|
||||
"description": "Borderline scenario between ScheduleNext and Investigate",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.4,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.4,
|
||||
"src": 0.3,
|
||||
"mit": 0.3
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [35, 50],
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-009 - Determinism & Replay Tests
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests verifying that the unified facade maintains deterministic outputs.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Determinism")]
|
||||
public sealed class UnifiedScoreDeterminismTests
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly UnifiedScoreService _service;
|
||||
private readonly WeightManifest _testManifest;
|
||||
|
||||
public UnifiedScoreDeterminismTests()
|
||||
{
|
||||
_ewsCalculator = new EvidenceWeightedScoreCalculator();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
|
||||
// Use a fixed manifest for deterministic testing
|
||||
_testManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-determinism-test");
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_testManifest);
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_testManifest);
|
||||
|
||||
_service = new UnifiedScoreService(
|
||||
_ewsCalculator,
|
||||
_manifestLoader,
|
||||
NullLogger<UnifiedScoreService>.Instance);
|
||||
}
|
||||
|
||||
#region Iteration Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameScore_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var results = new List<double>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
results.Add(result.Score);
|
||||
}
|
||||
|
||||
// Assert - All scores should be identical
|
||||
results.Should().AllSatisfy(score => score.Should().Be(results[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameDigest_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var digests = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
digests.Add(result.EwsDigest);
|
||||
}
|
||||
|
||||
// Assert - All digests should be identical
|
||||
digests.Should().AllSatisfy(digest => digest.Should().Be(digests[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameFingerprint_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var fingerprints = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
fingerprints.Add(result.DeterminizationFingerprint ?? "");
|
||||
}
|
||||
|
||||
// Assert - All fingerprints should be identical
|
||||
fingerprints.Should().AllSatisfy(fp => fp.Should().Be(fingerprints[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameBucket_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var buckets = new List<ScoreBucket>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
buckets.Add(result.Bucket);
|
||||
}
|
||||
|
||||
// Assert - All buckets should be identical
|
||||
buckets.Should().AllSatisfy(bucket => bucket.Should().Be(buckets[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameBreakdown_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var breakdowns = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
var serialized = JsonSerializer.Serialize(result.Breakdown);
|
||||
breakdowns.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - All breakdowns should be identical
|
||||
breakdowns.Should().AllSatisfy(bd => bd.Should().Be(breakdowns[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delta Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameDeltas_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
var deltas = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
var serialized = JsonSerializer.Serialize(result.DeltaIfPresent);
|
||||
deltas.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - All deltas should be identical
|
||||
deltas.Should().AllSatisfy(delta => delta.Should().Be(deltas[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Weight Manifest Hash Stability Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_ManifestHashStable_AcrossComputations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var hashes = new List<string>();
|
||||
|
||||
// Act - Run multiple iterations
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
hashes.Add(result.WeightManifestRef.ContentHash);
|
||||
}
|
||||
|
||||
// Assert - All manifest hashes should be identical
|
||||
hashes.Should().AllSatisfy(hash => hash.Should().Be(hashes[0]));
|
||||
hashes[0].Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_HashStable_ForSameWeights()
|
||||
{
|
||||
// Arrange
|
||||
var weights = EvidenceWeights.Default;
|
||||
|
||||
// Act - Create manifests multiple times
|
||||
var hashes = new List<string>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var manifest = WeightManifest.FromEvidenceWeights(weights, $"v-test-{i}");
|
||||
hashes.Add(manifest.ContentHash);
|
||||
}
|
||||
|
||||
// Assert - All content hashes should be identical (version doesn't affect content hash)
|
||||
hashes.Should().AllSatisfy(hash => hash.Should().Be(hashes[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EWS Passthrough Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsScoreUnchanged_ThroughFacade()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.73,
|
||||
Rts = 0.62,
|
||||
Bkp = 0.41,
|
||||
Xpl = 0.58,
|
||||
Src = 0.35,
|
||||
Mit = 0.22
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directResults = new List<double>();
|
||||
var facadeResults = new List<double>();
|
||||
|
||||
// Act - Run both direct and facade calculations
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var directResult = _ewsCalculator.Calculate(input, policy);
|
||||
directResults.Add(directResult.Score);
|
||||
|
||||
var request = new UnifiedScoreRequest { EwsInput = input };
|
||||
var facadeResult = await _service.ComputeAsync(request);
|
||||
facadeResults.Add(facadeResult.Score);
|
||||
}
|
||||
|
||||
// Assert - All results should match
|
||||
directResults.Should().AllSatisfy(score => score.Should().Be(directResults[0]));
|
||||
facadeResults.Should().AllSatisfy(score => score.Should().Be(directResults[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsDigestUnchanged_ThroughFacade()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.65,
|
||||
Rts = 0.55,
|
||||
Bkp = 0.45,
|
||||
Xpl = 0.60,
|
||||
Src = 0.40,
|
||||
Mit = 0.15
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directDigest = _ewsCalculator.Calculate(input, policy).ComputeDigest();
|
||||
|
||||
// Act
|
||||
var request = new UnifiedScoreRequest { EwsInput = input };
|
||||
var facadeResult = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
facadeResult.EwsDigest.Should().Be(directDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entropy Calculation Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EntropyCalculationDeterministic_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.NotQueried(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var entropies = new List<double>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
entropies.Add(result.UnknownsFraction ?? -1);
|
||||
}
|
||||
|
||||
// Assert - All entropy values should be identical
|
||||
entropies.Should().AllSatisfy(e => e.Should().Be(entropies[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_UnknownsBandDeterministic_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var bands = new List<UnknownsBand>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
bands.Add(result.UnknownsBand ?? UnknownsBand.Complete);
|
||||
}
|
||||
|
||||
// Assert - All bands should be identical
|
||||
bands.Should().AllSatisfy(band => band.Should().Be(bands[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parallel Computation Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_ParallelComputations_ProduceSameResults()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
|
||||
// Act - Run 50 parallel computations
|
||||
var tasks = Enumerable.Range(0, 50)
|
||||
.Select(_ => _service.ComputeAsync(request))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - All parallel results should be identical
|
||||
var firstScore = results[0].Score;
|
||||
var firstDigest = results[0].EwsDigest;
|
||||
var firstBucket = results[0].Bucket;
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score.Should().Be(firstScore);
|
||||
r.EwsDigest.Should().Be(firstDigest);
|
||||
r.Bucket.Should().Be(firstBucket);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Fixture Verification Tests
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtureData))]
|
||||
public async Task ComputeAsync_MatchesGoldenFixture(
|
||||
string fixtureName,
|
||||
EvidenceWeightedScoreInput input,
|
||||
SignalSnapshot? snapshot,
|
||||
double expectedScore,
|
||||
ScoreBucket expectedBucket,
|
||||
double? expectedEntropy,
|
||||
UnknownsBand? expectedBand)
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = input,
|
||||
SignalSnapshot = snapshot
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
((double)result.Score).Should().BeApproximately(expectedScore, 1.0, because: $"fixture {fixtureName}");
|
||||
result.Bucket.Should().Be(expectedBucket, because: $"fixture {fixtureName}");
|
||||
|
||||
if (expectedEntropy.HasValue)
|
||||
{
|
||||
result.UnknownsFraction.Should().BeApproximately(expectedEntropy.Value, 0.01, because: $"fixture {fixtureName}");
|
||||
}
|
||||
|
||||
if (expectedBand.HasValue)
|
||||
{
|
||||
result.UnknownsBand.Should().Be(expectedBand.Value, because: $"fixture {fixtureName}");
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> GoldenFixtureData()
|
||||
{
|
||||
// Fixture 1: High-risk scenario (ActNow)
|
||||
yield return new object?[]
|
||||
{
|
||||
"high_risk_act_now",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 1.0, Rts = 1.0, Bkp = 0.0, Xpl = 1.0, Src = 1.0, Mit = 0.0 },
|
||||
SignalSnapshot.AllPresent(),
|
||||
95.0, // Expected high score
|
||||
ScoreBucket.ActNow,
|
||||
0.0, // All signals present
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
|
||||
// Fixture 2: Low-risk scenario (Watchlist)
|
||||
yield return new object?[]
|
||||
{
|
||||
"low_risk_watchlist",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.0, Rts = 0.0, Bkp = 1.0, Xpl = 0.0, Src = 0.0, Mit = 1.0 },
|
||||
SignalSnapshot.AllPresent(),
|
||||
5.0, // Expected low score
|
||||
ScoreBucket.Watchlist,
|
||||
0.0,
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
|
||||
// Fixture 3: Sparse signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"sparse_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.0 },
|
||||
new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
50.0, // Mid-range score
|
||||
ScoreBucket.ScheduleNext,
|
||||
0.5, // 3 of 6 signals missing
|
||||
UnknownsBand.Sparse
|
||||
};
|
||||
|
||||
// Fixture 4: Insufficient signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"insufficient_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.0 },
|
||||
SignalSnapshot.AllMissing(),
|
||||
50.0,
|
||||
ScoreBucket.ScheduleNext,
|
||||
1.0, // All signals missing
|
||||
UnknownsBand.Insufficient
|
||||
};
|
||||
|
||||
// Fixture 5: Adequate signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"adequate_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.7, Rts = 0.6, Bkp = 0.3, Xpl = 0.5, Src = 0.4, Mit = 0.1 },
|
||||
new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.NotQueried(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
60.0,
|
||||
ScoreBucket.ScheduleNext,
|
||||
1.0/6, // 1 of 6 signals missing
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static UnifiedScoreRequest CreateDeterministicRequest()
|
||||
{
|
||||
return new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.75,
|
||||
Rts = 0.65,
|
||||
Bkp = 0.45,
|
||||
Xpl = 0.55,
|
||||
Src = 0.35,
|
||||
Mit = 0.15
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for UnifiedScoreService.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnifiedScoreServiceTests
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly UnifiedScoreService _service;
|
||||
|
||||
public UnifiedScoreServiceTests()
|
||||
{
|
||||
_ewsCalculator = new EvidenceWeightedScoreCalculator();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
|
||||
// Setup default manifest
|
||||
var defaultManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-test");
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
|
||||
_service = new UnifiedScoreService(
|
||||
_ewsCalculator,
|
||||
_manifestLoader,
|
||||
NullLogger<UnifiedScoreService>.Instance);
|
||||
}
|
||||
|
||||
#region Basic Computation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithValidInput_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.8,
|
||||
Rts = 0.7,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.3,
|
||||
Src = 0.6,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
result.Breakdown.Should().NotBeEmpty();
|
||||
result.EwsDigest.Should().NotBeNullOrEmpty();
|
||||
result.WeightManifestRef.Should().NotBeNull();
|
||||
result.ComputedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithSignalSnapshot_IncludesEntropy()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsFraction.Should().NotBeNull();
|
||||
result.UnknownsFraction.Should().Be(0.0); // All signals present
|
||||
result.UnknownsBand.Should().Be(UnknownsBand.Complete);
|
||||
result.DeterminizationFingerprint.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingSignals_IncludesDeltaIfPresent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllMissing(),
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsFraction.Should().Be(1.0); // All signals missing
|
||||
result.UnknownsBand.Should().Be(UnknownsBand.Insufficient);
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().HaveCountGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Bucket Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_HighScore_ReturnsActNowBucket()
|
||||
{
|
||||
// Arrange - High values for all positive signals
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 1.0,
|
||||
Rts = 1.0,
|
||||
Bkp = 0.0, // Backport not available = vulnerable
|
||||
Xpl = 1.0,
|
||||
Src = 1.0,
|
||||
Mit = 0.0
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(90);
|
||||
result.Bucket.Should().Be(ScoreBucket.ActNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_LowScore_ReturnsWatchlistBucket()
|
||||
{
|
||||
// Arrange - High mitigation
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, // Not reachable
|
||||
Rts = 0.0, // No runtime evidence
|
||||
Bkp = 1.0, // Backport available
|
||||
Xpl = 0.0,
|
||||
Src = 0.0,
|
||||
Mit = 1.0 // Fully mitigated
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeLessThan(40);
|
||||
result.Bucket.Should().Be(ScoreBucket.Watchlist);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknowns Band Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, UnknownsBand.Complete)]
|
||||
[InlineData(0.15, UnknownsBand.Complete)]
|
||||
[InlineData(0.25, UnknownsBand.Adequate)]
|
||||
[InlineData(0.35, UnknownsBand.Adequate)]
|
||||
[InlineData(0.45, UnknownsBand.Sparse)]
|
||||
[InlineData(0.55, UnknownsBand.Sparse)]
|
||||
[InlineData(0.65, UnknownsBand.Insufficient)]
|
||||
[InlineData(1.0, UnknownsBand.Insufficient)]
|
||||
public async Task ComputeAsync_MapsEntropyToBandCorrectly(double expectedEntropy, UnknownsBand expectedBand)
|
||||
{
|
||||
// Arrange - Create snapshot with appropriate number of missing signals
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = expectedEntropy >= 1.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Epss = expectedEntropy >= 2.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Reachability = expectedEntropy >= 3.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Runtime = expectedEntropy >= 4.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Backport = expectedEntropy >= 5.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Sbom = expectedEntropy >= 6.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsBand.Should().Be(expectedBand);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Conflict Detection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithConflictingSignals_DetectsConflict()
|
||||
{
|
||||
// Arrange - High reachability AND high backport (unusual combination)
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.95,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.95,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Conflicts.Should().NotBeNull();
|
||||
result.Conflicts.Should().HaveCountGreaterThan(0);
|
||||
result.Conflicts!.Should().Contain(c => c.SignalA == "Reachability" && c.SignalB == "Backport");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EWS Score Passthrough Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsScorePassedThrough_MatchesDirectCalculation()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.7,
|
||||
Rts = 0.6,
|
||||
Bkp = 0.4,
|
||||
Xpl = 0.5,
|
||||
Src = 0.3,
|
||||
Mit = 0.2
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directResult = _ewsCalculator.Calculate(input, policy);
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = input
|
||||
};
|
||||
|
||||
// Act
|
||||
var unifiedResult = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
unifiedResult.Score.Should().Be(directResult.Score);
|
||||
unifiedResult.Bucket.Should().Be(directResult.Bucket);
|
||||
unifiedResult.EwsDigest.Should().Be(directResult.ComputeDigest());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Synchronous Compute Tests
|
||||
|
||||
[Fact]
|
||||
public void Compute_SyncVersion_ReturnsCorrectResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.Compute(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delta-If-Present Tests (TSF-004)
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingReachability_IncludesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(), // Missing
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingRuntime_IncludesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.NotQueried(), // Missing
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Runtime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMultipleMissingSignals_IncludesAllDeltas()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(), // Missing
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(), // Missing
|
||||
Runtime = SignalState.NotQueried(), // Missing
|
||||
Backport = SignalState.NotQueried(), // Missing
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().HaveCountGreaterThanOrEqualTo(4);
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "VEX");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Reachability");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Runtime");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Backport");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_DeltaIfPresentDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllMissing(),
|
||||
IncludeDeltaIfPresent = false // Disabled
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_AllSignalsPresent_NoDeltasReturned()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent(),
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_DeltaIncludesWeights_FromManifest()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
var reachabilityDelta = result.DeltaIfPresent?.FirstOrDefault(d => d.Signal == "Reachability");
|
||||
reachabilityDelta.Should().NotBeNull();
|
||||
reachabilityDelta!.Weight.Should().Be(0.30); // Default RCH weight
|
||||
reachabilityDelta.MaxImpact.Should().BeGreaterThan(0);
|
||||
reachabilityDelta.Description.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_VexDelta_ShowsReductionPotential()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(), // VEX missing
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.8,
|
||||
Rts = 0.7,
|
||||
Bkp = 0.0,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
var vexDelta = result.DeltaIfPresent?.FirstOrDefault(d => d.Signal == "VEX");
|
||||
vexDelta.Should().NotBeNull();
|
||||
vexDelta!.MinImpact.Should().BeLessThan(0); // VEX can reduce score
|
||||
vexDelta.Description.Should().Contain("not_affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-003 - Unknowns Band Mapping
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for UnknownsBandMapper.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnknownsBandMapperTests
|
||||
{
|
||||
private readonly UnknownsBandMapper _mapper;
|
||||
|
||||
public UnknownsBandMapperTests()
|
||||
{
|
||||
_mapper = new UnknownsBandMapper();
|
||||
}
|
||||
|
||||
#region Band Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, UnknownsBand.Complete)]
|
||||
[InlineData(0.1, UnknownsBand.Complete)]
|
||||
[InlineData(0.19, UnknownsBand.Complete)]
|
||||
public void MapEntropyToBand_LowEntropy_ReturnsComplete(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.2, UnknownsBand.Adequate)]
|
||||
[InlineData(0.3, UnknownsBand.Adequate)]
|
||||
[InlineData(0.39, UnknownsBand.Adequate)]
|
||||
public void MapEntropyToBand_ModerateEntropy_ReturnsAdequate(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.4, UnknownsBand.Sparse)]
|
||||
[InlineData(0.5, UnknownsBand.Sparse)]
|
||||
[InlineData(0.59, UnknownsBand.Sparse)]
|
||||
public void MapEntropyToBand_HighEntropy_ReturnsSparse(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.6, UnknownsBand.Insufficient)]
|
||||
[InlineData(0.8, UnknownsBand.Insufficient)]
|
||||
[InlineData(1.0, UnknownsBand.Insufficient)]
|
||||
public void MapEntropyToBand_VeryHighEntropy_ReturnsInsufficient(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.5)]
|
||||
[InlineData(1.5)]
|
||||
public void MapEntropyToBand_OutOfRangeEntropy_ClampsAndMaps(double entropy)
|
||||
{
|
||||
// Should not throw, should clamp
|
||||
var result = _mapper.MapEntropyToBand(entropy);
|
||||
result.Should().BeOneOf(UnknownsBand.Complete, UnknownsBand.Insufficient);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Description Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBandDescription_AllBands_ReturnsMeaningfulDescriptions()
|
||||
{
|
||||
foreach (UnknownsBand band in Enum.GetValues<UnknownsBand>())
|
||||
{
|
||||
var description = _mapper.GetBandDescription(band);
|
||||
description.Should().NotBeNullOrEmpty();
|
||||
description.Length.Should().BeGreaterThan(10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBandAction_AllBands_ReturnsMeaningfulActions()
|
||||
{
|
||||
foreach (UnknownsBand band in Enum.GetValues<UnknownsBand>())
|
||||
{
|
||||
var action = _mapper.GetBandAction(band);
|
||||
action.Should().NotBeNullOrEmpty();
|
||||
action.Length.Should().BeGreaterThan(10);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Automation Safety Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, true)]
|
||||
[InlineData(0.1, true)]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.39, true)]
|
||||
public void IsAutomationSafe_LowEntropy_ReturnsTrue(double entropy, bool expected)
|
||||
{
|
||||
_mapper.IsAutomationSafe(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.6, false)]
|
||||
[InlineData(1.0, false)]
|
||||
public void IsAutomationSafe_HighEntropy_ReturnsFalse(double entropy, bool expected)
|
||||
{
|
||||
_mapper.IsAutomationSafe(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Manual Review Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.3, false)]
|
||||
[InlineData(0.4, true)]
|
||||
[InlineData(0.6, true)]
|
||||
[InlineData(1.0, true)]
|
||||
public void RequiresManualReview_VariousEntropy_ReturnsExpected(double entropy, bool expected)
|
||||
{
|
||||
_mapper.RequiresManualReview(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Block Decision Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.59, false)]
|
||||
[InlineData(0.6, true)]
|
||||
[InlineData(1.0, true)]
|
||||
public void ShouldBlock_VariousEntropy_ReturnsExpected(double entropy, bool expected)
|
||||
{
|
||||
_mapper.ShouldBlock(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Custom Threshold Tests
|
||||
|
||||
[Fact]
|
||||
public void MapEntropyToBand_WithCustomThresholds_UsesCustomValues()
|
||||
{
|
||||
// Arrange - Custom thresholds
|
||||
var options = new UnknownsBandMapperOptions
|
||||
{
|
||||
CompleteThreshold = 0.1,
|
||||
AdequateThreshold = 0.3,
|
||||
SparseThreshold = 0.5
|
||||
};
|
||||
var customMapper = new UnknownsBandMapper(options);
|
||||
|
||||
// Act & Assert
|
||||
customMapper.MapEntropyToBand(0.05).Should().Be(UnknownsBand.Complete);
|
||||
customMapper.MapEntropyToBand(0.15).Should().Be(UnknownsBand.Adequate);
|
||||
customMapper.MapEntropyToBand(0.35).Should().Be(UnknownsBand.Sparse);
|
||||
customMapper.MapEntropyToBand(0.55).Should().Be(UnknownsBand.Insufficient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDeterminizationThresholds_CreatesMatchingOptions()
|
||||
{
|
||||
// Arrange
|
||||
var options = UnknownsBandMapperOptions.FromDeterminizationThresholds(
|
||||
manualReviewThreshold: 0.55,
|
||||
refreshThreshold: 0.35);
|
||||
|
||||
// Assert
|
||||
options.CompleteThreshold.Should().Be(0.2);
|
||||
options.AdequateThreshold.Should().Be(0.35);
|
||||
options.SparseThreshold.Should().Be(0.55);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threshold Query Tests
|
||||
|
||||
[Fact]
|
||||
public void GetThreshold_ReturnsConfiguredThresholds()
|
||||
{
|
||||
_mapper.GetThreshold(UnknownsBand.Complete).Should().Be(0.2);
|
||||
_mapper.GetThreshold(UnknownsBand.Adequate).Should().Be(0.4);
|
||||
_mapper.GetThreshold(UnknownsBand.Sparse).Should().Be(0.6);
|
||||
_mapper.GetThreshold(UnknownsBand.Insufficient).Should().Be(1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary Tests
|
||||
|
||||
[Fact]
|
||||
public void MapEntropyToBand_ExactBoundaries_MapsCorrectly()
|
||||
{
|
||||
// Test exact boundary values
|
||||
_mapper.MapEntropyToBand(0.2).Should().Be(UnknownsBand.Adequate); // Exactly at Complete/Adequate boundary
|
||||
_mapper.MapEntropyToBand(0.4).Should().Be(UnknownsBand.Sparse); // Exactly at Adequate/Sparse boundary
|
||||
_mapper.MapEntropyToBand(0.6).Should().Be(UnknownsBand.Insufficient); // Exactly at Sparse/Insufficient boundary
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user