partly or unimplemented features - now implemented
This commit is contained in:
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user