///|
/// Declarative predicates for the two sides of a temporal relation.
pub(all) struct EventPattern {
category : EventCategory?
event_type : String?
action : String?
outcome : EventOutcome?
severity : Severity?
source_system : String?
source_component : String?
resource_kind : ResourceKind?
} derive(Debug, Eq, ToJson)
///|
/// A rule that relates a matching earlier event to a matching later event.
pub(all) struct CorrelationRule {
rule_id : String
from : EventPattern
to : EventPattern
within_seconds : Int
same_resource : Bool
relation_type : RelationType
explanation : String
} derive(Debug, Eq, ToJson)
///|
/// A declarative rule cannot be evaluated safely.
pub(all) suberror CorrelationRuleError {
InvalidRule(rule_id~ : String, reason~ : String)
} derive(Debug, Eq)
///|
fn validate_pattern(
rule_id : String,
pattern_name : String,
pattern : EventPattern,
) -> Unit raise CorrelationRuleError {
validate_pattern_string(
rule_id,
pattern_name,
"event_type",
pattern.event_type,
)
validate_pattern_string(rule_id, pattern_name, "action", pattern.action)
validate_pattern_string(
rule_id,
pattern_name,
"source_system",
pattern.source_system,
)
validate_pattern_string(
rule_id,
pattern_name,
"source_component",
pattern.source_component,
)
}
///|
fn validate_pattern_string(
rule_id : String,
pattern_name : String,
field_name : String,
value : String?,
) -> Unit raise CorrelationRuleError {
match value {
Some(value) =>
if value.trim() == "" {
raise CorrelationRuleError::InvalidRule(
rule_id~,
reason="\{pattern_name}.\{field_name} must not be empty",
)
}
None => ()
}
}
///|
/// Validates rule identifiers, patterns, and non-negative temporal windows.
pub fn validate_correlation_rules(
rules : Array[CorrelationRule],
) -> Unit raise CorrelationRuleError {
for index, rule in rules {
if rule.rule_id.trim() == "" {
raise CorrelationRuleError::InvalidRule(
rule_id=rule.rule_id,
reason="rule id must not be empty",
)
}
if rule.explanation.trim() == "" {
raise CorrelationRuleError::InvalidRule(
rule_id=rule.rule_id,
reason="explanation must not be empty",
)
}
if rule.within_seconds < 0 {
raise CorrelationRuleError::InvalidRule(
rule_id=rule.rule_id,
reason="within_seconds must not be negative",
)
}
validate_pattern(rule.rule_id, "from", rule.from)
validate_pattern(rule.rule_id, "to", rule.to)
for previous in 0.. Bool {
let category_matches = match pattern.category {
Some(expected) => event.category == expected
None => true
}
let event_type_matches = match pattern.event_type {
Some(expected) => event.event_type == expected.trim().to_owned()
None => true
}
let action_matches = match pattern.action {
Some(expected) => event.action == Some(expected.trim().to_owned())
None => true
}
let outcome_matches = match pattern.outcome {
Some(expected) => event.outcome == expected
None => true
}
let severity_matches = match pattern.severity {
Some(expected) => event.severity == Some(expected)
None => true
}
let source_system_matches = match pattern.source_system {
Some(expected) => event.source.system == expected.trim().to_owned()
None => true
}
let source_component_matches = match pattern.source_component {
Some(expected) => event.source.component == expected.trim().to_owned()
None => true
}
let resource_kind_matches = match pattern.resource_kind {
Some(expected) =>
match event.resource {
Some(resource) => resource.kind == expected
None => false
}
None => true
}
category_matches &&
event_type_matches &&
action_matches &&
outcome_matches &&
severity_matches &&
source_system_matches &&
source_component_matches &&
resource_kind_matches
}
///|
fn same_resource(left : CanonicalEvent, right : CanonicalEvent) -> Bool {
match (left.resource, right.resource) {
(Some(left_resource), Some(right_resource)) =>
left_resource.kind == right_resource.kind &&
left_resource.id == right_resource.id
_ => false
}
}
///|
fn non_negative_delta_seconds(
earlier : NormalizedTimestamp,
later : NormalizedTimestamp,
) -> Int {
(later.epoch_day - earlier.epoch_day) * 86400 +
later.second_of_day -
earlier.second_of_day
}
///|
fn add_rule_relations(
nodes : Array[CanonicalEvent],
rule : CorrelationRule,
edges : Array[EvidenceRelation],
) -> Unit {
for from_index, from_event in nodes {
if pattern_matches(rule.from, from_event) {
for to_index, to_event in nodes {
if from_index != to_index && pattern_matches(rule.to, to_event) {
match (from_event.event_time, to_event.event_time) {
(Some(from_time), Some(to_time)) => {
let order = compare_timestamps(from_time, to_time)
if (order < 0 || (order == 0 && from_index < to_index)) &&
non_negative_delta_seconds(from_time, to_time) <=
rule.within_seconds &&
(!rule.same_resource || same_resource(from_event, to_event)) {
let resource_key = if rule.same_resource {
from_event.resource
} else {
None
}
let delta_seconds = non_negative_delta_seconds(
from_time, to_time,
)
edges.push({
relation_id: "\{rule.rule_id}:\{from_index}:\{to_index}",
relation_type: rule.relation_type,
from_event_id: from_event.event_id,
to_event_id: to_event.event_id,
rule_id: Some(rule.rule_id),
resource_key,
delta_seconds: Some(delta_seconds),
evidence_refs: [from_event.provenance, to_event.provenance],
explanation: rule.explanation,
status: RelationStatus::Hypothesis,
})
}
}
_ => ()
}
}
}
}
}
}
///|
/// Evaluates rules in declaration order and returns an explainable incident graph.
///
/// A relation requires an earlier matching event, a later matching event, and
/// an inclusive temporal window. Same-resource rules require equal typed resource
/// identities and never infer a relation when either side lacks a resource.
pub fn evaluate_correlation_rules(
events : Array[CanonicalEvent],
rules : Array[CorrelationRule],
) -> IncidentGraph raise CorrelationRuleError {
validate_correlation_rules(rules)
let edges : Array[EvidenceRelation] = []
for rule in rules {
add_rule_relations(events, rule, edges)
}
{ nodes: events, edges, diagnostics: [], }
}