This commit is contained in:
StellaOps Bot
2025-12-26 15:19:07 +02:00
25 changed files with 3377 additions and 132 deletions

View File

@@ -0,0 +1,180 @@
namespace StellaOps.AdvisoryAI.PolicyStudio;
/// <summary>
/// A generated lattice rule.
/// </summary>
public sealed record LatticeRule
{
/// <summary>
/// Unique rule ID.
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// Rule name for display.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Rule description.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// K4 lattice expression.
/// </summary>
public required string LatticeExpression { get; init; }
/// <summary>
/// Rule conditions in structured format.
/// </summary>
public required IReadOnlyList<PolicyCondition> Conditions { get; init; }
/// <summary>
/// Resulting disposition.
/// </summary>
public required string Disposition { get; init; }
/// <summary>
/// Rule priority.
/// </summary>
public required int Priority { get; init; }
/// <summary>
/// Scope of the rule.
/// </summary>
public required string Scope { get; init; }
/// <summary>
/// Whether rule is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
}
/// <summary>
/// Result of generating rules from intent.
/// </summary>
public sealed record RuleGenerationResult
{
/// <summary>
/// Generated rules.
/// </summary>
public required IReadOnlyList<LatticeRule> Rules { get; init; }
/// <summary>
/// Whether generation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Validation warnings.
/// </summary>
public required IReadOnlyList<string> Warnings { get; init; }
/// <summary>
/// Validation errors (if any).
/// </summary>
public IReadOnlyList<string>? Errors { get; init; }
/// <summary>
/// Source intent ID.
/// </summary>
public required string IntentId { get; init; }
/// <summary>
/// Generated timestamp.
/// </summary>
public required string GeneratedAt { get; init; }
}
/// <summary>
/// Rule validation result.
/// </summary>
public sealed record RuleValidationResult
{
/// <summary>
/// Whether rules are valid.
/// </summary>
public required bool Valid { get; init; }
/// <summary>
/// Detected conflicts.
/// </summary>
public required IReadOnlyList<RuleConflict> Conflicts { get; init; }
/// <summary>
/// Unreachable conditions.
/// </summary>
public required IReadOnlyList<string> UnreachableConditions { get; init; }
/// <summary>
/// Potential infinite loops.
/// </summary>
public required IReadOnlyList<string> PotentialLoops { get; init; }
/// <summary>
/// Coverage analysis.
/// </summary>
public required double Coverage { get; init; }
}
/// <summary>
/// A conflict between rules.
/// </summary>
public sealed record RuleConflict
{
/// <summary>
/// First conflicting rule ID.
/// </summary>
public required string RuleId1 { get; init; }
/// <summary>
/// Second conflicting rule ID.
/// </summary>
public required string RuleId2 { get; init; }
/// <summary>
/// Description of the conflict.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Suggested resolution.
/// </summary>
public required string SuggestedResolution { get; init; }
/// <summary>
/// Severity of conflict (warning, error).
/// </summary>
public required string Severity { get; init; }
}
/// <summary>
/// Service for generating lattice rules from policy intents.
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
/// Task: POLICY-05
/// </summary>
public interface IPolicyRuleGenerator
{
/// <summary>
/// Generate lattice rules from a policy intent.
/// </summary>
/// <param name="intent">Parsed policy intent.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Generated rules with validation status.</returns>
Task<RuleGenerationResult> GenerateAsync(
PolicyIntent intent,
CancellationToken cancellationToken = default);
/// <summary>
/// Validate a set of rules for conflicts and issues.
/// </summary>
/// <param name="rules">Rules to validate.</param>
/// <param name="existingRuleIds">Existing rule IDs to check against.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Validation result.</returns>
Task<RuleValidationResult> ValidateAsync(
IReadOnlyList<LatticeRule> rules,
IReadOnlyList<string>? existingRuleIds = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,170 @@
namespace StellaOps.AdvisoryAI.PolicyStudio;
/// <summary>
/// Type of synthesized test case.
/// </summary>
public enum TestCaseType
{
/// <summary>
/// Input that should match the rule (positive case).
/// </summary>
Positive,
/// <summary>
/// Input that should NOT match the rule (negative case).
/// </summary>
Negative,
/// <summary>
/// Input at boundary conditions.
/// </summary>
Boundary,
/// <summary>
/// Input that triggers multiple rules (conflict case).
/// </summary>
Conflict
}
/// <summary>
/// A synthesized test case for policy validation.
/// </summary>
public sealed record PolicyTestCase
{
/// <summary>
/// Unique test case ID.
/// </summary>
public required string TestCaseId { get; init; }
/// <summary>
/// Test case name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Type of test case.
/// </summary>
public required TestCaseType Type { get; init; }
/// <summary>
/// Input values for the test.
/// </summary>
public required IReadOnlyDictionary<string, object> Input { get; init; }
/// <summary>
/// Expected disposition/output.
/// </summary>
public required string ExpectedDisposition { get; init; }
/// <summary>
/// Rule IDs being tested.
/// </summary>
public required IReadOnlyList<string> TargetRuleIds { get; init; }
/// <summary>
/// Description of what the test validates.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Whether this is a generated or manual test.
/// </summary>
public bool Generated { get; init; } = true;
}
/// <summary>
/// Result of running policy test cases.
/// </summary>
public sealed record TestRunResult
{
/// <summary>
/// Total tests run.
/// </summary>
public required int Total { get; init; }
/// <summary>
/// Tests passed.
/// </summary>
public required int Passed { get; init; }
/// <summary>
/// Tests failed.
/// </summary>
public required int Failed { get; init; }
/// <summary>
/// Individual test results.
/// </summary>
public required IReadOnlyList<TestCaseResult> Results { get; init; }
/// <summary>
/// Overall success.
/// </summary>
public bool Success => Failed == 0;
/// <summary>
/// Run timestamp.
/// </summary>
public required string RunAt { get; init; }
}
/// <summary>
/// Result of a single test case.
/// </summary>
public sealed record TestCaseResult
{
/// <summary>
/// Test case ID.
/// </summary>
public required string TestCaseId { get; init; }
/// <summary>
/// Whether test passed.
/// </summary>
public required bool Passed { get; init; }
/// <summary>
/// Expected disposition.
/// </summary>
public required string Expected { get; init; }
/// <summary>
/// Actual disposition.
/// </summary>
public required string Actual { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Service for synthesizing policy test cases.
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
/// Task: POLICY-08
/// </summary>
public interface ITestCaseSynthesizer
{
/// <summary>
/// Generate test cases for a set of rules.
/// </summary>
/// <param name="rules">Rules to generate tests for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Generated test cases.</returns>
Task<IReadOnlyList<PolicyTestCase>> SynthesizeAsync(
IReadOnlyList<LatticeRule> rules,
CancellationToken cancellationToken = default);
/// <summary>
/// Run test cases against rules.
/// </summary>
/// <param name="testCases">Test cases to run.</param>
/// <param name="rules">Rules to test.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Test run results.</returns>
Task<TestRunResult> RunTestsAsync(
IReadOnlyList<PolicyTestCase> testCases,
IReadOnlyList<LatticeRule> rules,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,260 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.PolicyStudio;
/// <summary>
/// Generator for K4 lattice-compatible rules.
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
/// Task: POLICY-06
/// </summary>
public sealed class LatticeRuleGenerator : IPolicyRuleGenerator
{
public Task<RuleGenerationResult> GenerateAsync(
PolicyIntent intent,
CancellationToken cancellationToken = default)
{
var rules = new List<LatticeRule>();
var warnings = new List<string>();
// Generate rule ID
var ruleId = $"rule:{ComputeHash(intent.IntentId)[..12]}";
// Build lattice expression from conditions
var latticeExpr = BuildLatticeExpression(intent.Conditions);
// Determine disposition from actions
var disposition = DetermineDisposition(intent.Actions);
// Create the rule
var rule = new LatticeRule
{
RuleId = ruleId,
Name = GenerateRuleName(intent),
Description = intent.OriginalInput,
LatticeExpression = latticeExpr,
Conditions = intent.Conditions,
Disposition = disposition,
Priority = intent.Priority,
Scope = intent.Scope
};
rules.Add(rule);
// Add warnings for complex conditions
if (intent.Conditions.Count > 5)
{
warnings.Add("Rule has many conditions - consider splitting into multiple rules");
}
if (intent.Confidence < 0.9)
{
warnings.Add($"Intent confidence is {intent.Confidence:P0} - review generated rule carefully");
}
return Task.FromResult(new RuleGenerationResult
{
Rules = rules,
Success = true,
Warnings = warnings,
IntentId = intent.IntentId,
GeneratedAt = DateTime.UtcNow.ToString("O")
});
}
public Task<RuleValidationResult> ValidateAsync(
IReadOnlyList<LatticeRule> rules,
IReadOnlyList<string>? existingRuleIds = null,
CancellationToken cancellationToken = default)
{
var conflicts = new List<RuleConflict>();
var unreachable = new List<string>();
var loops = new List<string>();
// Check for conflicts between rules
for (int i = 0; i < rules.Count; i++)
{
for (int j = i + 1; j < rules.Count; j++)
{
var conflict = DetectConflict(rules[i], rules[j]);
if (conflict != null)
{
conflicts.Add(conflict);
}
}
}
// Check for unreachable conditions
foreach (var rule in rules)
{
if (HasUnreachableConditions(rule))
{
unreachable.Add($"Rule {rule.RuleId} has unreachable conditions");
}
}
// Check for potential loops (circular dependencies)
// In a real implementation, this would analyze rule dependencies
var coverage = CalculateCoverage(rules);
return Task.FromResult(new RuleValidationResult
{
Valid = conflicts.Count == 0 && unreachable.Count == 0 && loops.Count == 0,
Conflicts = conflicts,
UnreachableConditions = unreachable,
PotentialLoops = loops,
Coverage = coverage
});
}
private static string BuildLatticeExpression(IReadOnlyList<PolicyCondition> conditions)
{
if (conditions.Count == 0)
{
return "TRUE";
}
var parts = new List<string>();
foreach (var condition in conditions)
{
var atom = MapToAtom(condition);
parts.Add(atom);
}
// Join with lattice meet operator
return string.Join(" ∧ ", parts);
}
private static string MapToAtom(PolicyCondition condition)
{
// Map condition to K4 lattice atom
return condition.Field switch
{
"severity" => $"severity({condition.Value})",
"reachable" => condition.Value is true ? "Reachable" : "¬Reachable",
"has_vex" => condition.Value is true ? "HasVex" : "¬HasVex",
"vex_status" => $"VexStatus({condition.Value})",
"cvss_score" => $"CVSS {condition.Operator} {condition.Value}",
"epss_score" => $"EPSS {condition.Operator} {condition.Value}",
"scope" => $"Scope({condition.Value})",
_ => $"{condition.Field} {condition.Operator} {condition.Value}"
};
}
private static string DetermineDisposition(IReadOnlyList<PolicyAction> actions)
{
foreach (var action in actions)
{
if (action.ActionType == "set_verdict" &&
action.Parameters.TryGetValue("verdict", out var verdict))
{
return verdict?.ToString() ?? "unknown";
}
}
return actions.Count > 0 ? actions[0].ActionType : "pass";
}
private static string GenerateRuleName(PolicyIntent intent)
{
var prefix = intent.IntentType switch
{
PolicyIntentType.OverrideRule => "Override",
PolicyIntentType.EscalationRule => "Escalate",
PolicyIntentType.ExceptionCondition => "Exception",
PolicyIntentType.MergePrecedence => "Precedence",
PolicyIntentType.ThresholdRule => "Threshold",
PolicyIntentType.ScopeRestriction => "Scope",
_ => "Rule"
};
var suffix = intent.OriginalInput.Length > 30
? intent.OriginalInput[..27] + "..."
: intent.OriginalInput;
return $"{prefix}: {suffix}";
}
private static RuleConflict? DetectConflict(LatticeRule rule1, LatticeRule rule2)
{
// Check for overlapping conditions with different dispositions
if (rule1.Disposition != rule2.Disposition)
{
var overlap = FindConditionOverlap(rule1.Conditions, rule2.Conditions);
if (overlap > 0.5)
{
return new RuleConflict
{
RuleId1 = rule1.RuleId,
RuleId2 = rule2.RuleId,
Description = $"Rules have {overlap:P0} condition overlap but different dispositions",
SuggestedResolution = rule1.Priority > rule2.Priority
? $"Rule {rule1.RuleId} will take precedence"
: $"Rule {rule2.RuleId} will take precedence",
Severity = overlap > 0.8 ? "error" : "warning"
};
}
}
return null;
}
private static double FindConditionOverlap(
IReadOnlyList<PolicyCondition> conditions1,
IReadOnlyList<PolicyCondition> conditions2)
{
if (conditions1.Count == 0 || conditions2.Count == 0)
{
return 0;
}
var fields1 = conditions1.Select(c => c.Field).ToHashSet();
var fields2 = conditions2.Select(c => c.Field).ToHashSet();
var intersection = fields1.Intersect(fields2).Count();
var union = fields1.Union(fields2).Count();
return union > 0 ? (double)intersection / union : 0;
}
private static bool HasUnreachableConditions(LatticeRule rule)
{
// Check for contradictory conditions
var conditions = rule.Conditions.ToList();
for (int i = 0; i < conditions.Count; i++)
{
for (int j = i + 1; j < conditions.Count; j++)
{
if (conditions[i].Field == conditions[j].Field &&
conditions[i].Operator == "equals" &&
conditions[j].Operator == "equals" &&
!Equals(conditions[i].Value, conditions[j].Value))
{
return true; // Same field with different required values
}
}
}
return false;
}
private static double CalculateCoverage(IReadOnlyList<LatticeRule> rules)
{
// Estimate coverage based on rule conditions
var uniqueFields = rules
.SelectMany(r => r.Conditions)
.Select(c => c.Field)
.Distinct()
.Count();
// Simple heuristic: more fields covered = higher coverage
return Math.Min(1.0, uniqueFields * 0.1);
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -0,0 +1,318 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.PolicyStudio;
/// <summary>
/// Property-based test case synthesizer for policy validation.
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
/// Task: POLICY-09
/// </summary>
public sealed class PropertyBasedTestSynthesizer : ITestCaseSynthesizer
{
public Task<IReadOnlyList<PolicyTestCase>> SynthesizeAsync(
IReadOnlyList<LatticeRule> rules,
CancellationToken cancellationToken = default)
{
var testCases = new List<PolicyTestCase>();
foreach (var rule in rules)
{
// POLICY-10: Generate positive tests
testCases.AddRange(GeneratePositiveTests(rule));
// POLICY-11: Generate negative tests
testCases.AddRange(GenerateNegativeTests(rule));
// POLICY-12: Generate boundary tests
testCases.AddRange(GenerateBoundaryTests(rule));
}
// Generate conflict tests for overlapping rules
testCases.AddRange(GenerateConflictTests(rules));
return Task.FromResult<IReadOnlyList<PolicyTestCase>>(testCases);
}
public Task<TestRunResult> RunTestsAsync(
IReadOnlyList<PolicyTestCase> testCases,
IReadOnlyList<LatticeRule> rules,
CancellationToken cancellationToken = default)
{
var results = new List<TestCaseResult>();
foreach (var testCase in testCases)
{
var result = EvaluateTestCase(testCase, rules);
results.Add(result);
}
return Task.FromResult(new TestRunResult
{
Total = results.Count,
Passed = results.Count(r => r.Passed),
Failed = results.Count(r => !r.Passed),
Results = results,
RunAt = DateTime.UtcNow.ToString("O")
});
}
/// <summary>
/// Generate positive test cases (inputs that should match).
/// POLICY-10
/// </summary>
private static IEnumerable<PolicyTestCase> GeneratePositiveTests(LatticeRule rule)
{
var testId = $"test-pos-{ComputeHash(rule.RuleId)[..8]}";
// Create input that satisfies all conditions
var input = new Dictionary<string, object>();
foreach (var condition in rule.Conditions)
{
input[condition.Field] = condition.Value;
}
yield return new PolicyTestCase
{
TestCaseId = testId,
Name = $"Positive: {rule.Name}",
Type = TestCaseType.Positive,
Input = input,
ExpectedDisposition = rule.Disposition,
TargetRuleIds = new[] { rule.RuleId },
Description = $"Input satisfying all conditions should produce {rule.Disposition}"
};
}
/// <summary>
/// Generate negative test cases (inputs that should NOT match).
/// POLICY-11
/// </summary>
private static IEnumerable<PolicyTestCase> GenerateNegativeTests(LatticeRule rule)
{
var baseId = ComputeHash(rule.RuleId)[..8];
// For each condition, create a test that violates just that condition
int i = 0;
foreach (var condition in rule.Conditions)
{
var input = new Dictionary<string, object>();
// Satisfy all other conditions
foreach (var c in rule.Conditions)
{
input[c.Field] = c.Value;
}
// Violate this specific condition
input[condition.Field] = GetOppositeValue(condition);
yield return new PolicyTestCase
{
TestCaseId = $"test-neg-{baseId}-{i++}",
Name = $"Negative: {rule.Name} (violates {condition.Field})",
Type = TestCaseType.Negative,
Input = input,
ExpectedDisposition = "pass", // Default when rule doesn't match
TargetRuleIds = new[] { rule.RuleId },
Description = $"Violating {condition.Field} condition should not trigger rule"
};
}
}
/// <summary>
/// Generate boundary test cases.
/// </summary>
private static IEnumerable<PolicyTestCase> GenerateBoundaryTests(LatticeRule rule)
{
var baseId = ComputeHash(rule.RuleId)[..8];
int i = 0;
foreach (var condition in rule.Conditions)
{
// Generate boundary values for numeric conditions
if (condition.Operator is "greater_than" or "less_than" or ">" or "<")
{
var value = condition.Value;
if (value is double dv)
{
// Test at boundary
var input = new Dictionary<string, object>();
foreach (var c in rule.Conditions)
{
input[c.Field] = c.Value;
}
// Just at boundary
input[condition.Field] = dv;
yield return new PolicyTestCase
{
TestCaseId = $"test-bnd-{baseId}-{i++}",
Name = $"Boundary: {rule.Name} ({condition.Field}={dv})",
Type = TestCaseType.Boundary,
Input = input,
ExpectedDisposition = EvaluateBoundary(condition, dv) ? rule.Disposition : "pass",
TargetRuleIds = new[] { rule.RuleId },
Description = $"Testing boundary value for {condition.Field}"
};
// Just past boundary
var epsilon = 0.001;
var pastValue = condition.Operator is "greater_than" or ">" ? dv + epsilon : dv - epsilon;
input[condition.Field] = pastValue;
yield return new PolicyTestCase
{
TestCaseId = $"test-bnd-{baseId}-{i++}",
Name = $"Boundary: {rule.Name} ({condition.Field}={pastValue:F3})",
Type = TestCaseType.Boundary,
Input = input,
ExpectedDisposition = rule.Disposition,
TargetRuleIds = new[] { rule.RuleId },
Description = $"Testing past boundary value for {condition.Field}"
};
}
}
}
}
/// <summary>
/// Generate conflict test cases for overlapping rules.
/// POLICY-12
/// </summary>
private static IEnumerable<PolicyTestCase> GenerateConflictTests(IReadOnlyList<LatticeRule> rules)
{
for (int i = 0; i < rules.Count; i++)
{
for (int j = i + 1; j < rules.Count; j++)
{
var rule1 = rules[i];
var rule2 = rules[j];
// Check if rules could overlap
var commonFields = rule1.Conditions.Select(c => c.Field)
.Intersect(rule2.Conditions.Select(c => c.Field))
.ToList();
if (commonFields.Count > 0)
{
// Create input that could trigger both rules
var input = new Dictionary<string, object>();
foreach (var condition in rule1.Conditions)
{
input[condition.Field] = condition.Value;
}
foreach (var condition in rule2.Conditions)
{
if (!input.ContainsKey(condition.Field))
{
input[condition.Field] = condition.Value;
}
}
// Determine expected based on priority
var expectedDisposition = rule1.Priority >= rule2.Priority
? rule1.Disposition
: rule2.Disposition;
yield return new PolicyTestCase
{
TestCaseId = $"test-conflict-{ComputeHash(rule1.RuleId + rule2.RuleId)[..8]}",
Name = $"Conflict: {rule1.Name} vs {rule2.Name}",
Type = TestCaseType.Conflict,
Input = input,
ExpectedDisposition = expectedDisposition,
TargetRuleIds = new[] { rule1.RuleId, rule2.RuleId },
Description = $"Testing priority resolution between {rule1.RuleId} and {rule2.RuleId}"
};
}
}
}
}
private static object GetOppositeValue(PolicyCondition condition)
{
return condition.Value switch
{
bool b => !b,
string s when s == "critical" => "low",
string s when s == "high" => "low",
string s when s == "low" => "critical",
double d => d * -1,
int i => i * -1,
_ => "opposite_value"
};
}
private static bool EvaluateBoundary(PolicyCondition condition, double value)
{
// Boundary value typically doesn't satisfy strict comparison
return condition.Operator is ">=" or "<=" or "greater_than_or_equal" or "less_than_or_equal";
}
private static TestCaseResult EvaluateTestCase(PolicyTestCase testCase, IReadOnlyList<LatticeRule> rules)
{
// Find matching rules
var matchingRules = rules
.Where(r => testCase.TargetRuleIds.Contains(r.RuleId))
.Where(r => EvaluateConditions(r.Conditions, testCase.Input))
.OrderByDescending(r => r.Priority)
.ToList();
var actual = matchingRules.Count > 0
? matchingRules[0].Disposition
: "pass";
return new TestCaseResult
{
TestCaseId = testCase.TestCaseId,
Passed = actual == testCase.ExpectedDisposition,
Expected = testCase.ExpectedDisposition,
Actual = actual,
ErrorMessage = actual != testCase.ExpectedDisposition
? $"Expected {testCase.ExpectedDisposition} but got {actual}"
: null
};
}
private static bool EvaluateConditions(
IReadOnlyList<PolicyCondition> conditions,
IReadOnlyDictionary<string, object> input)
{
foreach (var condition in conditions)
{
if (!input.TryGetValue(condition.Field, out var value))
{
return false;
}
if (!EvaluateCondition(condition, value))
{
return false;
}
}
return true;
}
private static bool EvaluateCondition(PolicyCondition condition, object actualValue)
{
return condition.Operator switch
{
"equals" or "=" or "==" => Equals(condition.Value, actualValue),
"not_equals" or "!=" => !Equals(condition.Value, actualValue),
"greater_than" or ">" when actualValue is double d => d > Convert.ToDouble(condition.Value),
"less_than" or "<" when actualValue is double d => d < Convert.ToDouble(condition.Value),
"contains" when actualValue is string s => s.Contains(condition.Value?.ToString() ?? "", StringComparison.OrdinalIgnoreCase),
_ => Equals(condition.Value, actualValue)
};
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}