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