///|
/// Aggregate analysis of one scanned document.
pub(all) struct AnalysisReport {
text_length : Int
match_count : Int
distinct_label_count : Int
distinct_keyword_count : Int
total_weight : Int
covered_characters : Int
unmatched_characters : Int
coverage_percent : Int
overlap_count : Int
longest_span : Int
matches : Array[LexiconMatch]
} derive(Debug, Eq, ToJson)
///|
/// Summarize all matches emitted for a document.
pub fn analyze_text(automaton : Automaton, text : String) -> AnalysisReport {
analyze_matches(text.length(), automaton.find_all(text))
}
///|
/// Summarize an existing match array when scanning was performed elsewhere.
pub fn analyze_matches(
text_length : Int,
matches : ArrayView[LexiconMatch],
) -> AnalysisReport {
let normalized_length = if text_length < 0 { 0 } else { text_length }
let copy = matches.to_owned()
let labels : Map[String, Bool] = Map([])
let keywords : Map[String, Bool] = Map([])
let mut total_weight = 0
let mut longest = 0
for hit in copy {
labels[hit.label] = true
keywords[hit.keyword] = true
total_weight += hit.weight
if match_span_length(hit) > longest {
longest = match_span_length(hit)
}
}
let covered = covered_characters(normalized_length, copy)
{
text_length: normalized_length,
match_count: copy.length(),
distinct_label_count: labels.length(),
distinct_keyword_count: keywords.length(),
total_weight,
covered_characters: covered,
unmatched_characters: if normalized_length > covered {
normalized_length - covered
} else {
0
},
coverage_percent: percentage(covered, normalized_length),
overlap_count: overlap_count(copy),
longest_span: longest,
matches: copy,
}
}
///|
/// Count covered source characters using a union of match intervals.
pub fn covered_characters(
text_length : Int,
matches : ArrayView[LexiconMatch],
) -> Int {
let limit = if text_length < 0 { 0 } else { text_length }
let sorted = sort_matches_by_position(matches)
let mut covered = 0
let mut cursor = 0
for hit in sorted {
let start = clamp_analysis_int(hit.start, 0, limit)
let end = clamp_analysis_int(hit.end, start, limit)
if end > cursor {
if start > cursor {
covered += end - start
} else {
covered += end - cursor
}
cursor = end
}
}
covered
}
///|
/// Convert a numerator into a rounded percentage in the range 0..100.
pub fn percentage(numerator : Int, denominator : Int) -> Int {
if denominator <= 0 || numerator <= 0 {
0
} else if numerator >= denominator {
100
} else {
numerator * 100 / denominator
}
}
///|
/// Count pairwise overlapping match spans.
pub fn overlap_count(matches : ArrayView[LexiconMatch]) -> Int {
let mut count = 0
for left_index in 0.. Int {
if text_length <= 0 {
0
} else {
matches.length() * 100 / text_length
}
}
///|
/// A label-level aggregate for dashboards and audit reports.
pub(all) struct LabelSummary {
label : String
match_count : Int
total_weight : Int
maximum_weight : Int
covered_characters : Int
average_span : Int
} derive(Debug, Eq, ToJson)
///|
/// Group matches into deterministic label summaries.
pub fn label_summaries(
matches : ArrayView[LexiconMatch],
) -> Array[LabelSummary] {
let grouped : Map[String, Array[LexiconMatch]] = Map([])
for hit in matches {
match grouped.get(hit.label) {
Some(bucket) => bucket.push(hit)
None => grouped[hit.label] = [hit]
}
}
let output : Array[LabelSummary] = []
for label, bucket in grouped {
let mut total_weight = 0
let mut maximum_weight = 0
let mut total_span = 0
for hit in bucket {
total_weight += hit.weight
if hit.weight > maximum_weight || total_span == 0 {
maximum_weight = hit.weight
}
total_span += match_span_length(hit)
}
output.push({
label,
match_count: bucket.length(),
total_weight,
maximum_weight,
covered_characters: covered_characters(1000000000, bucket),
average_span: if bucket.length() == 0 {
0
} else {
total_span / bucket.length()
},
})
}
output.sort_by(fn(left, right) {
if left.match_count != right.match_count {
right.match_count - left.match_count
} else {
left.label.compare(right.label)
}
})
output
}
///|
/// A keyword-level aggregate for identifying noisy or high-value rules.
pub(all) struct KeywordSummary {
keyword : String
label : String
match_count : Int
total_weight : Int
first_position : Int
last_position : Int
} derive(Debug, Eq, ToJson)
///|
/// Summarize repeated keyword occurrences.
pub fn keyword_summaries(
matches : ArrayView[LexiconMatch],
) -> Array[KeywordSummary] {
let grouped : Map[String, KeywordSummary] = Map([])
for hit in matches {
let key = entry_key({
keyword: hit.keyword,
label: hit.label,
weight: hit.weight,
})
match grouped.get(key) {
Some(old) =>
grouped[key] = {
keyword: old.keyword,
label: old.label,
match_count: old.match_count + 1,
total_weight: old.total_weight + hit.weight,
first_position: if hit.start < old.first_position {
hit.start
} else {
old.first_position
},
last_position: if hit.end > old.last_position {
hit.end
} else {
old.last_position
},
}
None =>
grouped[key] = {
keyword: hit.keyword,
label: hit.label,
match_count: 1,
total_weight: hit.weight,
first_position: hit.start,
last_position: hit.end,
}
}
}
let output : Array[KeywordSummary] = []
for _, summary in grouped {
output.push(summary)
}
output.sort_by(fn(left, right) {
if left.match_count != right.match_count {
right.match_count - left.match_count
} else {
left.keyword.compare(right.keyword)
}
})
output
}
///|
/// Return the match with the largest span, if any.
pub fn longest_match(matches : ArrayView[LexiconMatch]) -> LexiconMatch? {
let mut best : LexiconMatch? = None
for hit in matches {
match best {
None => best = Some(hit)
Some(old) =>
if match_span_length(hit) > match_span_length(old) ||
(
match_span_length(hit) == match_span_length(old) &&
hit.weight > old.weight
) {
best = Some(hit)
}
}
}
best
}
///|
/// Return the most frequently emitted keyword, if any.
pub fn most_frequent_keyword(matches : ArrayView[LexiconMatch]) -> String? {
let summaries = keyword_summaries(matches)
match summaries.get(0) {
Some(summary) => Some(summary.keyword)
None => None
}
}
///|
/// Return source intervals that contain at least one match.
pub fn coverage_ranges(
text_length : Int,
matches : ArrayView[LexiconMatch],
) -> Array[(Int, Int)] {
let limit = if text_length < 0 { 0 } else { text_length }
let sorted = sort_matches_by_position(matches)
let ranges : Array[(Int, Int)] = []
for hit in sorted {
let start = clamp_analysis_int(hit.start, 0, limit)
let end = clamp_analysis_int(hit.end, start, limit)
if end > start {
match ranges.get(ranges.length() - 1) {
Some((old_start, old_end)) if start <= old_end =>
ranges[ranges.length() - 1] = (
old_start,
if end > old_end {
end
} else {
old_end
},
)
_ => ranges.push((start, end))
}
}
}
ranges
}
///|
/// Return gaps between covered intervals.
pub fn uncovered_ranges(
text_length : Int,
matches : ArrayView[LexiconMatch],
) -> Array[(Int, Int)] {
let output : Array[(Int, Int)] = []
let limit = if text_length < 0 { 0 } else { text_length }
let cursor_ranges = coverage_ranges(limit, matches)
let mut cursor = 0
for range in cursor_ranges {
let start = range.0
let end = range.1
if start > cursor {
output.push((cursor, start))
}
cursor = end
}
if cursor < limit {
output.push((cursor, limit))
}
output
}
///|
/// Render label summaries as a stable TSV report.
pub fn label_report_tsv(matches : ArrayView[LexiconMatch]) -> String {
let output = StringBuilder()
output.write_string(
"label\tmatches\ttotal_weight\tmaximum_weight\taverage_span\n",
)
for summary in label_summaries(matches) {
output.write_string(
"\{summary.label}\t\{summary.match_count}\t\{summary.total_weight}\t\{summary.maximum_weight}\t\{summary.average_span}\n",
)
}
output.to_string()
}
///|
/// Return a histogram of match span lengths.
pub fn span_histogram(matches : ArrayView[LexiconMatch]) -> Map[Int, Int] {
let histogram : Map[Int, Int] = Map([])
for hit in matches {
let span = match_span_length(hit)
histogram[span] = histogram.get_or_default(span, 0) + 1
}
histogram
}
///|
/// Calculate the arithmetic mean span length.
pub fn average_span(matches : ArrayView[LexiconMatch]) -> Int {
if matches.length() == 0 {
0
} else {
let mut total = 0
for hit in matches {
total += match_span_length(hit)
}
total / matches.length()
}
}
///|
/// Return the median span length, using lower-middle selection for even input.
pub fn median_span(matches : ArrayView[LexiconMatch]) -> Int {
if matches.length() == 0 {
0
} else {
let spans = [ for hit in matches => match_span_length(hit) ]
spans.sort_by((left, right) => left - right)
spans[(spans.length() - 1) / 2]
}
}
///|
/// Return the count of distinct labels in a match array.
pub fn distinct_label_count(matches : ArrayView[LexiconMatch]) -> Int {
let labels : Map[String, Bool] = Map([])
for hit in matches {
labels[hit.label] = true
}
labels.length()
}
///|
fn clamp_analysis_int(value : Int, minimum : Int, maximum : Int) -> Int {
if value < minimum {
minimum
} else if value > maximum {
maximum
} else {
value
}
}