///|
pub fn finding_span(finding : Finding) -> Span {
{ start: finding.start, end: finding.end }
}
///|
pub fn finding_length(finding : Finding) -> Int {
finding.end - finding.start
}
///|
pub fn finding_is_valid(finding : Finding, input_length : Int) -> Bool {
finding_span(finding).is_valid(input_length) && !finding.text.is_empty()
}
///|
pub fn finding_score(finding : Finding) -> Int {
finding.confidence * 1000 + finding_length(finding)
}
///|
pub fn sort_findings(findings : Array[Finding]) -> Array[Finding] {
let sorted = findings.copy()
sorted.sort_by(fn(left : Finding, right : Finding) {
if left.start != right.start {
left.start - right.start
} else if left.confidence != right.confidence {
right.confidence - left.confidence
} else {
finding_length(right) - finding_length(left)
}
})
sorted
}
///|
pub fn sort_findings_by_priority(findings : Array[Finding]) -> Array[Finding] {
let sorted = findings.copy()
sorted.sort_by(fn(left : Finding, right : Finding) {
let score_difference = finding_score(right) - finding_score(left)
if score_difference != 0 {
score_difference
} else {
left.start - right.start
}
})
sorted
}
///|
pub fn unique_findings(findings : Array[Finding]) -> Array[Finding] {
let sorted = sort_findings(findings)
let result : Array[Finding] = []
for finding in sorted {
if !result.any(fn(existing) {
existing.start == finding.start &&
existing.end == finding.end &&
existing.kind == finding.kind
}) {
result.push(finding)
}
}
result
}
///|
pub fn merge_spans(spans : Array[Span]) -> Array[Span] {
let sorted = spans.filter(fn(item) { !item.is_empty() })
sorted.sort_by(fn(left, right) {
if left.start == right.start {
left.end - right.end
} else {
left.start - right.start
}
})
let result = []
for current in sorted {
match result.get(result.length() - 1) {
None => result.push(current)
Some(last) =>
if current.start <= last.end {
result[result.length() - 1] = {
start: last.start,
end: if current.end > last.end {
current.end
} else {
last.end
},
}
} else {
result.push(current)
}
}
}
result
}
///|
pub fn subtract_span(source : Span, blocked : Array[Span]) -> Array[Span] {
let result = [source]
let ordered = merge_spans(blocked)
for cut in ordered {
let next = []
for piece in result {
if !piece.overlaps(cut) {
next.push(piece)
} else {
if piece.start < cut.start {
next.push({ start: piece.start, end: cut.start })
}
if cut.end < piece.end {
next.push({ start: cut.end, end: piece.end })
}
}
}
result.clear()
result.append(next)
}
result.filter(fn(item) { !item.is_empty() })
}
///|
pub fn subtract_spans(
sources : Array[Span],
blocked : Array[Span],
) -> Array[Span] {
let result = []
for source in sources {
result.append(subtract_span(source, blocked))
}
merge_spans(result)
}
///|
pub fn finding_intersects_any(finding : Finding, spans : Array[Span]) -> Bool {
let target = finding_span(finding)
spans.any(fn(item) { target.overlaps(item) })
}
///|
pub fn filter_protected_findings(
findings : Array[Finding],
protected_ranges : Array[ProtectedRange],
) -> Array[Finding] {
let spans = protected_ranges.map(fn(item) { item.as_span() })
findings.filter(fn(item) { !finding_intersects_any(item, spans) })
}
///|
pub fn filter_findings_by_kind(
findings : Array[Finding],
kinds : Array[PhiKind],
) -> Array[Finding] {
if kinds.is_empty() {
findings
} else {
findings.filter(fn(item) { kinds.contains(item.kind) })
}
}
///|
pub fn filter_findings_by_confidence(
findings : Array[Finding],
threshold : Int,
) -> Array[Finding] {
findings.filter(fn(item) { item.confidence >= threshold })
}
///|
pub fn findings_in_span(
findings : Array[Finding],
window : Span,
) -> Array[Finding] {
findings.filter(fn(item) { window.contains_span(finding_span(item)) })
}
///|
pub fn findings_overlapping_span(
findings : Array[Finding],
window : Span,
) -> Array[Finding] {
findings.filter(fn(item) { window.overlaps(finding_span(item)) })
}
///|
pub fn findings_for_rule(
findings : Array[Finding],
rule_id : String,
) -> Array[Finding] {
findings.filter(fn(item) { item.rule_id == rule_id })
}
///|
pub fn findings_for_kind(
findings : Array[Finding],
kind : PhiKind,
) -> Array[Finding] {
findings.filter(fn(item) { item.kind == kind })
}
///|
pub fn first_finding(findings : Array[Finding]) -> Finding? {
sort_findings(findings).get(0)
}
///|
pub fn last_finding(findings : Array[Finding]) -> Finding? {
let sorted = sort_findings(findings)
if sorted.is_empty() {
None
} else {
sorted.get(sorted.length() - 1)
}
}
///|
pub fn total_finding_length(findings : Array[Finding]) -> Int {
let spans = findings.map(finding_span)
merge_spans(spans).fold(init=0, (total, item) => total + item.length())
}
///|
pub fn finding_density(findings : Array[Finding], input_length : Int) -> Float {
if input_length <= 0 {
0.0
} else {
Float::from_int(total_finding_length(findings)) /
Float::from_int(input_length)
}
}
///|
pub fn finding_context(
input : String,
finding : Finding,
radius : Int,
) -> TextWindow {
let center = (finding.start + finding.end) / 2
text_window(input, center, radius)
}
///|
pub fn shift_findings(
findings : Array[Finding],
amount : Int,
) -> Array[Finding] {
findings.map(fn(item) {
{
..item,
id: "\{item.id}@\{amount}",
start: item.start + amount,
end: item.end + amount,
}
})
}
///|
pub fn relabel_findings(
findings : Array[Finding],
label_prefix : String,
) -> Array[Finding] {
findings.map(fn(item) { { ..item, label: label_prefix + item.label } })
}
///|
pub fn apply_confidence_floor(
findings : Array[Finding],
floor : Int,
) -> Array[Finding] {
findings.map(fn(item) {
{
..item,
confidence: if item.confidence < floor {
floor
} else {
item.confidence
},
}
})
}
///|
pub fn finding_kind_counts(findings : Array[Finding]) -> Map[String, Int] {
let result : Map[String, Int] = Map([])
for item in findings {
let key = phi_kind_name(item.kind)
result[key] = result.get_or_default(key, 0) + 1
}
result
}
///|
pub fn finding_rule_counts(findings : Array[Finding]) -> Map[String, Int] {
let result : Map[String, Int] = Map([])
for item in findings {
result[item.rule_id] = result.get_or_default(item.rule_id, 0) + 1
}
result
}
///|
pub fn finding_risk_counts(findings : Array[Finding]) -> Map[String, Int] {
let result : Map[String, Int] = Map([])
for item in findings {
let key = "\{risk_level(item)}"
result[key] = result.get_or_default(key, 0) + 1
}
result
}
///|
pub fn finding_ids(findings : Array[Finding]) -> Array[String] {
findings.map(fn(item) { item.id })
}
///|
pub fn finding_texts(findings : Array[Finding]) -> Array[String] {
findings.map(fn(item) { item.text })
}
///|
pub fn finding_replacements(findings : Array[Finding]) -> Array[String] {
findings.map(fn(item) { item.replacement })
}
///|
pub fn validate_finding_order(findings : Array[Finding]) -> Bool {
let sorted = sort_findings(findings)
for i in 1.. sorted[i].start {
return false
}
}
true
}
///|
pub fn validate_finding_text(input : String, finding : Finding) -> Bool {
finding.start >= 0 &&
finding.end <= input.length() &&
finding.end > finding.start &&
input[finding.start:finding.end].to_owned() == finding.text
}
///|
pub fn validate_findings(
input : String,
findings : Array[Finding],
) -> Array[String] {
let issues = []
for item in findings {
if !finding_is_valid(item, input.length()) {
issues.push("\{item.id}: invalid span")
}
if !validate_finding_text(input, item) {
issues.push("\{item.id}: text mismatch")
}
}
if !validate_finding_order(findings) {
issues.push("findings overlap or are unsorted")
}
issues
}
///|
pub fn split_findings_by_risk(
findings : Array[Finding],
) -> (Array[Finding], Array[Finding], Array[Finding]) {
let critical = []
let high = []
let other = []
for item in findings {
match risk_level(item) {
Critical => critical.push(item)
High => high.push(item)
Medium | Low => other.push(item)
}
}
(critical, high, other)
}
///|
pub fn overlap_pairs(findings : Array[Finding]) -> Array[(String, String)] {
let pairs = []
for i in 0.. Finding? {
if findings.is_empty() {
None
} else {
let mut best = findings[0]
let mut best_distance = if position < best.start {
best.start - position
} else if position > best.end {
position - best.end
} else {
0
}
for item in findings[1:] {
let distance = if position < item.start {
item.start - position
} else if position > item.end {
position - item.end
} else {
0
}
if distance < best_distance {
best = item
best_distance = distance
}
}
Some(best)
}
}
///|
pub fn findings_to_spans(findings : Array[Finding]) -> Array[Span] {
findings.map(finding_span)
}
///|
pub fn span_summary(spans : Array[Span]) -> String {
let merged = merge_spans(spans)
let total = merged.fold(init=0, (sum, item) => sum + item.length())
"spans=\{spans.length()} merged=\{merged.length()} covered=\{total}"
}