Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user