This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -0,0 +1,55 @@
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Calculates freshness multiplier for evidence based on age.
/// Uses basis-point math for determinism (no floating point).
/// </summary>
public sealed class EvidenceFreshnessCalculator
{
private readonly FreshnessMultiplierConfig _config;
public EvidenceFreshnessCalculator(FreshnessMultiplierConfig? config = null)
{
_config = config ?? FreshnessMultiplierConfig.Default;
}
/// <summary>
/// Calculates the freshness multiplier for evidence collected at a given timestamp.
/// </summary>
/// <param name="evidenceTimestamp">When the evidence was collected.</param>
/// <param name="asOf">Reference time for freshness calculation (explicit, no implicit time).</param>
/// <returns>Multiplier in basis points (10000 = 100%).</returns>
public int CalculateMultiplierBps(DateTimeOffset evidenceTimestamp, DateTimeOffset asOf)
{
if (evidenceTimestamp > asOf)
{
return _config.Buckets[0].MultiplierBps; // Future evidence gets max freshness
}
var ageDays = (int)(asOf - evidenceTimestamp).TotalDays;
foreach (var bucket in _config.Buckets)
{
if (ageDays <= bucket.MaxAgeDays)
{
return bucket.MultiplierBps;
}
}
return _config.Buckets[^1].MultiplierBps; // Fallback to oldest bucket
}
/// <summary>
/// Applies freshness multiplier to a base score.
/// </summary>
/// <param name="baseScore">Score in range 0-100.</param>
/// <param name="evidenceTimestamp">When the evidence was collected.</param>
/// <param name="asOf">Reference time for freshness calculation.</param>
/// <returns>Adjusted score (integer, no floating point).</returns>
public int ApplyFreshness(int baseScore, DateTimeOffset evidenceTimestamp, DateTimeOffset asOf)
{
var multiplierBps = CalculateMultiplierBps(evidenceTimestamp, asOf);
return (baseScore * multiplierBps) / 10000;
}
}

View File

@@ -0,0 +1,31 @@
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Defines a freshness bucket for evidence age-based scoring decay.
/// </summary>
/// <param name="MaxAgeDays">Maximum age in days for this bucket (inclusive upper bound).</param>
/// <param name="MultiplierBps">Multiplier in basis points (10000 = 100%).</param>
public sealed record FreshnessBucket(int MaxAgeDays, int MultiplierBps);
/// <summary>
/// Configuration for evidence freshness multipliers.
/// Default buckets per determinism advisory: 7d=10000, 30d=9000, 90d=7500, 180d=6000, 365d=4000, >365d=2000.
/// </summary>
public sealed record FreshnessMultiplierConfig
{
public required IReadOnlyList<FreshnessBucket> Buckets { get; init; }
public static FreshnessMultiplierConfig Default { get; } = new()
{
Buckets =
[
new FreshnessBucket(7, 10000),
new FreshnessBucket(30, 9000),
new FreshnessBucket(90, 7500),
new FreshnessBucket(180, 6000),
new FreshnessBucket(365, 4000),
new FreshnessBucket(int.MaxValue, 2000)
]
};
}

View File

@@ -0,0 +1,84 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Structured explanation of a factor's contribution to the final score.
/// </summary>
/// <param name="Factor">Factor identifier (e.g., "reachability", "evidence", "provenance").</param>
/// <param name="Value">Computed value for this factor (0-100 range).</param>
/// <param name="Reason">Human-readable explanation of how the value was computed.</param>
/// <param name="ContributingDigests">Optional digests of objects that contributed to this factor.</param>
public sealed record ScoreExplanation(
string Factor,
int Value,
string Reason,
IReadOnlyList<string>? ContributingDigests = null);
/// <summary>
/// Builder for accumulating score explanations during scoring pipeline.
/// </summary>
public sealed class ScoreExplainBuilder
{
private readonly List<ScoreExplanation> _explanations = [];
public ScoreExplainBuilder Add(string factor, int value, string reason, IReadOnlyList<string>? digests = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(factor);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
IReadOnlyList<string>? normalizedDigests = null;
if (digests is { Count: > 0 })
{
normalizedDigests = digests
.Where(d => !string.IsNullOrWhiteSpace(d))
.Select(d => d.Trim())
.OrderBy(d => d, StringComparer.Ordinal)
.ToImmutableArray();
}
_explanations.Add(new ScoreExplanation(factor.Trim(), value, reason, normalizedDigests));
return this;
}
public ScoreExplainBuilder AddReachability(int hops, int score, string entrypoint)
{
var reason = hops switch
{
0 => $"Direct entry point: {entrypoint}",
<= 2 => $"{hops} hops from {entrypoint}",
_ => $"{hops} hops from nearest entry point"
};
return Add("reachability", score, reason);
}
public ScoreExplainBuilder AddEvidence(int points, int freshnessMultiplierBps, int ageDays)
{
var freshnessPercent = freshnessMultiplierBps / 100;
var reason = $"{points} evidence points, {ageDays} days old ({freshnessPercent}% freshness)";
return Add("evidence", (points * freshnessMultiplierBps) / 10000, reason);
}
public ScoreExplainBuilder AddProvenance(string level, int score)
{
return Add("provenance", score, $"Provenance level: {level}");
}
public ScoreExplainBuilder AddBaseSeverity(decimal cvss, int score)
{
return Add("baseSeverity", score, $"CVSS {cvss:F1} mapped to {score}");
}
/// <summary>
/// Builds the explanation list, sorted by factor name for determinism.
/// </summary>
public IReadOnlyList<ScoreExplanation> Build()
{
return _explanations
.OrderBy(e => e.Factor, StringComparer.Ordinal)
.ThenBy(e => e.ContributingDigests?.FirstOrDefault() ?? "", StringComparer.Ordinal)
.ToList();
}
}

View File

@@ -10,6 +10,10 @@ public static class SplSchemaResource
private const string SchemaResourceName = "StellaOps.Policy.Schemas.spl-schema@1.json";
private const string SampleResourceName = "StellaOps.Policy.Schemas.spl-sample@1.json";
public static string GetSchema() => ReadSchemaJson();
public static string GetSample() => ReadSampleJson();
public static Stream OpenSchemaStream()
{
return OpenResourceStream(SchemaResourceName);

View File

@@ -0,0 +1,10 @@
# Policy Library Local Tasks
This file mirrors sprint work for the `StellaOps.Policy` library.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `DET-3401-001` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `FreshnessBucket` + `FreshnessMultiplierConfig` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/FreshnessModels.cs` and covered bucket boundaries in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/EvidenceFreshnessCalculatorTests.cs`. |
| `DET-3401-002` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Implemented `EvidenceFreshnessCalculator` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/EvidenceFreshnessCalculator.cs`. |
| `DET-3401-009` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `ScoreExplanation` + `ScoreExplainBuilder` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs` and tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/ScoreExplainBuilderTests.cs`. |