finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,10 @@
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Signals.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 };
}

View File

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

View 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
};
}
}

View File

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

View File

@@ -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"
}
}
]
}

View File

@@ -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" />

View File

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

View File

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

View File

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