///|
/// Data minimization controls which structured fields are needed for a
/// downstream purpose before de-identification is applied.
pub(all) enum MinimizationAction {
KeepRequired
KeepOptional
RemoveField
RedactField
AggregateField
} derive(Debug, Eq)
///|
pub(all) struct MinimizationRule {
path_pattern : String
purpose : String
action : MinimizationAction
justification : String
priority : Int
} derive(Debug, Eq)
///|
pub(all) struct MinimizationPolicy {
purpose : String
rules : Array[MinimizationRule]
default_action : MinimizationAction
config : RedactionConfig
} derive(Debug, Eq)
///|
pub(all) struct MinimizationResult {
original : ResourceDocument
minimized : ResourceDocument
removed_paths : Array[String]
redacted_paths : Array[String]
aggregated_paths : Array[String]
issues : Array[String]
checksum : String
} derive(Debug)
///|
pub fn minimization_action_name(action : MinimizationAction) -> String {
match action {
KeepRequired => "keep_required"
KeepOptional => "keep_optional"
RemoveField => "remove"
RedactField => "redact"
AggregateField => "aggregate"
}
}
///|
pub fn minimization_rule(
path_pattern : String,
purpose : String,
action : MinimizationAction,
justification : String,
priority : Int,
) -> MinimizationRule {
{ path_pattern, purpose, action, justification, priority }
}
///|
pub fn MinimizationPolicy::new(
purpose : String,
rules : Array[MinimizationRule],
) -> MinimizationPolicy {
{
purpose,
rules,
default_action: RemoveField,
config: RedactionConfig::default(),
}
}
///|
pub fn MinimizationPolicy::default() -> MinimizationPolicy {
MinimizationPolicy::new("analytics", [
minimization_rule(
"Patient.name",
"analytics",
RedactField,
"retain linkage without identity",
100,
),
minimization_rule(
"Patient.identifier",
"analytics",
RemoveField,
"not needed",
100,
),
minimization_rule(
"Patient.birthDate",
"analytics",
AggregateField,
"age band only",
80,
),
minimization_rule(
"Patient.address",
"analytics",
RemoveField,
"not needed",
100,
),
minimization_rule(
"Observation.value",
"analytics",
KeepOptional,
"measurement required",
10,
),
minimization_rule(
"Observation.note",
"analytics",
RedactField,
"clinical context may be useful",
60,
),
])
}
///|
pub fn MinimizationPolicy::with_default(
policy : MinimizationPolicy,
action : MinimizationAction,
) -> MinimizationPolicy {
{ ..policy, default_action: action }
}
///|
pub fn MinimizationPolicy::with_config(
policy : MinimizationPolicy,
config : RedactionConfig,
) -> MinimizationPolicy {
{ ..policy, config, }
}
///|
pub fn MinimizationPolicy::rules_for_purpose(
self : MinimizationPolicy,
purpose : String,
) -> Array[MinimizationRule] {
self.rules.filter(fn(rule) { rule.purpose == purpose })
}
///|
pub fn MinimizationPolicy::action_for(
self : MinimizationPolicy,
path : String,
) -> MinimizationAction {
let mut selected : MinimizationRule? = None
for rule in self.rules {
if resource_path_matches(rule.path_pattern, path) {
match selected {
Some(previous) if previous.priority >= rule.priority => ()
_ => selected = Some(rule)
}
}
}
match selected {
Some(rule) => rule.action
None => self.default_action
}
}
///|
pub fn MinimizationPolicy::justification_for(
self : MinimizationPolicy,
path : String,
) -> String {
let mut selected : MinimizationRule? = None
for rule in self.rules {
if resource_path_matches(rule.path_pattern, path) {
match selected {
Some(previous) if previous.priority >= rule.priority => ()
_ => selected = Some(rule)
}
}
}
match selected {
Some(rule) => rule.justification
None => "default policy"
}
}
///|
pub fn MinimizationPolicy::is_well_formed(self : MinimizationPolicy) -> Bool {
self.purpose.trim().length() > 0 &&
self.rules.all(fn(rule) {
rule.path_pattern.trim().length() > 0 &&
rule.purpose.trim().length() > 0 &&
rule.justification.trim().length() > 0 &&
rule.priority >= 0
})
}
///|
pub fn MinimizationPolicy::checksum(self : MinimizationPolicy) -> String {
stable_hash(
self.purpose +
":" +
self.rules
.map(fn(rule) {
rule.path_pattern +
":" +
minimization_action_name(rule.action) +
":\{rule.priority}"
})
.join("\n"),
)
}
///|
pub fn aggregate_resource_value(field : ResourceField) -> String {
match field.kind {
Date =>
match parse_calendar_date(field.value) {
Some(date) => date.to_month()
None => "[DATE]"
}
PersonName => "[NAME]"
Address => "[ADDRESS]"
Phone => "[PHONE]"
Email => "[EMAIL]"
IdNumber | MedicalRecord | Insurance => "[ID]"
Organization => "[ORG]"
Custom(_) => "[VALUE]"
}
}
///|
pub fn apply_minimization_action(
field : ResourceField,
action : MinimizationAction,
config : RedactionConfig,
) -> ResourceField raise DeidError {
match action {
KeepRequired | KeepOptional => field
RemoveField => { ..field, value: "", action: Remove }
RedactField => resource_apply_action(field, ResourceAction::Redact, config)
AggregateField =>
{
..field,
value: aggregate_resource_value(field),
action: ResourceAction::Hash,
}
}
}
///|
pub fn minimize_resource(
document : ResourceDocument,
policy : MinimizationPolicy,
) -> MinimizationResult raise DeidError {
let minimized = ResourceDocument::empty(document.resource_type)
minimized.resource_id = document.resource_id
minimized.metadata = document.metadata.copy()
let removed = []
let redacted = []
let aggregated = []
let issues = []
for field in document.fields {
let action = policy.action_for(field.path)
let transformed = apply_minimization_action(field, action, policy.config)
match action {
RemoveField => removed.push(field.path)
RedactField => redacted.push(field.path)
AggregateField => aggregated.push(field.path)
_ => ()
}
if action != RemoveField || field.required {
minimized.fields.push(transformed)
}
if policy.justification_for(field.path).is_empty() {
issues.push("missing justification: \{field.path}")
}
}
{
original: document,
minimized,
removed_paths: removed,
redacted_paths: redacted,
aggregated_paths: aggregated,
issues,
checksum: stable_hash(resource_render(minimized)),
}
}
///|
pub fn MinimizationResult::changed(self : MinimizationResult) -> Bool {
resource_render(self.original) != resource_render(self.minimized)
}
///|
pub fn MinimizationResult::removed_count(self : MinimizationResult) -> Int {
self.removed_paths.length()
}
///|
pub fn MinimizationResult::redacted_count(self : MinimizationResult) -> Int {
self.redacted_paths.length()
}
///|
pub fn MinimizationResult::summary(self : MinimizationResult) -> String {
[
"resource_type=\{self.minimized.resource_type}",
"original_fields=\{self.original.field_count()}",
"minimized_fields=\{self.minimized.field_count()}",
"removed=\{self.removed_count()}",
"redacted=\{self.redacted_count()}",
"aggregated=\{self.aggregated_paths.length()}",
"issues=\{self.issues.length()}",
"changed=\{self.changed()}",
"checksum=\{self.checksum}",
].join("\n")
}
///|
pub fn MinimizationResult::to_json(self : MinimizationResult) -> String {
"{" +
"\"resource_type\":\{json_escape(self.minimized.resource_type)}," +
"\"original_fields\":\{self.original.field_count()}," +
"\"minimized_fields\":\{self.minimized.field_count()}," +
"\"removed_paths\":[" +
self.removed_paths.map(json_escape).join(",") +
"]," +
"\"redacted_paths\":[" +
self.redacted_paths.map(json_escape).join(",") +
"]," +
"\"aggregated_paths\":[" +
self.aggregated_paths.map(json_escape).join(",") +
"]," +
"\"issues\":[" +
self.issues.map(json_escape).join(",") +
"]," +
"\"checksum\":\{json_escape(self.checksum)}" +
"}"
}
///|
pub fn minimization_is_monotonic(
original : ResourceDocument,
minimized : ResourceDocument,
policy : MinimizationPolicy,
) -> Bool {
minimized.fields.all(fn(field) {
match original.get(field.path) {
Some(before) =>
policy.action_for(field.path) == KeepRequired ||
policy.action_for(field.path) == KeepOptional ||
before.value != field.value
None => false
}
})
}
///|
pub fn minimization_path_counts(
result : MinimizationResult,
) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for path in result.removed_paths {
ignore(path)
counts["removed"] = counts.get_or_default("removed", 0) + 1
}
for path in result.redacted_paths {
ignore(path)
counts["redacted"] = counts.get_or_default("redacted", 0) + 1
}
for path in result.aggregated_paths {
ignore(path)
counts["aggregated"] = counts.get_or_default("aggregated", 0) + 1
}
counts
}
///|
pub fn minimization_has_restricted_removal(
original : ResourceDocument,
result : MinimizationResult,
) -> Bool {
result.removed_paths.any(fn(path) {
match original.get(path) {
Some(field) => compliance_class_for_kind(field.kind) == Restricted
None => false
}
})
}
///|
pub fn minimization_report(
document : ResourceDocument,
policy : MinimizationPolicy,
) -> String raise DeidError {
let result = minimize_resource(document, policy)
[
result.summary(),
"policy=\{policy.purpose}",
"policy_checksum=\{policy.checksum()}",
"restricted_removal=\{minimization_has_restricted_removal(document, result)}",
"monotonic=\{minimization_is_monotonic(document, result.minimized, policy)}",
].join("\n")
}