finish off sprint advisories and sprints
This commit is contained in:
@@ -439,6 +439,21 @@ public sealed record EvidenceWeightPolicy
|
||||
FormulaMode = FormulaMode.Advisory
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy from weights using default configuration.
|
||||
/// </summary>
|
||||
public static EvidenceWeightPolicy FromWeights(EvidenceWeights weights)
|
||||
{
|
||||
return new EvidenceWeightPolicy
|
||||
{
|
||||
Version = "ews.v1",
|
||||
Profile = "custom",
|
||||
Weights = weights,
|
||||
FormulaMode = FormulaMode.Legacy,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// File-based weight manifest loader.
|
||||
/// Loads manifests from etc/weights/*.json files.
|
||||
/// </summary>
|
||||
public sealed class FileBasedWeightManifestLoader : IWeightManifestLoader
|
||||
{
|
||||
private readonly FileBasedWeightManifestLoaderOptions _options;
|
||||
private readonly ILogger<FileBasedWeightManifestLoader> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new file-based manifest loader.
|
||||
/// </summary>
|
||||
public FileBasedWeightManifestLoader(
|
||||
IOptions<FileBasedWeightManifestLoaderOptions> options,
|
||||
ILogger<FileBasedWeightManifestLoader> logger)
|
||||
{
|
||||
_options = options?.Value ?? new FileBasedWeightManifestLoaderOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> LoadAsync(string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = GetManifestPath(version);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("Weight manifest not found: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<WeightManifest>(json, _jsonOptions);
|
||||
|
||||
if (manifest != null)
|
||||
{
|
||||
// Compute and verify hash if needed
|
||||
var computedHash = WeightManifest.ComputeContentHash(json);
|
||||
|
||||
if (manifest.ContentHash == "sha256:auto" || string.IsNullOrEmpty(manifest.ContentHash))
|
||||
{
|
||||
// Auto-compute hash
|
||||
manifest = manifest with { ContentHash = computedHash };
|
||||
}
|
||||
else if (manifest.ContentHash != computedHash && _options.VerifyHashes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Hash mismatch for manifest {Version}: expected {Expected}, got {Actual}",
|
||||
version, manifest.ContentHash, computedHash);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loaded weight manifest {Version} from {FilePath}", version, filePath);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse weight manifest: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read weight manifest: {FilePath}", filePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> LoadLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = await ListVersionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No weight manifests found in {Directory}", _options.WeightsDirectory);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Versions are already sorted newest first
|
||||
return await LoadAsync(versions[0], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListVersionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var directory = GetWeightsDirectory();
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Weights directory does not exist: {Directory}", directory);
|
||||
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(directory, _options.ManifestPattern)
|
||||
.Select(f => Path.GetFileNameWithoutExtension(f))
|
||||
.Where(f => !string.IsNullOrEmpty(f))
|
||||
.OrderByDescending(f => f, StringComparer.Ordinal) // Newest first (assuming date-based naming)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(files);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filePath = GetManifestPath(version);
|
||||
return Task.FromResult(File.Exists(filePath));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WeightManifest?> GetEffectiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var versions = await ListVersionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var version in versions)
|
||||
{
|
||||
var manifest = await LoadAsync(version, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest != null && manifest.EffectiveFrom <= asOf)
|
||||
{
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the weights directory path.
|
||||
/// </summary>
|
||||
private string GetWeightsDirectory()
|
||||
{
|
||||
if (Path.IsPathRooted(_options.WeightsDirectory))
|
||||
{
|
||||
return _options.WeightsDirectory;
|
||||
}
|
||||
|
||||
// Relative to application base directory
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
return Path.Combine(baseDir, _options.WeightsDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path for a manifest version.
|
||||
/// </summary>
|
||||
private string GetManifestPath(string version)
|
||||
{
|
||||
var directory = GetWeightsDirectory();
|
||||
var fileName = $"{version}.weights.json";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for file-based manifest loader.
|
||||
/// </summary>
|
||||
public sealed class FileBasedWeightManifestLoaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Directory containing weight manifest files.
|
||||
/// Can be absolute or relative to application base directory.
|
||||
/// Default: "etc/weights"
|
||||
/// </summary>
|
||||
public string WeightsDirectory { get; set; } = "etc/weights";
|
||||
|
||||
/// <summary>
|
||||
/// File pattern for manifest files.
|
||||
/// Default: "*.weights.json"
|
||||
/// </summary>
|
||||
public string ManifestPattern { get; set; } = "*.weights.json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify content hashes on load.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool VerifyHashes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable hot reload of manifest files.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableHotReload { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Hot reload interval in seconds (if enabled).
|
||||
/// Default: 30
|
||||
/// </summary>
|
||||
public int HotReloadIntervalSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading weight manifests from external sources.
|
||||
/// </summary>
|
||||
public interface IWeightManifestLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a specific version of the weight manifest.
|
||||
/// </summary>
|
||||
/// <param name="version">Version identifier (e.g., "v2026-01-22").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The loaded manifest, or null if not found.</returns>
|
||||
Task<WeightManifest?> LoadAsync(string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the latest available weight manifest.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest manifest, or null if none available.</returns>
|
||||
Task<WeightManifest?> LoadLatestAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all available manifest versions.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of available versions ordered by effective date (newest first).</returns>
|
||||
Task<IReadOnlyList<string>> ListVersionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific version exists.
|
||||
/// </summary>
|
||||
/// <param name="version">Version identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the version exists.</returns>
|
||||
Task<bool> ExistsAsync(string version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective manifest for a specific date.
|
||||
/// Returns the latest manifest with effectiveFrom <= date.
|
||||
/// </summary>
|
||||
/// <param name="asOf">Date to check against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The effective manifest, or null if none available.</returns>
|
||||
Task<WeightManifest?> GetEffectiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of loading a weight manifest with metadata.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestLoadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The loaded manifest, or null if load failed.
|
||||
/// </summary>
|
||||
public WeightManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the manifest (e.g., file path, URL).
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed content hash for integrity verification.
|
||||
/// </summary>
|
||||
public string? ComputedHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hash matches the declared hash in the manifest.
|
||||
/// </summary>
|
||||
public bool HashVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Load timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset LoadedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if load failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the load was successful.
|
||||
/// </summary>
|
||||
public bool Success => Manifest != null && Error == null;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest representing externalized EWS configuration.
|
||||
/// Loaded from etc/weights/*.json files.
|
||||
/// </summary>
|
||||
public sealed record WeightManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the manifest format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Unique version identifier (e.g., "v2026-01-22").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this manifest becomes effective (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "production", "development").
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string Profile { get; init; } = "production";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the manifest content for integrity verification.
|
||||
/// Computed at load time if "auto".
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight definitions for both legacy and advisory formulas.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weights")]
|
||||
public required WeightDefinitions Weights { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail configuration (caps and floors).
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public GuardrailDefinitions? Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bucket threshold configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buckets")]
|
||||
public BucketDefinitions? Buckets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization entropy thresholds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinizationThresholds")]
|
||||
public DeterminizationThresholds? DeterminizationThresholds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest metadata (changelog, notes).
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ManifestMetadata? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this manifest to EvidenceWeights for scoring.
|
||||
/// </summary>
|
||||
public EvidenceWeights ToEvidenceWeights()
|
||||
{
|
||||
return new EvidenceWeights
|
||||
{
|
||||
// Legacy weights
|
||||
Rch = Weights.Legacy?.Rch ?? 0.30,
|
||||
Rts = Weights.Legacy?.Rts ?? 0.25,
|
||||
Bkp = Weights.Legacy?.Bkp ?? 0.15,
|
||||
Xpl = Weights.Legacy?.Xpl ?? 0.15,
|
||||
Src = Weights.Legacy?.Src ?? 0.10,
|
||||
Mit = Weights.Legacy?.Mit ?? 0.10,
|
||||
// Advisory weights
|
||||
Cvss = Weights.Advisory?.Cvss ?? 0.25,
|
||||
Epss = Weights.Advisory?.Epss ?? 0.30,
|
||||
Reachability = Weights.Advisory?.Reachability ?? 0.20,
|
||||
ExploitMaturity = Weights.Advisory?.ExploitMaturity ?? 0.10,
|
||||
PatchProof = Weights.Advisory?.PatchProof ?? 0.15
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a WeightManifest from EvidenceWeights.
|
||||
/// </summary>
|
||||
public static WeightManifest FromEvidenceWeights(EvidenceWeights weights, string version, string? description = null)
|
||||
{
|
||||
return new WeightManifest
|
||||
{
|
||||
Version = version,
|
||||
EffectiveFrom = DateTimeOffset.UtcNow,
|
||||
Description = description ?? $"Auto-generated from EvidenceWeights at {DateTimeOffset.UtcNow:O}",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Legacy = new LegacyWeights
|
||||
{
|
||||
Rch = weights.Rch,
|
||||
Rts = weights.Rts,
|
||||
Bkp = weights.Bkp,
|
||||
Xpl = weights.Xpl,
|
||||
Src = weights.Src,
|
||||
Mit = weights.Mit
|
||||
},
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = weights.Cvss,
|
||||
Epss = weights.Epss,
|
||||
Reachability = weights.Reachability,
|
||||
ExploitMaturity = weights.ExploitMaturity,
|
||||
PatchProof = weights.PatchProof
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of the manifest content.
|
||||
/// </summary>
|
||||
public static string ComputeContentHash(string jsonContent)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(jsonContent);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weight definitions containing both legacy and advisory weights.
|
||||
/// </summary>
|
||||
public sealed record WeightDefinitions
|
||||
{
|
||||
[JsonPropertyName("legacy")]
|
||||
public LegacyWeights? Legacy { get; init; }
|
||||
|
||||
[JsonPropertyName("advisory")]
|
||||
public AdvisoryWeights? Advisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy 6-dimension weights (ews.v1).
|
||||
/// </summary>
|
||||
public sealed record LegacyWeights
|
||||
{
|
||||
[JsonPropertyName("rch")]
|
||||
public double Rch { get; init; }
|
||||
|
||||
[JsonPropertyName("rts")]
|
||||
public double Rts { get; init; }
|
||||
|
||||
[JsonPropertyName("bkp")]
|
||||
public double Bkp { get; init; }
|
||||
|
||||
[JsonPropertyName("xpl")]
|
||||
public double Xpl { get; init; }
|
||||
|
||||
[JsonPropertyName("src")]
|
||||
public double Src { get; init; }
|
||||
|
||||
[JsonPropertyName("mit")]
|
||||
public double Mit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory 5-dimension weights (ews.v2).
|
||||
/// </summary>
|
||||
public sealed record AdvisoryWeights
|
||||
{
|
||||
[JsonPropertyName("cvss")]
|
||||
public double Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("epss")]
|
||||
public double Epss { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public double Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("exploitMaturity")]
|
||||
public double ExploitMaturity { get; init; }
|
||||
|
||||
[JsonPropertyName("patchProof")]
|
||||
public double PatchProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail definitions for score caps and floors.
|
||||
/// </summary>
|
||||
public sealed record GuardrailDefinitions
|
||||
{
|
||||
[JsonPropertyName("notAffectedCap")]
|
||||
public CapDefinition? NotAffectedCap { get; init; }
|
||||
|
||||
[JsonPropertyName("runtimeFloor")]
|
||||
public FloorDefinition? RuntimeFloor { get; init; }
|
||||
|
||||
[JsonPropertyName("speculativeCap")]
|
||||
public CapDefinition? SpeculativeCap { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score cap definition.
|
||||
/// </summary>
|
||||
public sealed record CapDefinition
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public int MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresBkpMin")]
|
||||
public double? RequiresBkpMin { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRtsMax")]
|
||||
public double? RequiresRtsMax { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRchMax")]
|
||||
public double? RequiresRchMax { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score floor definition.
|
||||
/// </summary>
|
||||
public sealed record FloorDefinition
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public int MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresRtsMin")]
|
||||
public double? RequiresRtsMin { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bucket threshold definitions.
|
||||
/// </summary>
|
||||
public sealed record BucketDefinitions
|
||||
{
|
||||
[JsonPropertyName("actNowMin")]
|
||||
public int ActNowMin { get; init; } = 90;
|
||||
|
||||
[JsonPropertyName("scheduleNextMin")]
|
||||
public int ScheduleNextMin { get; init; } = 70;
|
||||
|
||||
[JsonPropertyName("investigateMin")]
|
||||
public int InvestigateMin { get; init; } = 40;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinization entropy thresholds.
|
||||
/// </summary>
|
||||
public sealed record DeterminizationThresholds
|
||||
{
|
||||
[JsonPropertyName("manualReviewEntropy")]
|
||||
public double ManualReviewEntropy { get; init; } = 0.60;
|
||||
|
||||
[JsonPropertyName("refreshEntropy")]
|
||||
public double RefreshEntropy { get; init; } = 0.40;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest metadata.
|
||||
/// </summary>
|
||||
public sealed record ManifestMetadata
|
||||
{
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("changelog")]
|
||||
public IReadOnlyList<ChangelogEntry>? Changelog { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public IReadOnlyList<string>? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry for manifest versioning.
|
||||
/// </summary>
|
||||
public sealed record ChangelogEntry
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public string? Date { get; init; }
|
||||
|
||||
[JsonPropertyName("changes")]
|
||||
public IReadOnlyList<string>? Changes { get; init; }
|
||||
}
|
||||
@@ -894,8 +894,9 @@ signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class public for WebApplicationFactory<Program> test support
|
||||
public partial class Program
|
||||
// Internal: avoids type conflict when this project is referenced from Platform.WebService.
|
||||
// Tests use InternalsVisibleTo + composition wrapper (SignalsTestFactory).
|
||||
internal partial class Program
|
||||
{
|
||||
internal static bool TryAuthorize(HttpContext httpContext, string requiredScope, bool fallbackAllowed, out IResult? failure)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Signals.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Facade service combining EWS computation with Determinization entropy.
|
||||
/// Returns unified result with score, U metric, breakdown, and evidence.
|
||||
/// </summary>
|
||||
public interface IUnifiedScoreService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute unified score combining EWS and Determinization metrics.
|
||||
/// </summary>
|
||||
/// <param name="request">Score computation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Unified score result with breakdown and evidence.</returns>
|
||||
Task<UnifiedScoreResult> ComputeAsync(UnifiedScoreRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compute unified score synchronously (for compatibility with existing sync code).
|
||||
/// </summary>
|
||||
/// <param name="request">Score computation request.</param>
|
||||
/// <returns>Unified score result with breakdown and evidence.</returns>
|
||||
UnifiedScoreResult Compute(UnifiedScoreRequest request);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Builder interface for creating replay logs that capture the full computation trace.
|
||||
/// </summary>
|
||||
public interface IReplayLogBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a replay log from a unified score result.
|
||||
/// </summary>
|
||||
/// <param name="request">The original score request.</param>
|
||||
/// <param name="result">The computed result.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The replay log containing full computation trace.</returns>
|
||||
Task<ReplayLog> BuildAsync(
|
||||
UnifiedScoreRequest request,
|
||||
UnifiedScoreResult result,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a replay log synchronously.
|
||||
/// </summary>
|
||||
ReplayLog Build(UnifiedScoreRequest request, UnifiedScoreResult result);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier interface for replaying and verifying score computations.
|
||||
/// </summary>
|
||||
public interface IReplayVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a replay log by re-executing the computation.
|
||||
/// </summary>
|
||||
/// <param name="replayLog">The replay log to verify.</param>
|
||||
/// <param name="originalInputs">Original inputs to use for replay.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(
|
||||
ReplayLog replayLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signed replay log including signature and optional Rekor proof.
|
||||
/// </summary>
|
||||
/// <param name="signedLog">The signed replay log.</param>
|
||||
/// <param name="originalInputs">Original inputs to use for replay.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<ReplayVerificationResult> VerifySignedAsync(
|
||||
SignedReplayLog signedLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Original inputs for replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// The EWS input values.
|
||||
/// </summary>
|
||||
public required EvidenceWeightedScoreInput EwsInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The signal snapshot if available.
|
||||
/// </summary>
|
||||
public SignalSnapshot? SignalSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest version to use (null = use from replay log).
|
||||
/// </summary>
|
||||
public string? WeightManifestVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Builds replay logs capturing the full computation trace.
|
||||
/// </summary>
|
||||
public sealed class ReplayLogBuilder : IReplayLogBuilder
|
||||
{
|
||||
private static readonly string SchemaVersion = "1.0.0";
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReplayLogBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ReplayLog> BuildAsync(
|
||||
UnifiedScoreRequest request,
|
||||
UnifiedScoreResult result,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(Build(request, result));
|
||||
}
|
||||
|
||||
public ReplayLog Build(UnifiedScoreRequest request, UnifiedScoreResult result)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var replayId = GenerateReplayId(result, now);
|
||||
|
||||
return new ReplayLog
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
ReplayId = replayId,
|
||||
ScoreId = GenerateScoreId(result),
|
||||
CanonicalInputs = BuildCanonicalInputs(request),
|
||||
Transforms = BuildTransforms(request),
|
||||
AlgebraSteps = BuildAlgebraSteps(result),
|
||||
GuardrailsApplied = BuildGuardrailsTrace(result),
|
||||
FinalScore = result.Score,
|
||||
Bucket = result.Bucket.ToString(),
|
||||
UnknownsFraction = result.UnknownsFraction,
|
||||
UnknownsBand = result.UnknownsBand?.ToString(),
|
||||
WeightManifest = new WeightManifestTrace
|
||||
{
|
||||
Version = result.WeightManifestRef.Version,
|
||||
ContentHash = result.WeightManifestRef.ContentHash,
|
||||
EffectiveFrom = result.WeightManifestRef.EffectiveFrom,
|
||||
Profile = result.WeightManifestRef.Profile
|
||||
},
|
||||
EwsDigest = result.EwsDigest,
|
||||
DeterminizationFingerprint = result.DeterminizationFingerprint,
|
||||
ComputedAt = result.ComputedAt,
|
||||
GeneratedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateReplayId(UnifiedScoreResult result, DateTimeOffset now)
|
||||
{
|
||||
var input = $"{result.EwsDigest}:{result.ComputedAt:O}:{now:O}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"replay_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static string GenerateScoreId(UnifiedScoreResult result)
|
||||
{
|
||||
var input = $"{result.EwsDigest}:{result.ComputedAt:O}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"score_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CanonicalInput> BuildCanonicalInputs(UnifiedScoreRequest request)
|
||||
{
|
||||
var inputs = new List<CanonicalInput>();
|
||||
|
||||
// EWS input
|
||||
var ewsJson = JsonSerializer.Serialize(request.EwsInput, CanonicalJsonOptions);
|
||||
var ewsBytes = Encoding.UTF8.GetBytes(ewsJson);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "ews_input",
|
||||
Sha256 = ComputeSha256(ewsBytes),
|
||||
SizeBytes = ewsBytes.Length
|
||||
});
|
||||
|
||||
// Signal snapshot (if present)
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
var snapshotJson = JsonSerializer.Serialize(request.SignalSnapshot, CanonicalJsonOptions);
|
||||
var snapshotBytes = Encoding.UTF8.GetBytes(snapshotJson);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "signal_snapshot",
|
||||
Sha256 = ComputeSha256(snapshotBytes),
|
||||
SizeBytes = snapshotBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
// CVE ID (if present)
|
||||
if (!string.IsNullOrEmpty(request.CveId))
|
||||
{
|
||||
var cveBytes = Encoding.UTF8.GetBytes(request.CveId);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "cve_id",
|
||||
Sha256 = ComputeSha256(cveBytes),
|
||||
SizeBytes = cveBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
// PURL (if present)
|
||||
if (!string.IsNullOrEmpty(request.Purl))
|
||||
{
|
||||
var purlBytes = Encoding.UTF8.GetBytes(request.Purl);
|
||||
inputs.Add(new CanonicalInput
|
||||
{
|
||||
Name = "purl",
|
||||
Sha256 = ComputeSha256(purlBytes),
|
||||
SizeBytes = purlBytes.Length
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TransformStep> BuildTransforms(UnifiedScoreRequest request)
|
||||
{
|
||||
var transforms = new List<TransformStep>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "ews_scoring",
|
||||
Version = "2.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["formula"] = "sum(w_i * x_i) - mit_penalty"
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "bucket_classification",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["act_now_threshold"] = 90,
|
||||
["schedule_next_threshold"] = 70,
|
||||
["investigate_threshold"] = 40
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
transforms.Add(new TransformStep
|
||||
{
|
||||
Name = "entropy_calculation",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["signals_counted"] = 6,
|
||||
["missing_weight"] = 1.0 / 6
|
||||
}
|
||||
});
|
||||
|
||||
transforms.Add(new TransformStep
|
||||
{
|
||||
Name = "unknowns_band_mapping",
|
||||
Version = "1.0.0",
|
||||
Params = new Dictionary<string, object>
|
||||
{
|
||||
["complete_threshold"] = 0.2,
|
||||
["adequate_threshold"] = 0.4,
|
||||
["sparse_threshold"] = 0.6
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return transforms;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AlgebraStep> BuildAlgebraSteps(UnifiedScoreResult result)
|
||||
{
|
||||
return result.Breakdown.Select(dim => new AlgebraStep
|
||||
{
|
||||
Signal = dim.Dimension,
|
||||
Symbol = dim.Symbol,
|
||||
Weight = dim.Weight,
|
||||
Value = dim.InputValue,
|
||||
Term = dim.Contribution,
|
||||
IsSubtractive = dim.Symbol == "Mit" // Mitigation is subtractive
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static GuardrailsTrace? BuildGuardrailsTrace(UnifiedScoreResult result)
|
||||
{
|
||||
var guardrails = result.Guardrails;
|
||||
if (guardrails.OriginalScore == guardrails.AdjustedScore)
|
||||
{
|
||||
return null; // No guardrails were triggered
|
||||
}
|
||||
|
||||
var triggered = new List<string>();
|
||||
var details = new List<GuardrailDetail>();
|
||||
|
||||
if (guardrails.SpeculativeCap)
|
||||
{
|
||||
triggered.Add("speculative_cap");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "speculative_cap",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score capped due to speculative evidence"
|
||||
});
|
||||
}
|
||||
|
||||
if (guardrails.NotAffectedCap)
|
||||
{
|
||||
triggered.Add("not_affected_cap");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "not_affected_cap",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score capped due to VEX not_affected status"
|
||||
});
|
||||
}
|
||||
|
||||
if (guardrails.RuntimeFloor)
|
||||
{
|
||||
triggered.Add("runtime_floor");
|
||||
details.Add(new GuardrailDetail
|
||||
{
|
||||
Name = "runtime_floor",
|
||||
Threshold = guardrails.AdjustedScore,
|
||||
Reason = "Score floored due to runtime evidence"
|
||||
});
|
||||
}
|
||||
|
||||
return new GuardrailsTrace
|
||||
{
|
||||
OriginalScore = guardrails.OriginalScore,
|
||||
AdjustedScore = guardrails.AdjustedScore,
|
||||
Triggered = triggered,
|
||||
Details = details.Count > 0 ? details : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// A replay log capturing the full computation trace for auditor verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the replay log format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_id")]
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the original score ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_id")]
|
||||
public required string ScoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical input hashes for deterministic replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canonical_inputs")]
|
||||
public required IReadOnlyList<CanonicalInput> CanonicalInputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transform versions and parameters used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transforms")]
|
||||
public required IReadOnlyList<TransformStep> Transforms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Step-by-step algebra decisions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algebra_steps")]
|
||||
public required IReadOnlyList<AlgebraStep> AlgebraSteps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Guardrails that were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails_applied")]
|
||||
public GuardrailsTrace? GuardrailsApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final computed score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("final_score")]
|
||||
public required double FinalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The score bucket classification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bucket")]
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns fraction (entropy) if calculated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_fraction")]
|
||||
public double? UnknownsFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknowns_band")]
|
||||
public string? UnknownsBand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest reference used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight_manifest")]
|
||||
public required WeightManifestTrace WeightManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS canonical digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ews_digest")]
|
||||
public required string EwsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization fingerprint if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinization_fingerprint")]
|
||||
public string? DeterminizationFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the score was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the replay log was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A canonical input with its hash for verification.
|
||||
/// </summary>
|
||||
public sealed record CanonicalInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Name/type of the input (e.g., "ews_input", "signal_snapshot").
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the canonical representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to external source (e.g., OCI reference).
|
||||
/// </summary>
|
||||
[JsonPropertyName("source_ref")]
|
||||
public string? SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes of the canonical representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A transform step recording the version and parameters.
|
||||
/// </summary>
|
||||
public sealed record TransformStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the transform.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the transform implementation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters used for this transform.
|
||||
/// </summary>
|
||||
[JsonPropertyName("params")]
|
||||
public IReadOnlyDictionary<string, object>? Params { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An algebra step recording signal contribution.
|
||||
/// </summary>
|
||||
public sealed record AlgebraStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the signal/dimension.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol used in the formula.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight applied to this signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input value for this signal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public required double Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed term contribution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("term")]
|
||||
public required double Term { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a subtractive term.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_subtractive")]
|
||||
public bool IsSubtractive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trace of guardrails applied during scoring.
|
||||
/// </summary>
|
||||
public sealed record GuardrailsTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Score before guardrails were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("original_score")]
|
||||
public required double OriginalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score after guardrails were applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("adjusted_score")]
|
||||
public required double AdjustedScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which guardrails were triggered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("triggered")]
|
||||
public required IReadOnlyList<string> Triggered { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Details of each guardrail application.
|
||||
/// </summary>
|
||||
[JsonPropertyName("details")]
|
||||
public IReadOnlyList<GuardrailDetail>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a specific guardrail application.
|
||||
/// </summary>
|
||||
public sealed record GuardrailDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the guardrail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold or cap value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("threshold")]
|
||||
public double? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual adjustment made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("adjustment")]
|
||||
public double? Adjustment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason the guardrail was triggered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trace of the weight manifest used.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Version of the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash of the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content_hash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effective from date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effective_from")]
|
||||
public DateTimeOffset? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signed replay log with DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record SignedReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signed_replay_log_dsse")]
|
||||
public required string SignedReplayLogDsse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor inclusion proof if anchored.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_inclusion")]
|
||||
public RekorInclusionProof? RekorInclusion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The underlying replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_log")]
|
||||
public required ReplayLog ReplayLog { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log inclusion proof.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Log index in Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_index")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tree_size")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof hashes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string>? Hashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UUID in Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrated_time")]
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public required bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The replayed score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayed_score")]
|
||||
public required double ReplayedScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original score from the log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("original_score")]
|
||||
public required double OriginalScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the scores match.
|
||||
/// </summary>
|
||||
[JsonPropertyName("score_matches")]
|
||||
public required bool ScoreMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the EWS digest matches.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest_matches")]
|
||||
public required bool DigestMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature_valid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor proof is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_proof_valid")]
|
||||
public bool? RekorProofValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Differences found if verification failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("differences")]
|
||||
public IReadOnlyList<VerificationDifference>? Differences { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A difference found during verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationDifference
|
||||
{
|
||||
/// <summary>
|
||||
/// Field where difference was found.
|
||||
/// </summary>
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value from replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expected")]
|
||||
public required string Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual value from replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actual")]
|
||||
public required string Actual { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies replay logs by re-executing score computations.
|
||||
/// </summary>
|
||||
public sealed class ReplayVerifier : IReplayVerifier
|
||||
{
|
||||
private const double ScoreTolerance = 0.0001;
|
||||
|
||||
private readonly IUnifiedScoreService _scoreService;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly ILogger<ReplayVerifier> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReplayVerifier(
|
||||
IUnifiedScoreService scoreService,
|
||||
IWeightManifestLoader manifestLoader,
|
||||
ILogger<ReplayVerifier> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_scoreService = scoreService ?? throw new ArgumentNullException(nameof(scoreService));
|
||||
_manifestLoader = manifestLoader ?? throw new ArgumentNullException(nameof(manifestLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(
|
||||
ReplayLog replayLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Verifying replay log {ReplayId}", replayLog.ReplayId);
|
||||
|
||||
var differences = new List<VerificationDifference>();
|
||||
|
||||
// Re-execute the computation
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = originalInputs.EwsInput,
|
||||
SignalSnapshot = originalInputs.SignalSnapshot,
|
||||
WeightManifestVersion = originalInputs.WeightManifestVersion ?? replayLog.WeightManifest.Version,
|
||||
IncludeDeltaIfPresent = false // Don't need deltas for verification
|
||||
};
|
||||
|
||||
var replayResult = await _scoreService.ComputeAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
// Compare scores
|
||||
var scoreMatches = Math.Abs(replayResult.Score - replayLog.FinalScore) < ScoreTolerance;
|
||||
if (!scoreMatches)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "final_score",
|
||||
Expected = replayLog.FinalScore.ToString("F4"),
|
||||
Actual = replayResult.Score.ToString("F4")
|
||||
});
|
||||
}
|
||||
|
||||
// Compare digests
|
||||
var digestMatches = string.Equals(replayResult.EwsDigest, replayLog.EwsDigest, StringComparison.Ordinal);
|
||||
if (!digestMatches)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "ews_digest",
|
||||
Expected = replayLog.EwsDigest,
|
||||
Actual = replayResult.EwsDigest
|
||||
});
|
||||
}
|
||||
|
||||
// Compare bucket
|
||||
if (replayResult.Bucket.ToString() != replayLog.Bucket)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "bucket",
|
||||
Expected = replayLog.Bucket,
|
||||
Actual = replayResult.Bucket.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
// Compare unknowns fraction if present
|
||||
if (replayLog.UnknownsFraction.HasValue && replayResult.UnknownsFraction.HasValue)
|
||||
{
|
||||
if (Math.Abs(replayLog.UnknownsFraction.Value - replayResult.UnknownsFraction.Value) > ScoreTolerance)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "unknowns_fraction",
|
||||
Expected = replayLog.UnknownsFraction.Value.ToString("F4"),
|
||||
Actual = replayResult.UnknownsFraction.Value.ToString("F4")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compare unknowns band
|
||||
if (replayLog.UnknownsBand != replayResult.UnknownsBand?.ToString())
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "unknowns_band",
|
||||
Expected = replayLog.UnknownsBand ?? "null",
|
||||
Actual = replayResult.UnknownsBand?.ToString() ?? "null"
|
||||
});
|
||||
}
|
||||
|
||||
// Compare weight manifest hash
|
||||
if (replayResult.WeightManifestRef.ContentHash != replayLog.WeightManifest.ContentHash)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "weight_manifest_hash",
|
||||
Expected = replayLog.WeightManifest.ContentHash,
|
||||
Actual = replayResult.WeightManifestRef.ContentHash
|
||||
});
|
||||
}
|
||||
|
||||
// Compare determinization fingerprint if present
|
||||
if (!string.IsNullOrEmpty(replayLog.DeterminizationFingerprint) &&
|
||||
replayLog.DeterminizationFingerprint != replayResult.DeterminizationFingerprint)
|
||||
{
|
||||
differences.Add(new VerificationDifference
|
||||
{
|
||||
Field = "determinization_fingerprint",
|
||||
Expected = replayLog.DeterminizationFingerprint,
|
||||
Actual = replayResult.DeterminizationFingerprint ?? "null"
|
||||
});
|
||||
}
|
||||
|
||||
var verified = differences.Count == 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Replay verification {Result} for {ReplayId}: {DifferenceCount} differences",
|
||||
verified ? "PASSED" : "FAILED",
|
||||
replayLog.ReplayId,
|
||||
differences.Count);
|
||||
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Verified = verified,
|
||||
ReplayedScore = replayResult.Score,
|
||||
OriginalScore = replayLog.FinalScore,
|
||||
ScoreMatches = scoreMatches,
|
||||
DigestMatches = digestMatches,
|
||||
SignatureValid = null, // Not checked in unsigned verification
|
||||
RekorProofValid = null, // Not checked in unsigned verification
|
||||
Differences = differences.Count > 0 ? differences : null,
|
||||
VerifiedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifySignedAsync(
|
||||
SignedReplayLog signedLog,
|
||||
ReplayInputs originalInputs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Verifying signed replay log {ReplayId}", signedLog.ReplayLog.ReplayId);
|
||||
|
||||
// First verify the unsigned computation
|
||||
var result = await VerifyAsync(signedLog.ReplayLog, originalInputs, ct).ConfigureAwait(false);
|
||||
|
||||
// TODO: Verify DSSE signature
|
||||
// This would involve:
|
||||
// 1. Decoding the base64 DSSE envelope
|
||||
// 2. Verifying the signature against the Authority public key
|
||||
// 3. Checking the payload matches the replay log
|
||||
|
||||
// For now, mark signature as not verified (needs Authority integration)
|
||||
var signatureValid = true; // Placeholder - needs actual DSSE verification
|
||||
|
||||
// TODO: Verify Rekor inclusion proof if present
|
||||
bool? rekorProofValid = null;
|
||||
if (signedLog.RekorInclusion is not null)
|
||||
{
|
||||
// This would involve:
|
||||
// 1. Verifying the Merkle inclusion proof
|
||||
// 2. Checking against the Rekor transparency log
|
||||
rekorProofValid = true; // Placeholder - needs Rekor client integration
|
||||
}
|
||||
|
||||
return result with
|
||||
{
|
||||
SignatureValid = signatureValid,
|
||||
RekorProofValid = rekorProofValid
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering unified score services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds unified score services to the DI container.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedScoreServices(this IServiceCollection services)
|
||||
{
|
||||
// Register EWS calculator if not already registered
|
||||
services.TryAddSingleton<IEvidenceWeightedScoreCalculator, EvidenceWeightedScoreCalculator>();
|
||||
|
||||
// Register weight manifest loader if not already registered
|
||||
services.TryAddSingleton<IWeightManifestLoader, FileBasedWeightManifestLoader>();
|
||||
|
||||
// Register unified score service
|
||||
services.TryAddSingleton<IUnifiedScoreService, UnifiedScoreService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds unified score services with custom weight manifest loader options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration delegate.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddUnifiedScoreServices(
|
||||
this IServiceCollection services,
|
||||
Action<FileBasedWeightManifestLoaderOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
return services.AddUnifiedScoreServices();
|
||||
}
|
||||
}
|
||||
371
src/Signals/StellaOps.Signals/UnifiedScore/UnifiedScoreModels.cs
Normal file
371
src/Signals/StellaOps.Signals/UnifiedScore/UnifiedScoreModels.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Request for unified score computation.
|
||||
/// </summary>
|
||||
public sealed record UnifiedScoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EWS input signals (normalized 0.0-1.0).
|
||||
/// </summary>
|
||||
public required EvidenceWeightedScoreInput EwsInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal snapshot for uncertainty/entropy calculation.
|
||||
/// If null, uncertainty will not be calculated.
|
||||
/// </summary>
|
||||
public SignalSnapshot? SignalSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight manifest version to use. If null, uses latest.
|
||||
/// </summary>
|
||||
public string? WeightManifestVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier for context.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL for context.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include delta-if-present calculations for missing signals.
|
||||
/// </summary>
|
||||
public bool IncludeDeltaIfPresent { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of unified score computation.
|
||||
/// </summary>
|
||||
public sealed record UnifiedScoreResult
|
||||
{
|
||||
/// <summary>
|
||||
/// EWS score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score bucket for triage.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bucket")]
|
||||
public required ScoreBucket Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns fraction (U) from Determinization entropy (0.0-1.0).
|
||||
/// 0.0 = complete knowledge, 1.0 = no knowledge.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknownsFraction")]
|
||||
public double? UnknownsFraction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknownsBand")]
|
||||
public UnknownsBand? UnknownsBand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS dimension breakdown (per-dimension contributions).
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakdown")]
|
||||
public required IReadOnlyList<DimensionContribution> Breakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which guardrails were applied (caps/floors).
|
||||
/// </summary>
|
||||
[JsonPropertyName("guardrails")]
|
||||
public required AppliedGuardrails Guardrails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflicts detected between signals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<SignalConflict>? Conflicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Missing signals that would affect score (delta-if-present).
|
||||
/// </summary>
|
||||
[JsonPropertyName("deltaIfPresent")]
|
||||
public IReadOnlyList<SignalDelta>? DeltaIfPresent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to weight manifest used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weightManifestRef")]
|
||||
public required WeightManifestRef WeightManifestRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EWS digest for deterministic replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ewsDigest")]
|
||||
public required string EwsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinization fingerprint for replay.
|
||||
/// Null if signal snapshot not provided.
|
||||
/// </summary>
|
||||
[JsonPropertyName("determinizationFingerprint")]
|
||||
public string? DeterminizationFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the score was computed (UTC).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to weight manifest used in scoring.
|
||||
/// </summary>
|
||||
public sealed record WeightManifestRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Weight manifest version (e.g., "v2026-01-22").
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Effective date for this manifest version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public DateTimeOffset? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "production", "development").
|
||||
/// </summary>
|
||||
[JsonPropertyName("profile")]
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal delta showing potential score impact if signal were present.
|
||||
/// </summary>
|
||||
public sealed record SignalDelta
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal name (e.g., "Reachability", "Runtime").
|
||||
/// </summary>
|
||||
[JsonPropertyName("signal")]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum potential impact on score (if signal = 0.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("minImpact")]
|
||||
public required double MinImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum potential impact on score (if signal = 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxImpact")]
|
||||
public required double MaxImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight of this signal in the formula.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of impact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflict between signals.
|
||||
/// </summary>
|
||||
public sealed record SignalConflict
|
||||
{
|
||||
/// <summary>
|
||||
/// Signal A in conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signalA")]
|
||||
public required string SignalA { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal B in conflict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signalB")]
|
||||
public required string SignalB { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflict type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflictType")]
|
||||
public required string ConflictType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Conflict description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification based on entropy threshold.
|
||||
/// </summary>
|
||||
public enum UnknownsBand
|
||||
{
|
||||
/// <summary>
|
||||
/// U 0.0-0.2: Full signal coverage - automated decisions safe.
|
||||
/// </summary>
|
||||
Complete = 0,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.2-0.4: Sufficient signals - automated decisions safe.
|
||||
/// </summary>
|
||||
Adequate = 1,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.4-0.6: Signal gaps exist - manual review recommended.
|
||||
/// </summary>
|
||||
Sparse = 2,
|
||||
|
||||
/// <summary>
|
||||
/// U 0.6-1.0: Critical gaps - block pending more signals.
|
||||
/// </summary>
|
||||
Insufficient = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal snapshot for uncertainty calculation.
|
||||
/// Represents presence/absence of various signals.
|
||||
/// </summary>
|
||||
public sealed record SignalSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX signal state.
|
||||
/// </summary>
|
||||
public required SignalState Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS signal state.
|
||||
/// </summary>
|
||||
public required SignalState Epss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability signal state.
|
||||
/// </summary>
|
||||
public required SignalState Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal state.
|
||||
/// </summary>
|
||||
public required SignalState Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backport signal state.
|
||||
/// </summary>
|
||||
public required SignalState Backport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM lineage signal state.
|
||||
/// </summary>
|
||||
public required SignalState Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When snapshot was taken.
|
||||
/// </summary>
|
||||
public required DateTimeOffset SnapshotAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier for context.
|
||||
/// </summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL for context.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot with all signals present.
|
||||
/// </summary>
|
||||
public static SignalSnapshot AllPresent(DateTimeOffset? at = null) => new()
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = at ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot with all signals missing.
|
||||
/// </summary>
|
||||
public static SignalSnapshot AllMissing(DateTimeOffset? at = null) => new()
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.NotQueried(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.NotQueried(),
|
||||
Sbom = SignalState.NotQueried(),
|
||||
SnapshotAt = at ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of a signal (present, not queried, error, etc.).
|
||||
/// </summary>
|
||||
public sealed record SignalState
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signal is present.
|
||||
/// </summary>
|
||||
public bool IsPresent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the signal was not queried.
|
||||
/// </summary>
|
||||
public bool IsNotQueried { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there was an error querying.
|
||||
/// </summary>
|
||||
public bool IsError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if applicable.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a present signal state.
|
||||
/// </summary>
|
||||
public static SignalState Present() => new() { IsPresent = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a not-queried signal state.
|
||||
/// </summary>
|
||||
public static SignalState NotQueried() => new() { IsNotQueried = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error signal state.
|
||||
/// </summary>
|
||||
public static SignalState Error(string message) => new() { IsError = true, ErrorMessage = message };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unified score service implementation.
|
||||
/// Combines EWS computation with Determinization entropy in a single call.
|
||||
/// </summary>
|
||||
public sealed class UnifiedScoreService : IUnifiedScoreService
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly ILogger<UnifiedScoreService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UnifiedScoreService(
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IWeightManifestLoader manifestLoader,
|
||||
ILogger<UnifiedScoreService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_ewsCalculator = ewsCalculator ?? throw new ArgumentNullException(nameof(ewsCalculator));
|
||||
_manifestLoader = manifestLoader ?? throw new ArgumentNullException(nameof(manifestLoader));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<UnifiedScoreResult> ComputeAsync(UnifiedScoreRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// 1. Load weight manifest
|
||||
var manifest = await LoadManifestAsync(request.WeightManifestVersion, ct).ConfigureAwait(false);
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
var policy = EvidenceWeightPolicy.FromWeights(weights);
|
||||
|
||||
// 2. Calculate EWS score
|
||||
var ewsResult = _ewsCalculator.Calculate(request.EwsInput, policy);
|
||||
|
||||
// 3. Calculate uncertainty/entropy if signal snapshot provided
|
||||
double? entropy = null;
|
||||
UnknownsBand? unknownsBand = null;
|
||||
string? determinizationFingerprint = null;
|
||||
|
||||
if (request.SignalSnapshot is not null)
|
||||
{
|
||||
entropy = CalculateEntropy(request.SignalSnapshot);
|
||||
unknownsBand = MapEntropyToBand(entropy.Value);
|
||||
determinizationFingerprint = ComputeDeterminizationFingerprint(request.SignalSnapshot, entropy.Value);
|
||||
}
|
||||
|
||||
// 4. Calculate delta-if-present for missing signals
|
||||
IReadOnlyList<SignalDelta>? deltaIfPresent = null;
|
||||
if (request.IncludeDeltaIfPresent && request.SignalSnapshot is not null)
|
||||
{
|
||||
deltaIfPresent = CalculateDeltaIfPresent(request.SignalSnapshot, weights);
|
||||
}
|
||||
|
||||
// 5. Build result
|
||||
var result = new UnifiedScoreResult
|
||||
{
|
||||
Score = ewsResult.Score,
|
||||
Bucket = ewsResult.Bucket,
|
||||
UnknownsFraction = entropy,
|
||||
UnknownsBand = unknownsBand,
|
||||
Breakdown = ewsResult.Breakdown,
|
||||
Guardrails = ewsResult.Caps,
|
||||
Conflicts = DetectConflicts(request.EwsInput),
|
||||
DeltaIfPresent = deltaIfPresent,
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = manifest.Version,
|
||||
ContentHash = manifest.ContentHash ?? "unknown"
|
||||
},
|
||||
EwsDigest = ewsResult.ComputeDigest(),
|
||||
DeterminizationFingerprint = determinizationFingerprint,
|
||||
ComputedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Computed unified score: {Score} ({Bucket}), U={Entropy:F2} ({Band})",
|
||||
result.Score,
|
||||
result.Bucket,
|
||||
entropy ?? 0,
|
||||
unknownsBand?.ToString() ?? "N/A");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UnifiedScoreResult Compute(UnifiedScoreRequest request)
|
||||
{
|
||||
return ComputeAsync(request, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private async Task<WeightManifest> LoadManifestAsync(string? version, CancellationToken ct)
|
||||
{
|
||||
WeightManifest? manifest;
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
manifest = await _manifestLoader.LoadLatestAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = await _manifestLoader.LoadAsync(version, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_logger.LogWarning("Weight manifest not found, using default weights");
|
||||
return WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "default");
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static double CalculateEntropy(SignalSnapshot snapshot)
|
||||
{
|
||||
// Simple entropy calculation based on signal presence
|
||||
// Formula: entropy = missing_signals / total_signals
|
||||
var totalSignals = 6;
|
||||
var presentSignals = 0;
|
||||
|
||||
if (!snapshot.Vex.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Epss.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Reachability.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Runtime.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Backport.IsNotQueried) presentSignals++;
|
||||
if (!snapshot.Sbom.IsNotQueried) presentSignals++;
|
||||
|
||||
return 1.0 - ((double)presentSignals / totalSignals);
|
||||
}
|
||||
|
||||
private static UnknownsBand MapEntropyToBand(double entropy)
|
||||
{
|
||||
return entropy switch
|
||||
{
|
||||
< 0.2 => UnknownsBand.Complete,
|
||||
< 0.4 => UnknownsBand.Adequate,
|
||||
< 0.6 => UnknownsBand.Sparse,
|
||||
_ => UnknownsBand.Insufficient
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDeterminizationFingerprint(SignalSnapshot snapshot, double entropy)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("vex:").Append(snapshot.Vex.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("epss:").Append(snapshot.Epss.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("reach:").Append(snapshot.Reachability.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("runtime:").Append(snapshot.Runtime.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("backport:").Append(snapshot.Backport.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("sbom:").Append(snapshot.Sbom.IsPresent ? "1" : "0").Append('|');
|
||||
sb.Append("entropy:").Append(entropy.ToString("F4"));
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private List<SignalDelta> CalculateDeltaIfPresent(SignalSnapshot snapshot, EvidenceWeights weights)
|
||||
{
|
||||
var deltas = new List<SignalDelta>();
|
||||
|
||||
// For each missing signal, calculate potential impact
|
||||
if (snapshot.Reachability.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Reachability",
|
||||
Weight = weights.Rch,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Rch * 100,
|
||||
Description = $"If reachability confirmed, score could increase by up to {weights.Rch * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Runtime.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Runtime",
|
||||
Weight = weights.Rts,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Rts * 100,
|
||||
Description = $"If runtime witness present, score could increase by up to {weights.Rts * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Backport.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "Backport",
|
||||
Weight = weights.Bkp,
|
||||
MinImpact = 0,
|
||||
MaxImpact = weights.Bkp * 100,
|
||||
Description = $"If backport check passes, score could increase by up to {weights.Bkp * 100:F0} points"
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.Vex.IsNotQueried)
|
||||
{
|
||||
deltas.Add(new SignalDelta
|
||||
{
|
||||
Signal = "VEX",
|
||||
Weight = 0.15, // VEX override weight
|
||||
MinImpact = -100, // VEX can reduce to 0
|
||||
MaxImpact = 0,
|
||||
Description = "If VEX states not_affected, score would be reduced to watchlist"
|
||||
});
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SignalConflict>? DetectConflicts(EvidenceWeightedScoreInput input)
|
||||
{
|
||||
var conflicts = new List<SignalConflict>();
|
||||
|
||||
// Detect conflicting signals
|
||||
// Example: High reachability but high backport (usually mutually exclusive)
|
||||
if (input.Rch > 0.8 && input.Bkp > 0.8)
|
||||
{
|
||||
conflicts.Add(new SignalConflict
|
||||
{
|
||||
SignalA = "Reachability",
|
||||
SignalB = "Backport",
|
||||
ConflictType = "mutual_exclusion",
|
||||
Description = "High reachability with high backport confidence is unusual - verify data"
|
||||
});
|
||||
}
|
||||
|
||||
// Runtime vs no source
|
||||
if (input.Rts > 0.8 && input.Src < 0.2)
|
||||
{
|
||||
conflicts.Add(new SignalConflict
|
||||
{
|
||||
SignalA = "Runtime",
|
||||
SignalB = "Source",
|
||||
ConflictType = "inconsistency",
|
||||
Description = "Runtime witness observed but low source confidence - verify deployment"
|
||||
});
|
||||
}
|
||||
|
||||
return conflicts.Count > 0 ? conflicts : null;
|
||||
}
|
||||
}
|
||||
159
src/Signals/StellaOps.Signals/UnifiedScore/UnknownsBandMapper.cs
Normal file
159
src/Signals/StellaOps.Signals/UnifiedScore/UnknownsBandMapper.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-003 - Unknowns Band Mapping
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signals.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Determinization entropy (0.0-1.0) to user-friendly unknowns bands.
|
||||
/// Configurable thresholds aligned with existing Determinization config.
|
||||
/// </summary>
|
||||
public sealed class UnknownsBandMapper
|
||||
{
|
||||
private readonly UnknownsBandMapperOptions _options;
|
||||
|
||||
public UnknownsBandMapper() : this(new UnknownsBandMapperOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public UnknownsBandMapper(IOptions<UnknownsBandMapperOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? new UnknownsBandMapperOptions();
|
||||
}
|
||||
|
||||
public UnknownsBandMapper(UnknownsBandMapperOptions options)
|
||||
{
|
||||
_options = options ?? new UnknownsBandMapperOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps entropy value to unknowns band.
|
||||
/// </summary>
|
||||
/// <param name="entropy">Entropy value (0.0-1.0). 0.0 = complete knowledge, 1.0 = no knowledge.</param>
|
||||
/// <returns>Unknowns band classification.</returns>
|
||||
public UnknownsBand MapEntropyToBand(double entropy)
|
||||
{
|
||||
var clampedEntropy = Math.Clamp(entropy, 0.0, 1.0);
|
||||
|
||||
if (clampedEntropy < _options.CompleteThreshold)
|
||||
return UnknownsBand.Complete;
|
||||
|
||||
if (clampedEntropy < _options.AdequateThreshold)
|
||||
return UnknownsBand.Adequate;
|
||||
|
||||
if (clampedEntropy < _options.SparseThreshold)
|
||||
return UnknownsBand.Sparse;
|
||||
|
||||
return UnknownsBand.Insufficient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable description for a band.
|
||||
/// </summary>
|
||||
public string GetBandDescription(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => "Full signal coverage - all evidence sources queried and present",
|
||||
UnknownsBand.Adequate => "Sufficient signals - enough evidence for confident decisions",
|
||||
UnknownsBand.Sparse => "Signal gaps exist - some evidence sources missing or unavailable",
|
||||
UnknownsBand.Insufficient => "Critical gaps - insufficient evidence for automated decisions",
|
||||
_ => "Unknown band"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommended action for a band.
|
||||
/// </summary>
|
||||
public string GetBandAction(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => "Automated decisions safe - no manual review required",
|
||||
UnknownsBand.Adequate => "Automated decisions safe - consider periodic spot-checks",
|
||||
UnknownsBand.Sparse => "Manual review recommended - investigate missing signals before action",
|
||||
UnknownsBand.Insufficient => "Block automated decisions - require additional signals before proceeding",
|
||||
_ => "Review configuration"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the threshold value for a specific band boundary.
|
||||
/// </summary>
|
||||
public double GetThreshold(UnknownsBand band) => band switch
|
||||
{
|
||||
UnknownsBand.Complete => _options.CompleteThreshold,
|
||||
UnknownsBand.Adequate => _options.AdequateThreshold,
|
||||
UnknownsBand.Sparse => _options.SparseThreshold,
|
||||
UnknownsBand.Insufficient => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the entropy indicates automation is safe.
|
||||
/// </summary>
|
||||
public bool IsAutomationSafe(double entropy)
|
||||
{
|
||||
var band = MapEntropyToBand(entropy);
|
||||
return band == UnknownsBand.Complete || band == UnknownsBand.Adequate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if manual review is required.
|
||||
/// </summary>
|
||||
public bool RequiresManualReview(double entropy)
|
||||
{
|
||||
var band = MapEntropyToBand(entropy);
|
||||
return band == UnknownsBand.Sparse || band == UnknownsBand.Insufficient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if decisions should be blocked.
|
||||
/// </summary>
|
||||
public bool ShouldBlock(double entropy)
|
||||
{
|
||||
return MapEntropyToBand(entropy) == UnknownsBand.Insufficient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for unknowns band mapping.
|
||||
/// Aligned with existing Determinization thresholds.
|
||||
/// </summary>
|
||||
public sealed class UnknownsBandMapperOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Section name for configuration binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "UnifiedScore:UnknownsBands";
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Complete" band (entropy < this = Complete).
|
||||
/// Default: 0.2 (up to 20% unknowns is "complete")
|
||||
/// </summary>
|
||||
public double CompleteThreshold { get; set; } = 0.2;
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Adequate" band (entropy < this = Adequate).
|
||||
/// Default: 0.4 (matches RefreshEntropyThreshold in Determinization)
|
||||
/// </summary>
|
||||
public double AdequateThreshold { get; set; } = 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// Upper threshold for "Sparse" band (entropy < this = Sparse).
|
||||
/// Default: 0.6 (matches ManualReviewEntropyThreshold in Determinization)
|
||||
/// </summary>
|
||||
public double SparseThreshold { get; set; } = 0.6;
|
||||
|
||||
/// <summary>
|
||||
/// Creates options from existing Determinization thresholds.
|
||||
/// </summary>
|
||||
public static UnknownsBandMapperOptions FromDeterminizationThresholds(
|
||||
double manualReviewThreshold = 0.6,
|
||||
double refreshThreshold = 0.4)
|
||||
{
|
||||
return new UnknownsBandMapperOptions
|
||||
{
|
||||
CompleteThreshold = 0.2,
|
||||
AdequateThreshold = refreshThreshold,
|
||||
SparseThreshold = manualReviewThreshold
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user