///|
/// Operational metrics for measuring de-identification quality and privacy
/// posture. These values are computed from pipeline artifacts rather than
/// declared as targets, so dashboards can distinguish observations from goals.
pub(all) enum PrivacyMetricKind {
MetricDocuments
MetricCharacters
MetricFindings
MetricRedactions
MetricReviews
MetricFailures
MetricLatencyMs
MetricOutputBytes
} derive(Debug, Eq)
///|
pub(all) enum MetricAggregation {
MetricSum
MetricAverage
MetricMinimum
MetricMaximum
MetricLast
} derive(Debug, Eq)
///|
pub(all) struct PrivacyMetricSample {
metric_id : String
batch_id : String
kind : PrivacyMetricKind
value : Int
unit : String
recorded_at : String
source_checksum : String
labels : Map[String, String]
} derive(Debug)
///|
pub(all) struct MetricWindow {
start_at : String
end_at : String
mut samples : Array[PrivacyMetricSample]
mut checksum : String
} derive(Debug)
///|
pub(all) struct PrivacyMetricSummary {
kind : PrivacyMetricKind
aggregation : MetricAggregation
count : Int
value : Int
minimum : Int
maximum : Int
source_checksum : String
} derive(Debug, Eq)
///|
pub fn privacy_metric_kind_name(kind : PrivacyMetricKind) -> String {
match kind {
MetricDocuments => "documents"
MetricCharacters => "characters"
MetricFindings => "findings"
MetricRedactions => "redactions"
MetricReviews => "reviews"
MetricFailures => "failures"
MetricLatencyMs => "latency_ms"
MetricOutputBytes => "output_bytes"
}
}
///|
pub fn metric_aggregation_name(aggregation : MetricAggregation) -> String {
match aggregation {
MetricSum => "sum"
MetricAverage => "average"
MetricMinimum => "minimum"
MetricMaximum => "maximum"
MetricLast => "last"
}
}
///|
pub fn privacy_metric_unit(kind : PrivacyMetricKind) -> String {
match kind {
MetricDocuments
| MetricFindings
| MetricRedactions
| MetricReviews
| MetricFailures => "count"
MetricCharacters | MetricOutputBytes => "bytes"
MetricLatencyMs => "milliseconds"
}
}
///|
pub fn privacy_metric_sample(
batch_id : String,
kind : PrivacyMetricKind,
value : Int,
recorded_at : String,
) -> PrivacyMetricSample {
let safe_value = if value < 0 { 0 } else { value }
{
metric_id: stable_hash(
batch_id + ":" + privacy_metric_kind_name(kind) + ":" + recorded_at,
),
batch_id,
kind,
value: safe_value,
unit: privacy_metric_unit(kind),
recorded_at,
source_checksum: "",
labels: Map([]),
}
}
///|
pub fn PrivacyMetricSample::with_source(
sample : PrivacyMetricSample,
source_checksum : String,
) -> PrivacyMetricSample {
{ ..sample, source_checksum, }
}
///|
pub fn PrivacyMetricSample::with_label(
sample : PrivacyMetricSample,
key : String,
value : String,
) -> PrivacyMetricSample {
sample.labels[key] = value
sample
}
///|
pub fn PrivacyMetricSample::is_valid(self : PrivacyMetricSample) -> Bool {
self.metric_id.length() > 0 &&
self.batch_id.length() > 0 &&
self.recorded_at.length() > 0 &&
self.value >= 0 &&
self.unit == privacy_metric_unit(self.kind)
}
///|
pub fn metric_window(start_at : String, end_at : String) -> MetricWindow {
{
start_at,
end_at,
samples: [],
checksum: stable_hash(start_at + ":" + end_at),
}
}
///|
pub fn MetricWindow::add(
self : MetricWindow,
sample : PrivacyMetricSample,
) -> Bool {
if sample.is_valid() {
self.samples.push(sample)
self.checksum = stable_hash(
self.samples.map(fn(item) { item.metric_id }).join("\n"),
)
true
} else {
false
}
}
///|
pub fn MetricWindow::for_kind(
self : MetricWindow,
kind : PrivacyMetricKind,
) -> Array[PrivacyMetricSample] {
self.samples.filter(fn(sample) { sample.kind == kind })
}
///|
pub fn MetricWindow::for_batch(
self : MetricWindow,
batch_id : String,
) -> Array[PrivacyMetricSample] {
self.samples.filter(fn(sample) { sample.batch_id == batch_id })
}
///|
pub fn MetricWindow::sample_count(self : MetricWindow) -> Int {
self.samples.length()
}
///|
pub fn MetricWindow::is_valid(self : MetricWindow) -> Bool {
self.start_at.length() > 0 &&
self.end_at.length() > 0 &&
self.samples.all(PrivacyMetricSample::is_valid)
}
///|
pub fn MetricWindow::batches(self : MetricWindow) -> Array[String] {
let seen : Map[String, Bool] = Map([])
let result : Array[String] = []
for sample in self.samples {
if !seen.contains(sample.batch_id) {
seen[sample.batch_id] = true
result.push(sample.batch_id)
}
}
result
}
///|
fn metric_minimum(values : Array[Int]) -> Int {
match values.get(0) {
None => 0
Some(first) =>
values.fold(init=first, (best, value) => {
if value < best {
value
} else {
best
}
})
}
}
///|
fn metric_maximum(values : Array[Int]) -> Int {
match values.get(0) {
None => 0
Some(first) =>
values.fold(init=first, (best, value) => {
if value > best {
value
} else {
best
}
})
}
}
///|
fn metric_total(values : Array[Int]) -> Int {
values.fold(init=0, (sum, value) => sum + value)
}
///|
pub fn summarize_metric(
samples : Array[PrivacyMetricSample],
kind : PrivacyMetricKind,
aggregation : MetricAggregation,
) -> PrivacyMetricSummary {
let selected = samples.filter(fn(sample) { sample.kind == kind })
let values = selected.map(fn(sample) { sample.value })
let count = values.length()
let total = metric_total(values)
let value = match aggregation {
MetricSum => total
MetricAverage => if count == 0 { 0 } else { total / count }
MetricMinimum => metric_minimum(values)
MetricMaximum => metric_maximum(values)
MetricLast =>
match values.get(count - 1) {
Some(item) => item
None => 0
}
}
{
kind,
aggregation,
count,
value,
minimum: metric_minimum(values),
maximum: metric_maximum(values),
source_checksum: stable_hash(
selected.map(fn(sample) { sample.source_checksum }).join("\n"),
),
}
}
///|
pub fn MetricWindow::summary(
self : MetricWindow,
kind : PrivacyMetricKind,
aggregation : MetricAggregation,
) -> PrivacyMetricSummary {
summarize_metric(self.samples, kind, aggregation)
}
///|
pub fn metric_rate(numerator : Int, denominator : Int) -> Int {
if denominator <= 0 || numerator <= 0 {
0
} else {
numerator * 100 / denominator
}
}
///|
pub fn detection_recall_proxy(detected : Int, reviewed_positive : Int) -> Int {
metric_rate(detected, reviewed_positive)
}
///|
pub fn review_precision_proxy(accepted : Int, reviewed : Int) -> Int {
metric_rate(accepted, reviewed)
}
///|
pub fn output_reduction_ratio(input_bytes : Int, output_bytes : Int) -> Int {
if input_bytes <= 0 || output_bytes >= input_bytes {
0
} else {
(input_bytes - output_bytes) * 100 / input_bytes
}
}
///|
pub fn privacy_budget_consumed(
redactions : Int,
high_risk_findings : Int,
manual_overrides : Int,
) -> Int {
let safe_redactions = if redactions < 0 { 0 } else { redactions }
let safe_high = if high_risk_findings < 0 { 0 } else { high_risk_findings }
let safe_overrides = if manual_overrides < 0 { 0 } else { manual_overrides }
safe_redactions + safe_high * 5 + safe_overrides * 10
}
///|
pub fn privacy_risk_score(
findings : Int,
unresolved : Int,
protected_skips : Int,
) -> Int {
let safe_findings = if findings < 0 { 0 } else { findings }
let safe_unresolved = if unresolved < 0 { 0 } else { unresolved }
let safe_skips = if protected_skips < 0 { 0 } else { protected_skips }
let denominator = if safe_findings == 0 { 1 } else { safe_findings }
let score = (safe_unresolved * 100 + safe_skips * 25) / denominator
if score > 100 {
100
} else {
score
}
}
///|
pub fn privacy_metric_health(
documents : Int,
failures : Int,
pending_reviews : Int,
latency_ms : Int,
) -> Int {
if documents <= 0 {
0
} else {
let failure_penalty = metric_rate(failures, documents)
let review_penalty = metric_rate(pending_reviews, documents)
let latency_penalty = if latency_ms > 10000 {
20
} else if latency_ms > 3000 {
10
} else {
0
}
let score = 100 - failure_penalty - review_penalty / 2 - latency_penalty
if score < 0 {
0
} else {
score
}
}
}
///|
pub fn window_metric_table(window : MetricWindow) -> Map[String, Int] {
let table : Map[String, Int] = Map([])
for
kind in [
MetricDocuments,
MetricCharacters,
MetricFindings,
MetricRedactions,
MetricReviews,
MetricFailures,
MetricLatencyMs,
MetricOutputBytes,
] {
let summary = window.summary(kind, MetricSum)
table[privacy_metric_kind_name(kind)] = summary.value
}
table
}
///|
pub fn MetricWindow::health(self : MetricWindow) -> Int {
let table = window_metric_table(self)
privacy_metric_health(
table.get_or_default("documents", 0),
table.get_or_default("failures", 0),
table.get_or_default("reviews", 0),
table.get_or_default("latency_ms", 0),
)
}
///|
pub fn MetricWindow::to_json(self : MetricWindow) -> String {
let table = window_metric_table(self)
"{" +
"\"start_at\":\"" +
json_escape(self.start_at) +
"\"," +
"\"end_at\":\"" +
json_escape(self.end_at) +
"\"," +
"\"samples\":" +
self.samples.length().to_string() +
"," +
"\"documents\":" +
table.get_or_default("documents", 0).to_string() +
"," +
"\"findings\":" +
table.get_or_default("findings", 0).to_string() +
"," +
"\"redactions\":" +
table.get_or_default("redactions", 0).to_string() +
"," +
"\"health\":" +
self.health().to_string() +
"," +
"\"checksum\":\"" +
json_escape(self.checksum) +
"\"}"
}
///|
pub fn merge_metric_windows(
left : MetricWindow,
right : MetricWindow,
) -> MetricWindow {
let merged = metric_window(left.start_at, right.end_at)
for sample in left.samples {
merged.samples.push(sample)
}
for sample in right.samples {
merged.samples.push(sample)
}
merged.checksum = stable_hash(
merged.samples.map(fn(item) { item.metric_id }).join("\n"),
)
merged
}
///|
pub fn metric_window_has_source(
window : MetricWindow,
source_checksum : String,
) -> Bool {
window.samples.any(fn(sample) { sample.source_checksum == source_checksum })
}
///|
pub fn metric_sample_keys(window : MetricWindow) -> Array[String] {
window.samples.map(fn(sample) {
sample.batch_id +
":" +
privacy_metric_kind_name(sample.kind) +
":" +
sample.recorded_at
})
}
///|
pub fn metric_window_is_monotonic(
before : MetricWindow,
after : MetricWindow,
) -> Bool {
after.samples.length() >= before.samples.length() &&
before.samples.all(fn(sample) {
metric_window_has_source(after, sample.source_checksum) ||
sample.source_checksum.is_empty()
})
}
///|
pub fn privacy_metrics_csv(window : MetricWindow) -> String {
let header = "metric_id,batch_id,kind,value,unit,recorded_at,source_checksum"
let rows = window.samples.map(fn(sample) {
[
sample.metric_id,
sample.batch_id,
privacy_metric_kind_name(sample.kind),
sample.value.to_string(),
sample.unit,
sample.recorded_at,
sample.source_checksum,
]
.map(metric_csv_escape)
.join(",")
})
([header] + rows).join("\n")
}
///|
fn metric_csv_escape(value : String) -> String {
if value.contains(",") || value.contains("\"") || value.contains("\n") {
"\"" + value.replace(old="\"", new="\"\"") + "\""
} else {
value
}
}