partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,452 @@
// -----------------------------------------------------------------------------
// ProofGraphBuilder.cs
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
// Task: T1 - Proof graph builder
// Description: Constructs proof graphs from verdict rationale data.
// Deterministic: same inputs always produce same graph with
// content-addressed ID. Supports counterfactual overlay nodes.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Builds proof graphs from verdict rationale components.
/// </summary>
public interface IProofGraphBuilder
{
/// <summary>
/// Builds a complete proof graph from a verdict rationale and
/// optional score breakdown data.
/// </summary>
ProofGraph Build(ProofGraphInput input);
/// <summary>
/// Adds a counterfactual overlay to an existing proof graph,
/// showing how scores would change under hypothetical conditions.
/// </summary>
ProofGraph AddCounterfactualOverlay(
ProofGraph baseGraph,
CounterfactualScenario scenario);
}
/// <summary>
/// Input data for building a proof graph.
/// </summary>
public sealed record ProofGraphInput
{
/// <summary>The verdict rationale to visualize.</summary>
public required VerdictRationale Rationale { get; init; }
/// <summary>Per-factor score breakdown, if available.</summary>
public ScoreBreakdownDashboard? ScoreBreakdown { get; init; }
/// <summary>Reference time for graph computation.</summary>
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// A counterfactual scenario for what-if analysis.
/// </summary>
public sealed record CounterfactualScenario
{
/// <summary>Scenario label.</summary>
[JsonPropertyName("label")]
public required string Label { get; init; }
/// <summary>Factor overrides (factorId → hypothetical score).</summary>
[JsonPropertyName("factor_overrides")]
public required ImmutableDictionary<string, int> FactorOverrides { get; init; }
/// <summary>Resulting composite score under this scenario.</summary>
[JsonPropertyName("resulting_score")]
public int? ResultingScore { get; init; }
}
/// <summary>
/// Deterministic proof graph builder.
/// </summary>
public sealed class ProofGraphBuilder : IProofGraphBuilder
{
private readonly ILogger<ProofGraphBuilder> _logger;
public ProofGraphBuilder(ILogger<ProofGraphBuilder> logger)
{
_logger = logger;
}
public ProofGraph Build(ProofGraphInput input)
{
ArgumentNullException.ThrowIfNull(input);
var nodes = new List<ProofGraphNode>();
var edges = new List<ProofGraphEdge>();
// 1. Create verdict root node (depth 0)
var verdictNodeId = $"verdict:{input.Rationale.VerdictRef.AttestationId}";
nodes.Add(new ProofGraphNode
{
Id = verdictNodeId,
Label = $"Verdict: {input.Rationale.Decision.Verdict}",
Type = ProofNodeType.Verdict,
Confidence = input.Rationale.Decision.Score.HasValue
? input.Rationale.Decision.Score.Value / 100.0
: null,
ScoreContribution = input.Rationale.Decision.Score,
Depth = 0
});
// 2. Create policy rule node (depth 1)
var policyNodeId = $"policy:{input.Rationale.PolicyClause.ClauseId}";
nodes.Add(new ProofGraphNode
{
Id = policyNodeId,
Label = input.Rationale.PolicyClause.RuleDescription,
Type = ProofNodeType.PolicyRule,
Depth = 1
});
edges.Add(new ProofGraphEdge
{
Source = policyNodeId,
Target = verdictNodeId,
Relation = ProofEdgeRelation.Gates,
Label = "Policy evaluation"
});
// 3. Create score computation nodes from breakdown (depth 2)
if (input.ScoreBreakdown is not null)
{
foreach (var factor in input.ScoreBreakdown.Factors)
{
var factorNodeId = $"score:{factor.FactorId}";
nodes.Add(new ProofGraphNode
{
Id = factorNodeId,
Label = $"{factor.FactorName} ({factor.RawScore})",
Type = ProofNodeType.ScoreComputation,
Confidence = factor.Confidence,
ScoreContribution = factor.WeightedContribution,
Depth = 2,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("weight", factor.Weight.ToString("F2"))
.Add("raw_score", factor.RawScore.ToString())
});
edges.Add(new ProofGraphEdge
{
Source = factorNodeId,
Target = verdictNodeId,
Relation = ProofEdgeRelation.ContributesScore,
Weight = factor.Weight,
Label = $"{factor.Weight:P0} weight"
});
}
// 3b. Guardrail nodes (depth 1, override verdict)
foreach (var guardrail in input.ScoreBreakdown.GuardrailsApplied)
{
var guardrailNodeId = $"guardrail:{guardrail.GuardrailName}";
nodes.Add(new ProofGraphNode
{
Id = guardrailNodeId,
Label = $"Guardrail: {guardrail.GuardrailName} ({guardrail.ScoreBefore}→{guardrail.ScoreAfter})",
Type = ProofNodeType.Guardrail,
Depth = 1,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("reason", guardrail.Reason)
});
edges.Add(new ProofGraphEdge
{
Source = guardrailNodeId,
Target = verdictNodeId,
Relation = ProofEdgeRelation.GuardrailApplied,
Label = guardrail.Reason
});
}
}
// 4. Create evidence leaf nodes (depth 3)
var leafNodeIds = new List<string>();
// Reachability evidence
if (input.Rationale.Evidence.Reachability is not null)
{
var reachNodeId = $"evidence:reachability:{input.Rationale.Evidence.Cve}";
nodes.Add(new ProofGraphNode
{
Id = reachNodeId,
Label = $"Reachability: {input.Rationale.Evidence.Reachability.VulnerableFunction ?? "analyzed"}",
Type = ProofNodeType.ReachabilityAnalysis,
Depth = 3,
Metadata = ImmutableDictionary<string, string>.Empty
.Add("entry_point", input.Rationale.Evidence.Reachability.EntryPoint ?? "unknown")
});
edges.Add(new ProofGraphEdge
{
Source = reachNodeId,
Target = TryFindScoreNode(nodes, "rch") ?? policyNodeId,
Relation = ProofEdgeRelation.ProvidesEvidence,
Label = "Reachability signal"
});
leafNodeIds.Add(reachNodeId);
}
// VEX statement evidence
if (input.Rationale.Attestations.VexStatements?.Count > 0)
{
for (int i = 0; i < input.Rationale.Attestations.VexStatements.Count; i++)
{
var vex = input.Rationale.Attestations.VexStatements[i];
var vexNodeId = $"evidence:vex:{vex.Id}";
nodes.Add(new ProofGraphNode
{
Id = vexNodeId,
Label = $"VEX: {vex.Summary ?? vex.Id}",
Type = ProofNodeType.VexStatement,
Digest = vex.Digest,
Depth = 3
});
edges.Add(new ProofGraphEdge
{
Source = vexNodeId,
Target = policyNodeId,
Relation = ProofEdgeRelation.Attests,
Label = "VEX statement"
});
leafNodeIds.Add(vexNodeId);
}
}
// Provenance attestation
if (input.Rationale.Attestations.Provenance is not null)
{
var provNodeId = $"evidence:provenance:{input.Rationale.Attestations.Provenance.Id}";
nodes.Add(new ProofGraphNode
{
Id = provNodeId,
Label = $"Provenance: {input.Rationale.Attestations.Provenance.Summary ?? "verified"}",
Type = ProofNodeType.Provenance,
Digest = input.Rationale.Attestations.Provenance.Digest,
Depth = 3
});
edges.Add(new ProofGraphEdge
{
Source = provNodeId,
Target = policyNodeId,
Relation = ProofEdgeRelation.Attests,
Label = "Provenance attestation"
});
leafNodeIds.Add(provNodeId);
}
// Path witness
if (input.Rationale.Attestations.PathWitness is not null)
{
var pathNodeId = $"evidence:pathwitness:{input.Rationale.Attestations.PathWitness.Id}";
nodes.Add(new ProofGraphNode
{
Id = pathNodeId,
Label = $"Path Witness: {input.Rationale.Attestations.PathWitness.Summary ?? "verified"}",
Type = ProofNodeType.ReachabilityAnalysis,
Digest = input.Rationale.Attestations.PathWitness.Digest,
Depth = 3
});
edges.Add(new ProofGraphEdge
{
Source = pathNodeId,
Target = TryFindScoreNode(nodes, "rch") ?? policyNodeId,
Relation = ProofEdgeRelation.Attests,
Label = "Path witness attestation"
});
leafNodeIds.Add(pathNodeId);
}
// 5. Build critical paths (leaf → root)
var criticalPaths = BuildCriticalPaths(nodes, edges, verdictNodeId, leafNodeIds);
// 6. Compute content-addressed graph ID
var graphId = ComputeGraphId(nodes, edges);
var graph = new ProofGraph
{
GraphId = graphId,
VerdictRef = input.Rationale.VerdictRef,
Nodes = [.. nodes],
Edges = [.. edges],
CriticalPaths = [.. criticalPaths],
RootNodeId = verdictNodeId,
LeafNodeIds = [.. leafNodeIds],
ComputedAt = input.ComputedAt
};
_logger.LogDebug(
"Built proof graph {GraphId} with {NodeCount} nodes, {EdgeCount} edges, {PathCount} paths",
graphId, nodes.Count, edges.Count, criticalPaths.Count);
return graph;
}
public ProofGraph AddCounterfactualOverlay(
ProofGraph baseGraph,
CounterfactualScenario scenario)
{
ArgumentNullException.ThrowIfNull(baseGraph);
ArgumentNullException.ThrowIfNull(scenario);
var nodes = baseGraph.Nodes.ToList();
var edges = baseGraph.Edges.ToList();
// Add a counterfactual hypothesis node
var cfNodeId = $"counterfactual:{scenario.Label.Replace(" ", "_").ToLowerInvariant()}";
nodes.Add(new ProofGraphNode
{
Id = cfNodeId,
Label = $"What-If: {scenario.Label}",
Type = ProofNodeType.Counterfactual,
ScoreContribution = scenario.ResultingScore,
Depth = 0,
Metadata = scenario.FactorOverrides
.ToImmutableDictionary(kv => $"override_{kv.Key}", kv => kv.Value.ToString())
});
// Connect overridden factors to the counterfactual node
foreach (var (factorId, _) in scenario.FactorOverrides)
{
var existingNode = nodes.FirstOrDefault(n => n.Id == $"score:{factorId}");
if (existingNode is not null)
{
edges.Add(new ProofGraphEdge
{
Source = existingNode.Id,
Target = cfNodeId,
Relation = ProofEdgeRelation.Overrides,
Label = $"What-if override: {factorId}"
});
}
}
var newGraphId = ComputeGraphId(nodes, edges);
return baseGraph with
{
GraphId = newGraphId,
Nodes = [.. nodes],
Edges = [.. edges]
};
}
// ── Private helpers ──────────────────────────────────────────────────
private static string? TryFindScoreNode(List<ProofGraphNode> nodes, string factorCode)
{
return nodes.FirstOrDefault(n => n.Id == $"score:{factorCode}")?.Id;
}
private static List<ProofGraphPath> BuildCriticalPaths(
List<ProofGraphNode> nodes,
List<ProofGraphEdge> edges,
string rootId,
List<string> leafIds)
{
var paths = new List<ProofGraphPath>();
// Build adjacency list (reverse: from target to source for tracing back)
var reverseAdj = edges
.GroupBy(e => e.Target)
.ToDictionary(g => g.Key, g => g.Select(e => (e.Source, e.Weight)).ToList());
// Forward adjacency for tracing leaf to root
var forwardAdj = edges
.GroupBy(e => e.Source)
.ToDictionary(g => g.Key, g => g.Select(e => (e.Target, e.Weight)).ToList());
foreach (var leafId in leafIds)
{
var path = FindPathBfs(forwardAdj, leafId, rootId);
if (path.Count > 0)
{
// Calculate path confidence as product of edge weights
var confidence = 1.0;
for (int i = 0; i < path.Count - 1; i++)
{
var edge = edges.FirstOrDefault(e =>
e.Source == path[i] && e.Target == path[i + 1]);
if (edge is not null)
{
confidence *= edge.Weight;
}
}
var leafNode = nodes.FirstOrDefault(n => n.Id == leafId);
paths.Add(new ProofGraphPath
{
NodeIds = [.. path],
PathConfidence = confidence,
Description = $"{leafNode?.Label ?? leafId} → verdict"
});
}
}
// Mark highest-confidence path as critical
if (paths.Count > 0)
{
var maxConfidence = paths.Max(p => p.PathConfidence);
for (int i = 0; i < paths.Count; i++)
{
if (Math.Abs(paths[i].PathConfidence - maxConfidence) < 0.0001)
{
paths[i] = paths[i] with { IsCritical = true };
}
}
}
return paths;
}
private static List<string> FindPathBfs(
Dictionary<string, List<(string Target, double Weight)>> adj,
string from,
string to)
{
var visited = new HashSet<string>();
var queue = new Queue<List<string>>();
queue.Enqueue([from]);
while (queue.Count > 0)
{
var path = queue.Dequeue();
var current = path[^1];
if (current == to)
return path;
if (!visited.Add(current))
continue;
if (adj.TryGetValue(current, out var neighbors))
{
foreach (var (target, _) in neighbors.OrderBy(n => n.Target, StringComparer.Ordinal))
{
if (!visited.Contains(target))
{
queue.Enqueue([.. path, target]);
}
}
}
}
return [];
}
private static string ComputeGraphId(List<ProofGraphNode> nodes, List<ProofGraphEdge> edges)
{
// Deterministic: sort nodes by ID, edges by source+target
var sortedNodes = string.Join("|", nodes.OrderBy(n => n.Id).Select(n => n.Id));
var sortedEdges = string.Join("|", edges
.OrderBy(e => e.Source).ThenBy(e => e.Target)
.Select(e => $"{e.Source}->{e.Target}"));
var content = $"{sortedNodes}:{sortedEdges}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"pg:sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -0,0 +1,204 @@
// -----------------------------------------------------------------------------
// ProofGraphModels.cs
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
// Task: T1 - Proof graph visualization models
// Description: Directed acyclic graph representation of the full evidence
// chain backing a verdict. Nodes represent evidence artifacts,
// edges represent derivation/dependency relationships, and
// paths show the full chain from source evidence to verdict.
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Complete directed acyclic graph representing the evidence chain
/// from source artifacts to a final verdict decision.
/// </summary>
public sealed record ProofGraph
{
/// <summary>Content-addressed graph identifier.</summary>
[JsonPropertyName("graph_id")]
public required string GraphId { get; init; }
/// <summary>Reference to the verdict this graph explains.</summary>
[JsonPropertyName("verdict_ref")]
public required VerdictReference VerdictRef { get; init; }
/// <summary>All nodes in the proof graph.</summary>
[JsonPropertyName("nodes")]
public required ImmutableArray<ProofGraphNode> Nodes { get; init; }
/// <summary>All edges in the proof graph.</summary>
[JsonPropertyName("edges")]
public required ImmutableArray<ProofGraphEdge> Edges { get; init; }
/// <summary>Critical paths from source evidence to verdict.</summary>
[JsonPropertyName("critical_paths")]
public required ImmutableArray<ProofGraphPath> CriticalPaths { get; init; }
/// <summary>Root node ID (the verdict node).</summary>
[JsonPropertyName("root_node_id")]
public required string RootNodeId { get; init; }
/// <summary>Leaf node IDs (source evidence).</summary>
[JsonPropertyName("leaf_node_ids")]
public required ImmutableArray<string> LeafNodeIds { get; init; }
/// <summary>When the graph was computed.</summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// A node in the proof graph representing an evidence artifact,
/// intermediate computation, or the final verdict.
/// </summary>
public sealed record ProofGraphNode
{
/// <summary>Unique node identifier.</summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>Human-readable label for display.</summary>
[JsonPropertyName("label")]
public required string Label { get; init; }
/// <summary>Node type classification.</summary>
[JsonPropertyName("type")]
public required ProofNodeType Type { get; init; }
/// <summary>Confidence score at this node (0.0 to 1.0).</summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
/// <summary>Score contribution of this node to the verdict.</summary>
[JsonPropertyName("score_contribution")]
public double? ScoreContribution { get; init; }
/// <summary>Content digest of the underlying artifact.</summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>Additional metadata for display.</summary>
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>Visual depth in the graph (0 = verdict root).</summary>
[JsonPropertyName("depth")]
public int Depth { get; init; }
}
/// <summary>
/// Classification of proof graph nodes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProofNodeType
{
/// <summary>Final verdict decision.</summary>
Verdict,
/// <summary>Policy rule evaluation.</summary>
PolicyRule,
/// <summary>Scoring computation (e.g., EWS dimension).</summary>
ScoreComputation,
/// <summary>VEX statement evidence.</summary>
VexStatement,
/// <summary>Reachability analysis result.</summary>
ReachabilityAnalysis,
/// <summary>SBOM lineage evidence.</summary>
SbomEvidence,
/// <summary>Provenance attestation.</summary>
Provenance,
/// <summary>Runtime signal observation.</summary>
RuntimeSignal,
/// <summary>EPSS/CVSS advisory data.</summary>
AdvisoryData,
/// <summary>Guardrail rule application.</summary>
Guardrail,
/// <summary>Counterfactual hypothesis node.</summary>
Counterfactual
}
/// <summary>
/// A directed edge in the proof graph showing derivation.
/// </summary>
public sealed record ProofGraphEdge
{
/// <summary>Source node ID (evidence provider).</summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>Target node ID (evidence consumer).</summary>
[JsonPropertyName("target")]
public required string Target { get; init; }
/// <summary>Relationship type.</summary>
[JsonPropertyName("relation")]
public required ProofEdgeRelation Relation { get; init; }
/// <summary>Weight/importance of this edge (0.0 to 1.0).</summary>
[JsonPropertyName("weight")]
public double Weight { get; init; } = 1.0;
/// <summary>Human-readable label for the edge.</summary>
[JsonPropertyName("label")]
public string? Label { get; init; }
}
/// <summary>
/// Types of relationships between proof graph nodes.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ProofEdgeRelation
{
/// <summary>Source provides input evidence to target.</summary>
ProvidesEvidence,
/// <summary>Source score contributes to target aggregate.</summary>
ContributesScore,
/// <summary>Source evaluation gates target decision.</summary>
Gates,
/// <summary>Source attestation supports target claim.</summary>
Attests,
/// <summary>Source overrides target under certain conditions.</summary>
Overrides,
/// <summary>Source guardrail modifies target score.</summary>
GuardrailApplied
}
/// <summary>
/// A path through the proof graph from a leaf evidence node
/// to the root verdict node.
/// </summary>
public sealed record ProofGraphPath
{
/// <summary>Ordered node IDs from leaf to root.</summary>
[JsonPropertyName("node_ids")]
public required ImmutableArray<string> NodeIds { get; init; }
/// <summary>Cumulative confidence along this path.</summary>
[JsonPropertyName("path_confidence")]
public required double PathConfidence { get; init; }
/// <summary>Whether this path is the highest-confidence path.</summary>
[JsonPropertyName("is_critical")]
public bool IsCritical { get; init; }
/// <summary>Human-readable description of this evidence chain.</summary>
[JsonPropertyName("description")]
public required string Description { get; init; }
}

View File

@@ -0,0 +1,272 @@
// -----------------------------------------------------------------------------
// ProofStudioService.cs
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
// Task: T2 - Integration service wiring proof graph + score breakdown
// Description: Orchestrates proof graph construction and score breakdown
// composition from existing policy engine data models.
// Bridges ScoreExplanation (Policy.Scoring) and VerdictRationale
// (Explainability) into the proof studio visualization models.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Integration surface for the Proof Studio UX.
/// Composes proof graphs and score breakdowns from existing
/// policy engine results.
/// </summary>
public interface IProofStudioService
{
/// <summary>
/// Builds a full proof studio view from a verdict rationale and
/// optional per-factor score explanation data.
/// </summary>
ProofStudioView Compose(ProofStudioRequest request);
/// <summary>
/// Applies a counterfactual scenario to an existing proof studio view,
/// returning a new view with the overlay applied.
/// </summary>
ProofStudioView ApplyCounterfactual(
ProofStudioView current,
CounterfactualScenario scenario);
}
/// <summary>
/// Request to compose a proof studio view.
/// </summary>
public sealed record ProofStudioRequest
{
/// <summary>Verdict rationale from the explainability module.</summary>
public required VerdictRationale Rationale { get; init; }
/// <summary>Per-factor score explanations from scoring engine.</summary>
public IReadOnlyList<ScoreFactorInput>? ScoreFactors { get; init; }
/// <summary>Composite score (0-100).</summary>
public int? CompositeScore { get; init; }
/// <summary>Action bucket label.</summary>
public string? ActionBucket { get; init; }
/// <summary>Guardrail applications, if any.</summary>
public IReadOnlyList<GuardrailInput>? Guardrails { get; init; }
/// <summary>Entropy value (0-1).</summary>
public double? Entropy { get; init; }
/// <summary>Whether manual review is required.</summary>
public bool NeedsReview { get; init; }
}
/// <summary>
/// Score factor input from the scoring engine.
/// </summary>
public sealed record ScoreFactorInput
{
/// <summary>Factor identifier (e.g., "reachability", "evidence").</summary>
public required string Factor { get; init; }
/// <summary>Raw factor value (0-100).</summary>
public required int Value { get; init; }
/// <summary>Weight applied to this factor (0-1).</summary>
public double Weight { get; init; }
/// <summary>Confidence in this factor's accuracy (0-1).</summary>
public double Confidence { get; init; } = 1.0;
/// <summary>Human-readable explanation.</summary>
public required string Reason { get; init; }
/// <summary>Whether this factor is subtractive.</summary>
public bool IsSubtractive { get; init; }
/// <summary>Contributing evidence digests.</summary>
public IReadOnlyList<string>? ContributingDigests { get; init; }
}
/// <summary>
/// Guardrail application input from the scoring engine.
/// </summary>
public sealed record GuardrailInput
{
public required string Name { get; init; }
public int ScoreBefore { get; init; }
public int ScoreAfter { get; init; }
public required string Reason { get; init; }
public IReadOnlyList<string>? Conditions { get; init; }
}
/// <summary>
/// Complete proof studio view combining graph and dashboard.
/// </summary>
public sealed record ProofStudioView
{
/// <summary>The proof graph DAG.</summary>
[JsonPropertyName("proof_graph")]
public required ProofGraph ProofGraph { get; init; }
/// <summary>The score breakdown dashboard.</summary>
[JsonPropertyName("score_breakdown")]
public ScoreBreakdownDashboard? ScoreBreakdown { get; init; }
/// <summary>When this view was composed.</summary>
[JsonPropertyName("composed_at")]
public required DateTimeOffset ComposedAt { get; init; }
}
/// <summary>
/// Default implementation of <see cref="IProofStudioService"/>.
/// </summary>
public sealed class ProofStudioService : IProofStudioService
{
private readonly IProofGraphBuilder _graphBuilder;
private readonly ILogger<ProofStudioService> _logger;
private readonly Counter<long> _viewsComposed;
private readonly Counter<long> _counterfactualsApplied;
public ProofStudioService(
IProofGraphBuilder graphBuilder,
ILogger<ProofStudioService> logger,
IMeterFactory meterFactory)
{
_graphBuilder = graphBuilder;
_logger = logger;
var meter = meterFactory.Create("StellaOps.Policy.Explainability.ProofStudio");
_viewsComposed = meter.CreateCounter<long>(
"stellaops.proofstudio.views_composed_total",
description: "Total proof studio views composed");
_counterfactualsApplied = meter.CreateCounter<long>(
"stellaops.proofstudio.counterfactuals_applied_total",
description: "Total counterfactual scenarios applied");
}
public ProofStudioView Compose(ProofStudioRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var now = DateTimeOffset.UtcNow;
// Build score breakdown dashboard if factor data is available
ScoreBreakdownDashboard? dashboard = null;
if (request.ScoreFactors is { Count: > 0 })
{
dashboard = BuildDashboard(request, now);
}
// Build proof graph
var graphInput = new ProofGraphInput
{
Rationale = request.Rationale,
ScoreBreakdown = dashboard,
ComputedAt = now
};
var proofGraph = _graphBuilder.Build(graphInput);
_viewsComposed.Add(1);
_logger.LogDebug(
"Composed proof studio view {GraphId} with {HasDashboard} dashboard",
proofGraph.GraphId, dashboard is not null);
return new ProofStudioView
{
ProofGraph = proofGraph,
ScoreBreakdown = dashboard,
ComposedAt = now
};
}
public ProofStudioView ApplyCounterfactual(
ProofStudioView current,
CounterfactualScenario scenario)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(scenario);
var overlayGraph = _graphBuilder.AddCounterfactualOverlay(
current.ProofGraph, scenario);
_counterfactualsApplied.Add(1);
_logger.LogDebug(
"Applied counterfactual '{Label}' to graph {GraphId}",
scenario.Label, current.ProofGraph.GraphId);
return current with
{
ProofGraph = overlayGraph,
ComposedAt = DateTimeOffset.UtcNow
};
}
// ── Private helpers ──────────────────────────────────────────────────
private static ScoreBreakdownDashboard BuildDashboard(
ProofStudioRequest request,
DateTimeOffset computedAt)
{
var factors = request.ScoreFactors!
.Select(f => new FactorContribution
{
FactorId = f.Factor,
FactorName = FormatFactorName(f.Factor),
RawScore = f.Value,
Weight = f.Weight,
Confidence = f.Confidence,
IsSubtractive = f.IsSubtractive,
EvidenceSource = f.ContributingDigests?.FirstOrDefault(),
Explanation = f.Reason
})
.ToImmutableArray();
var guardrails = (request.Guardrails ?? [])
.Select(g => new GuardrailApplication
{
GuardrailName = g.Name,
ScoreBefore = g.ScoreBefore,
ScoreAfter = g.ScoreAfter,
Reason = g.Reason,
Conditions = g.Conditions is not null
? [.. g.Conditions]
: []
})
.ToImmutableArray();
return new ScoreBreakdownDashboard
{
DashboardId = $"dash:{Guid.CreateVersion7():N}",
VerdictRef = request.Rationale.VerdictRef,
CompositeScore = request.CompositeScore ?? 0,
ActionBucket = request.ActionBucket ?? "Unknown",
Factors = factors,
GuardrailsApplied = guardrails,
PreGuardrailScore = request.CompositeScore ?? 0,
Entropy = request.Entropy ?? 0.0,
NeedsReview = request.NeedsReview,
ComputedAt = computedAt
};
}
private static string FormatFactorName(string factorId)
{
return factorId switch
{
"reachability" or "rch" => "Reachability",
"evidence" or "evd" => "Evidence",
"provenance" or "prv" => "Provenance",
"baseSeverity" or "sev" => "Base Severity",
"runtimeSignal" or "rts" => "Runtime Signal",
"mitigation" or "mit" => "Mitigation",
"exploit" or "exp" => "Exploit Maturity",
"temporal" or "tmp" => "Temporal",
_ => factorId.Length > 0
? char.ToUpperInvariant(factorId[0]) + factorId[1..]
: factorId
};
}
}

View File

@@ -0,0 +1,131 @@
// -----------------------------------------------------------------------------
// ScoreBreakdownDashboard.cs
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
// Task: T1 - Score breakdown dashboard data models
// Description: Per-factor score breakdown for dashboard visualization.
// Produces chart-ready data showing how each scoring dimension
// contributes to the final verdict score.
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Complete score breakdown for dashboard visualization, showing
/// per-factor contributions to the final verdict score.
/// </summary>
public sealed record ScoreBreakdownDashboard
{
/// <summary>Content-addressed dashboard identifier.</summary>
[JsonPropertyName("dashboard_id")]
public required string DashboardId { get; init; }
/// <summary>Reference to the verdict being broken down.</summary>
[JsonPropertyName("verdict_ref")]
public required VerdictReference VerdictRef { get; init; }
/// <summary>Overall composite score (0-100).</summary>
[JsonPropertyName("composite_score")]
public required int CompositeScore { get; init; }
/// <summary>Action bucket label (e.g., "Act Now", "Schedule Next").</summary>
[JsonPropertyName("action_bucket")]
public required string ActionBucket { get; init; }
/// <summary>Per-factor contribution breakdown for chart rendering.</summary>
[JsonPropertyName("factors")]
public required ImmutableArray<FactorContribution> Factors { get; init; }
/// <summary>Guardrails that were applied, if any.</summary>
[JsonPropertyName("guardrails_applied")]
public ImmutableArray<GuardrailApplication> GuardrailsApplied { get; init; } = [];
/// <summary>Score before guardrails were applied.</summary>
[JsonPropertyName("pre_guardrail_score")]
public int? PreGuardrailScore { get; init; }
/// <summary>Entropy level for determinization decisions.</summary>
[JsonPropertyName("entropy")]
public double? Entropy { get; init; }
/// <summary>Whether this verdict needs manual review based on entropy.</summary>
[JsonPropertyName("needs_review")]
public bool NeedsReview { get; init; }
/// <summary>When the breakdown was computed.</summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Individual factor contribution to the composite score.
/// </summary>
public sealed record FactorContribution
{
/// <summary>Factor identifier (e.g., "rch", "rts", "bkp").</summary>
[JsonPropertyName("factor_id")]
public required string FactorId { get; init; }
/// <summary>Human-readable factor name.</summary>
[JsonPropertyName("factor_name")]
public required string FactorName { get; init; }
/// <summary>Raw normalized score for this factor (0-100).</summary>
[JsonPropertyName("raw_score")]
public required int RawScore { get; init; }
/// <summary>Weight assigned to this factor (0.0-1.0).</summary>
[JsonPropertyName("weight")]
public required double Weight { get; init; }
/// <summary>Weighted contribution to composite score.</summary>
[JsonPropertyName("weighted_contribution")]
public double WeightedContribution => RawScore * Weight;
/// <summary>Confidence level for this factor (0.0-1.0).</summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Whether this is a subtractive factor (reduces risk).
/// </summary>
[JsonPropertyName("is_subtractive")]
public bool IsSubtractive { get; init; }
/// <summary>Source of the evidence for this factor.</summary>
[JsonPropertyName("evidence_source")]
public string? EvidenceSource { get; init; }
/// <summary>Human-readable explanation of the score.</summary>
[JsonPropertyName("explanation")]
public required string Explanation { get; init; }
/// <summary>Percentage of composite that this factor contributes.</summary>
[JsonPropertyName("percentage_of_total")]
public double PercentageOfTotal { get; init; }
}
/// <summary>
/// Record of a guardrail being applied to the score.
/// </summary>
public sealed record GuardrailApplication
{
/// <summary>Guardrail name (e.g., "notAffectedCap", "runtimeFloor").</summary>
[JsonPropertyName("guardrail_name")]
public required string GuardrailName { get; init; }
/// <summary>Score before this guardrail.</summary>
[JsonPropertyName("score_before")]
public required int ScoreBefore { get; init; }
/// <summary>Score after this guardrail.</summary>
[JsonPropertyName("score_after")]
public required int ScoreAfter { get; init; }
/// <summary>Human-readable reason the guardrail triggered.</summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>Conditions that caused the guardrail to fire.</summary>
[JsonPropertyName("conditions")]
public ImmutableArray<string> Conditions { get; init; } = [];
}

View File

@@ -7,6 +7,8 @@ public static class ExplainabilityServiceCollectionExtensions
public static IServiceCollection AddVerdictExplainability(this IServiceCollection services)
{
services.AddSingleton<IVerdictRationaleRenderer, VerdictRationaleRenderer>();
services.AddSingleton<IProofGraphBuilder, ProofGraphBuilder>();
services.AddSingleton<IProofStudioService, ProofStudioService>();
return services;
}
}