///|
/// Aggregated operational metrics contain no original text, only counts and
/// checksums suitable for dashboards.
pub(all) struct DeidAnalytics {
document_id : String
input_length : Int
output_length : Int
finding_count : Int
applied_count : Int
risk_score : Int
kind_counts : Map[String, Int]
risk_counts : Map[String, Int]
profile : String
checksum : String
} derive(Debug, Eq)
///|
pub(all) struct AnalyticsSummary {
documents : Int
total_input_length : Int
total_output_length : Int
total_findings : Int
total_applied : Int
average_risk : Float
kind_counts : Map[String, Int]
risk_counts : Map[String, Int]
checksum : String
} derive(Debug)
///|
pub(all) struct AnalyticsAccumulator {
mut records : Array[DeidAnalytics]
mut sequence : Int
} derive(Debug)
///|
pub fn analytics_from_result(
document_id : String,
result : DeidResult,
profile : String,
) -> DeidAnalytics {
let risk_score = result.findings.fold(init=0, (sum, finding) => {
let value = match risk_level(finding) {
RiskLevel::Low => 10
RiskLevel::Medium => 30
RiskLevel::High => 60
RiskLevel::Critical => 100
}
sum + value
})
let kind_counts : Map[String, Int] = Map([])
let risk_counts : Map[String, Int] = Map([])
for finding in result.findings {
let kind = phi_kind_name(finding.kind)
let risk = "\{risk_level(finding)}"
kind_counts[kind] = kind_counts.get_or_default(kind, 0) + 1
risk_counts[risk] = risk_counts.get_or_default(risk, 0) + 1
}
let checksum = stable_hash(document_id + ":" + result.text)
{
document_id,
input_length: result.audit.input_length,
output_length: result.audit.output_length,
finding_count: result.findings.length(),
applied_count: result.offsets.length(),
risk_score,
kind_counts,
risk_counts,
profile,
checksum,
}
}
///|
pub fn DeidAnalytics::redaction_percent(self : DeidAnalytics) -> Int {
if self.input_length == 0 {
0
} else {
self.applied_count * 100 / self.input_length
}
}
///|
pub fn DeidAnalytics::average_finding_size(self : DeidAnalytics) -> Float {
if self.finding_count == 0 {
0.0
} else {
Float::from_int(self.input_length - self.output_length) /
Float::from_int(self.finding_count)
}
}
///|
pub fn DeidAnalytics::has_critical(self : DeidAnalytics) -> Bool {
self.risk_counts.get_or_default("critical", 0) > 0
}
///|
pub fn DeidAnalytics::summary(self : DeidAnalytics) -> String {
[
"document_id=\{self.document_id}",
"profile=\{self.profile}",
"input_length=\{self.input_length}",
"output_length=\{self.output_length}",
"findings=\{self.finding_count}",
"applied=\{self.applied_count}",
"risk_score=\{self.risk_score}",
"critical=\{self.has_critical()}",
"checksum=\{self.checksum}",
].join("\n")
}
///|
pub fn DeidAnalytics::to_json(self : DeidAnalytics) -> String {
"{" +
"\"document_id\":\{json_escape(self.document_id)}," +
"\"profile\":\{json_escape(self.profile)}," +
"\"input_length\":\{self.input_length}," +
"\"output_length\":\{self.output_length}," +
"\"findings\":\{self.finding_count}," +
"\"applied\":\{self.applied_count}," +
"\"risk_score\":\{self.risk_score}," +
"\"critical\":\{self.has_critical()}," +
"\"checksum\":\{json_escape(self.checksum)}" +
"}"
}
///|
pub fn AnalyticsAccumulator::new() -> AnalyticsAccumulator {
{ records: [], sequence: 0 }
}
///|
pub fn AnalyticsAccumulator::add(
self : AnalyticsAccumulator,
value : DeidAnalytics,
) -> Unit {
self.records.push(value)
self.sequence += 1
}
///|
pub fn AnalyticsAccumulator::size(self : AnalyticsAccumulator) -> Int {
self.records.length()
}
///|
pub fn AnalyticsAccumulator::find(
self : AnalyticsAccumulator,
document_id : String,
) -> DeidAnalytics? {
let mut result : DeidAnalytics? = None
for record in self.records {
if record.document_id == document_id {
result = Some(record)
}
}
result
}
///|
pub fn AnalyticsAccumulator::profiles(
self : AnalyticsAccumulator,
) -> Array[String] {
let values : Map[String, Unit] = Map([])
for record in self.records {
values[record.profile] = ()
}
values.keys().to_array()
}
///|
pub fn AnalyticsAccumulator::summary(
self : AnalyticsAccumulator,
) -> AnalyticsSummary {
let kind_counts : Map[String, Int] = Map([])
let risk_counts : Map[String, Int] = Map([])
let mut input_length = 0
let mut output_length = 0
let mut findings = 0
let mut applied = 0
let mut risk = 0
for record in self.records {
input_length += record.input_length
output_length += record.output_length
findings += record.finding_count
applied += record.applied_count
risk += record.risk_score
merge_counts(kind_counts, record.kind_counts)
merge_counts(risk_counts, record.risk_counts)
}
{
documents: self.records.length(),
total_input_length: input_length,
total_output_length: output_length,
total_findings: findings,
total_applied: applied,
average_risk: if self.records.is_empty() {
0.0
} else {
Float::from_int(risk) / Float::from_int(self.records.length())
},
kind_counts,
risk_counts,
checksum: self.checksum(),
}
}
///|
pub fn AnalyticsAccumulator::checksum(self : AnalyticsAccumulator) -> String {
stable_hash(self.records.map(fn(record) { record.checksum }).join("\n"))
}
///|
pub fn AnalyticsAccumulator::critical_documents(
self : AnalyticsAccumulator,
) -> Array[String] {
self.records
.filter(DeidAnalytics::has_critical)
.map(fn(record) { record.document_id })
}
///|
pub fn AnalyticsAccumulator::high_volume_documents(
self : AnalyticsAccumulator,
threshold : Int,
) -> Array[String] {
self.records
.filter(fn(record) { record.finding_count >= threshold })
.map(fn(record) { record.document_id })
}
///|
pub fn AnalyticsAccumulator::risk_distribution(
self : AnalyticsAccumulator,
) -> Map[String, Int] {
let result : Map[String, Int] = Map([])
for record in self.records {
merge_counts(result, record.risk_counts)
}
result
}
///|
pub fn analytics_summary_text(summary : AnalyticsSummary) -> String {
[
"documents=\{summary.documents}",
"input=\{summary.total_input_length}",
"output=\{summary.total_output_length}",
"findings=\{summary.total_findings}",
"applied=\{summary.total_applied}",
"average_risk=\{summary.average_risk}",
"kinds=\{map_to_json(summary.kind_counts)}",
"risks=\{map_to_json(summary.risk_counts)}",
"checksum=\{summary.checksum}",
].join("\n")
}
///|
pub fn analytics_summary_json(summary : AnalyticsSummary) -> String {
"{" +
"\"documents\":\{summary.documents}," +
"\"total_input_length\":\{summary.total_input_length}," +
"\"total_output_length\":\{summary.total_output_length}," +
"\"total_findings\":\{summary.total_findings}," +
"\"total_applied\":\{summary.total_applied}," +
"\"average_risk\":\{summary.average_risk}," +
"\"kind_counts\":\{map_to_json(summary.kind_counts)}," +
"\"risk_counts\":\{map_to_json(summary.risk_counts)}," +
"\"checksum\":\{json_escape(summary.checksum)}" +
"}"
}
///|
pub fn analytics_kind_ratio(summary : AnalyticsSummary, kind : String) -> Float {
if summary.total_findings == 0 {
0.0
} else {
Float::from_int(summary.kind_counts.get_or_default(kind, 0)) /
Float::from_int(summary.total_findings)
}
}
///|
pub fn analytics_risk_ratio(summary : AnalyticsSummary, risk : String) -> Float {
if summary.total_findings == 0 {
0.0
} else {
Float::from_int(summary.risk_counts.get_or_default(risk, 0)) /
Float::from_int(summary.total_findings)
}
}
///|
pub fn analytics_reduction_percent(summary : AnalyticsSummary) -> Int {
if summary.total_input_length == 0 {
0
} else {
(summary.total_input_length - summary.total_output_length) *
100 /
summary.total_input_length
}
}
///|
pub fn analytics_average_findings(summary : AnalyticsSummary) -> Float {
if summary.documents == 0 {
0.0
} else {
Float::from_int(summary.total_findings) / Float::from_int(summary.documents)
}
}
///|
pub fn analytics_ready(summary : AnalyticsSummary) -> Bool {
summary.documents > 0 && summary.total_applied <= summary.total_findings
}
///|
pub fn AnalyticsAccumulator::to_json(self : AnalyticsAccumulator) -> String {
let summary = self.summary()
"{" +
"\"summary\":\{analytics_summary_json(summary)}," +
"\"records\":[" +
self.records.map(DeidAnalytics::to_json).join(",") +
"]}"
}
///|
pub fn AnalyticsAccumulator::to_csv(self : AnalyticsAccumulator) -> String {
let lines = [
"document_id,profile,input_length,output_length,findings,applied,risk_score,checksum",
]
for record in self.records {
lines.push(
[
csv_cell(record.document_id),
csv_cell(record.profile),
"\{record.input_length}",
"\{record.output_length}",
"\{record.finding_count}",
"\{record.applied_count}",
"\{record.risk_score}",
record.checksum,
].join(","),
)
}
lines.join("\n")
}
///|
pub fn analytics_merge(
left : AnalyticsAccumulator,
right : AnalyticsAccumulator,
) -> AnalyticsAccumulator {
let result = AnalyticsAccumulator::new()
for record in left.records {
result.add(record)
}
for record in right.records {
result.add(record)
}
result
}
///|
pub fn analytics_consistent(summary : AnalyticsSummary) -> Bool {
summary.total_applied <= summary.total_findings &&
summary.total_output_length >= 0 &&
summary.total_input_length >= 0
}
///|
pub fn analytics_record_checksum(
document_id : String,
output : String,
) -> String {
stable_hash(document_id + "\u{1f}" + output)
}