///|
/// The precision of a temporal value determines how much calendar detail can
/// safely be retained after de-identification.
pub(all) enum TemporalPrecision {
Day
Month
Year
Age
Unknown
} derive(Debug, Eq)
///|
/// A calendar date represented without a timezone. Clinical notes often
/// contain dates without a time zone, so shifting is deliberately calendar
/// based rather than timestamp based.
pub(all) struct CalendarDate {
year : Int
month : Int
day : Int
} derive(Debug, Eq)
///|
/// A deterministic date transformation configuration.
pub(all) struct TemporalPolicy {
salt : String
day_delta : Int
keep_year : Bool
keep_month : Bool
age_cutoff : Int
shift_ages : Bool
} derive(Debug, Eq)
///|
/// A date candidate with its original precision and transformed value.
pub(all) struct TemporalValue {
original : String
shifted : String
precision : TemporalPrecision
date : CalendarDate?
start : Int
end : Int
} derive(Debug, Eq)
///|
pub fn TemporalPolicy::default() -> TemporalPolicy {
{
salt: "moonbit-temporal",
day_delta: 0,
keep_year: true,
keep_month: true,
age_cutoff: 89,
shift_ages: true,
}
}
///|
pub fn temporal_policy(salt : String, day_delta : Int) -> TemporalPolicy {
{ ..TemporalPolicy::default(), salt, day_delta }
}
///|
pub fn temporal_precision_name(value : TemporalPrecision) -> String {
match value {
Day => "day"
Month => "month"
Year => "year"
Age => "age"
Unknown => "unknown"
}
}
///|
pub fn calendar_date(year~ : Int, month~ : Int, day~ : Int) -> CalendarDate {
{ year, month, day }
}
///|
pub fn CalendarDate::is_valid(self : CalendarDate) -> Bool {
self.year >= 1 &&
self.month >= 1 &&
self.month <= 12 &&
self.day >= 1 &&
self.day <= days_in_month(self.year, self.month)
}
///|
pub fn CalendarDate::is_leap(self : CalendarDate) -> Bool {
is_leap_year(self.year)
}
///|
pub fn CalendarDate::month_days(self : CalendarDate) -> Int {
days_in_month(self.year, self.month)
}
///|
pub fn CalendarDate::day_of_year(self : CalendarDate) -> Int {
if !self.is_valid() {
0
} else {
let mut total = self.day
for month in 1.. Int {
if self.month <= 0 {
0
} else {
(self.month - 1) / 3 + 1
}
}
///|
pub fn CalendarDate::weekday(self : CalendarDate) -> Int {
if !self.is_valid() {
-1
} else {
// Sakamoto's algorithm, with Sunday represented by zero.
let table = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
let y = if self.month < 3 { self.year - 1 } else { self.year }
(y + y / 4 - y / 100 + y / 400 + table[self.month - 1] + self.day) % 7
}
}
///|
pub fn CalendarDate::weekday_name(self : CalendarDate) -> String {
match self.weekday() {
0 => "Sunday"
1 => "Monday"
2 => "Tuesday"
3 => "Wednesday"
4 => "Thursday"
5 => "Friday"
6 => "Saturday"
_ => "Unknown"
}
}
///|
pub fn CalendarDate::to_iso(self : CalendarDate) -> String {
if !self.is_valid() {
""
} else {
pad_left("\{self.year}", 4, '0') +
"-" +
pad_left("\{self.month}", 2, '0') +
"-" +
pad_left("\{self.day}", 2, '0')
}
}
///|
pub fn CalendarDate::to_compact(self : CalendarDate) -> String {
if !self.is_valid() {
""
} else {
"\{self.year}\{pad_left("\{self.month}", 2, '0')}\{pad_left("\{self.day}", 2, '0')}"
}
}
///|
pub fn CalendarDate::to_chinese(self : CalendarDate) -> String {
if !self.is_valid() {
""
} else {
"\{self.year}年\{self.month}月\{self.day}日"
}
}
///|
pub fn CalendarDate::to_month(self : CalendarDate) -> String {
if !self.is_valid() {
""
} else {
"\{self.year}-\{pad_left("\{self.month}", 2, '0')}"
}
}
///|
pub fn calendar_date_order(left : CalendarDate, right : CalendarDate) -> Int {
if left.year != right.year {
left.year - right.year
} else if left.month != right.month {
left.month - right.month
} else {
left.day - right.day
}
}
///|
pub fn calendar_date_equal(left : CalendarDate, right : CalendarDate) -> Bool {
calendar_date_order(left, right) == 0
}
///|
pub fn calendar_date_before(left : CalendarDate, right : CalendarDate) -> Bool {
calendar_date_order(left, right) < 0
}
///|
pub fn calendar_date_after(left : CalendarDate, right : CalendarDate) -> Bool {
calendar_date_order(left, right) > 0
}
///|
fn date_separatorized(text : String) -> String {
let builder = StringBuilder()
for c in text {
if c.is_ascii_digit() {
builder.write_char(c)
} else if c == '-' ||
c == '/' ||
c == '.' ||
c == '年' ||
c == '月' ||
c == '日' ||
c == ' ' {
if !builder.is_empty() && !builder.to_string().has_suffix("-") {
builder.write_char('-')
}
}
}
builder.to_string().trim(chars="-").to_owned()
}
///|
pub fn parse_calendar_date(text : String) -> CalendarDate? {
let normalized = date_separatorized(text)
let parts = normalized.split("-").to_array()
if parts.length() < 3 {
None
} else {
let year = decimal_value(parts[0].to_owned())
let month = decimal_value(parts[1].to_owned())
let day = decimal_value(parts[2].to_owned())
let value = { year, month, day }
if value.is_valid() {
Some(value)
} else {
None
}
}
}
///|
pub fn parse_year_month(text : String) -> (Int, Int)? {
let normalized = date_separatorized(text)
let parts = normalized.split("-").to_array()
if parts.length() < 2 {
None
} else {
let year = decimal_value(parts[0].to_owned())
let month = decimal_value(parts[1].to_owned())
if year >= 1 && month >= 1 && month <= 12 {
Some((year, month))
} else {
None
}
}
}
///|
pub fn parse_age(text : String) -> Int? {
let normalized = text.trim().to_owned().to_lower()
let digits = numeric_prefix(normalized)
if digits.is_empty() {
None
} else {
let value = decimal_value(digits)
if value >= 0 && value <= 150 {
Some(value)
} else {
None
}
}
}
///|
pub fn date_like_precision(text : String) -> TemporalPrecision {
if parse_calendar_date(text) is Some(_) {
Day
} else if parse_year_month(text) is Some(_) {
Month
} else if text.to_array().all(fn(c) { c.is_ascii_digit() }) &&
text.length() == 4 {
Year
} else if parse_age(text) is Some(_) {
Age
} else {
Unknown
}
}
///|
pub fn date_distance(left : CalendarDate, right : CalendarDate) -> Int {
let mut distance = 0
let mut cursor = left
if calendar_date_equal(cursor, right) {
0
} else if calendar_date_before(cursor, right) {
while calendar_date_before(cursor, right) {
cursor = add_calendar_days(cursor, 1)
distance += 1
if distance > 2000000 {
break
}
}
distance
} else {
while calendar_date_after(cursor, right) {
cursor = add_calendar_days(cursor, -1)
distance -= 1
if distance < -2000000 {
break
}
}
distance
}
}
///|
pub fn add_calendar_days(date : CalendarDate, amount : Int) -> CalendarDate {
if !date.is_valid() || amount == 0 {
date
} else if amount > 0 {
let mut result = date
for _ in 0.. 1 {
result = { ..result, day: result.day - 1 }
} else if result.month > 1 {
let month = result.month - 1
result = {
year: result.year,
month,
day: days_in_month(result.year, month),
}
} else {
let year = result.year - 1
result = { year, month: 12, day: 31 }
}
}
result
}
}
///|
pub fn add_calendar_months(date : CalendarDate, amount : Int) -> CalendarDate {
if !date.is_valid() {
date
} else {
let total = date.year * 12 + date.month - 1 + amount
let year = total / 12
let month = total % 12 + 1
let day = if date.day > days_in_month(year, month) {
days_in_month(year, month)
} else {
date.day
}
{ year, month, day }
}
}
///|
pub fn add_calendar_years(date : CalendarDate, amount : Int) -> CalendarDate {
let year = date.year + amount
let day = if date.month == 2 && date.day == 29 && !is_leap_year(year) {
28
} else {
date.day
}
{ year, month: date.month, day }
}
///|
pub fn age_on_date(birth : CalendarDate, at : CalendarDate) -> Int {
if !birth.is_valid() || !at.is_valid() || calendar_date_before(at, birth) {
0
} else {
let mut age = at.year - birth.year
if at.month < birth.month || (at.month == birth.month && at.day < birth.day) {
age -= 1
}
age
}
}
///|
pub fn bucket_age(age : Int, cutoff : Int) -> String {
if age < 0 {
"unknown"
} else if age >= cutoff {
"90+"
} else if age < 1 {
"under-1"
} else if age < 5 {
"1-4"
} else if age < 13 {
"5-12"
} else if age < 18 {
"13-17"
} else if age < 30 {
"18-29"
} else if age < 45 {
"30-44"
} else if age < 65 {
"45-64"
} else {
"65-89"
}
}
///|
pub fn bucket_age_default(age : Int) -> String {
bucket_age(age, TemporalPolicy::default().age_cutoff)
}
///|
fn deterministic_day_delta(value : String, policy : TemporalPolicy) -> Int {
let token = stable_hash(policy.salt + "\u{1f}" + value)
let digits = token
.to_array()
.fold(init=0, (sum, c) => {
let v = if c.is_ascii_digit() { c.to_int() - '0'.to_int() } else { 0 }
sum + v
})
let span = if policy.day_delta < 0 {
-policy.day_delta
} else {
policy.day_delta
}
if span == 0 {
0
} else {
digits % (span * 2 + 1) - span
}
}
///|
pub fn shift_date_deterministic(
date : CalendarDate,
original : String,
policy : TemporalPolicy,
) -> CalendarDate {
let delta = if policy.day_delta == 0 {
0
} else {
deterministic_day_delta(original, policy)
}
add_calendar_days(date, delta)
}
///|
pub fn shift_date_with_delta(
date : CalendarDate,
delta : Int,
precision : TemporalPrecision,
) -> CalendarDate {
match precision {
Day => add_calendar_days(date, delta)
Month => add_calendar_months(date, delta / 30)
Year => add_calendar_years(date, delta / 365)
Age | Unknown => date
}
}
///|
pub fn shift_temporal_text(
original : String,
date : CalendarDate,
precision : TemporalPrecision,
policy : TemporalPolicy,
) -> String {
let shifted = shift_date_deterministic(date, original, policy)
match precision {
Day => shifted.to_iso()
Month => shifted.to_month()
Year => "\{shifted.year}"
Age => bucket_age_default(age_on_date(date, shifted))
Unknown => original
}
}
///|
pub fn temporal_value(
original : String,
start : Int,
end : Int,
policy : TemporalPolicy,
) -> TemporalValue {
let precision = date_like_precision(original)
let date = parse_calendar_date(original)
let shifted = match date {
Some(value) => shift_temporal_text(original, value, precision, policy)
None => original
}
{ original, shifted, precision, date, start, end }
}
///|
pub fn temporal_value_is_valid(
value : TemporalValue,
text_length : Int,
) -> Bool {
value.start >= 0 &&
value.end > value.start &&
value.end <= text_length &&
value.original.length() > 0 &&
value.shifted.length() > 0
}
///|
pub fn temporal_value_summary(value : TemporalValue) -> String {
[
"original=\{quote_for_log(value.original)}",
"shifted=\{quote_for_log(value.shifted)}",
"precision=\{temporal_precision_name(value.precision)}",
"span=\{value.start}..\{value.end}",
].join("\n")
}
///|
pub fn temporal_values_for_findings(
input : String,
findings : Array[Finding],
policy : TemporalPolicy,
) -> Array[TemporalValue] {
findings
.filter(fn(item) { item.kind == Date })
.map(fn(item) { temporal_value(item.text, item.start, item.end, policy) })
.filter(fn(item) { temporal_value_is_valid(item, input.length()) })
}
///|
pub fn temporal_findings(
input : String,
policy : TemporalPolicy,
) -> Array[Finding] raise DeidError {
let findings = scan(input).filter(fn(item) { item.kind == Date })
findings.map(fn(item) {
let value = temporal_value(item.text, item.start, item.end, policy)
{ ..item, replacement: value.shifted }
})
}
///|
pub fn shift_dates_in_text(
input : String,
policy : TemporalPolicy,
) -> String raise DeidError {
let findings = temporal_findings(input, policy)
let (text, _) = apply_findings(input, findings)
text
}
///|
pub fn shift_dates_with_audit(
input : String,
policy : TemporalPolicy,
) -> DeidResult raise DeidError {
let findings = temporal_findings(input, policy)
let (text, offsets) = apply_findings(input, findings)
{
text,
findings,
offsets,
audit: build_audit(input, text, findings, offsets),
}
}
///|
pub fn temporal_histogram(
input : String,
policy : TemporalPolicy,
) -> Map[String, Int] raise DeidError {
let result : Map[String, Int] = Map([])
for value in temporal_values_for_findings(input, scan(input), policy) {
let key = temporal_precision_name(value.precision)
result[key] = result.get_or_default(key, 0) + 1
}
result
}
///|
pub fn temporal_years(input : String) -> Array[Int] raise DeidError {
let years = []
for finding in scan(input).filter(fn(item) { item.kind == Date }) {
match parse_calendar_date(finding.text) {
Some(date) => if !years.contains(date.year) { years.push(date.year) }
None => ()
}
}
years.sort()
years
}
///|
pub fn temporal_date_spans(input : String) -> Array[Span] raise DeidError {
scan(input)
.filter(fn(item) { item.kind == Date })
.map(fn(item) { { start: item.start, end: item.end } })
}
///|
pub fn temporal_policy_summary(policy : TemporalPolicy) -> String {
[
"salt_checksum=\{stable_hash(policy.salt)}",
"day_delta=\{policy.day_delta}",
"keep_year=\{policy.keep_year}",
"keep_month=\{policy.keep_month}",
"age_cutoff=\{policy.age_cutoff}",
"shift_ages=\{policy.shift_ages}",
].join("\n")
}
///|
pub fn temporal_shift_is_deterministic(
input : String,
policy : TemporalPolicy,
) -> Bool raise DeidError {
shift_dates_in_text(input, policy) == shift_dates_in_text(input, policy)
}
///|
pub fn temporal_shift_preserves_non_dates(
input : String,
policy : TemporalPolicy,
) -> Bool raise DeidError {
let shifted = shift_dates_in_text(input, policy)
let findings = temporal_findings(input, policy)
let mut left = 0
for finding in findings {
if finding.start >= left {
if input[left:finding.start] != shifted[left:finding.start] {
return false
}
left = finding.end
}
}
input[left:] == shifted[left:]
}
///|
pub fn temporal_date_sequence(
start : CalendarDate,
count : Int,
step : Int,
) -> Array[CalendarDate] {
let result = []
if count > 0 && start.is_valid() {
let mut current = start
for _ in 0.. Bool {
if values.length() < 2 {
true
} else {
let mut result = true
for i in 1.. CalendarDate? {
if values.is_empty() {
None
} else {
Some(
values.fold(init=values[0], (best, item) => {
if calendar_date_before(item, best) {
item
} else {
best
}
}),
)
}
}
///|
pub fn temporal_max_date(values : Array[CalendarDate]) -> CalendarDate? {
if values.is_empty() {
None
} else {
Some(
values.fold(init=values[0], (best, item) => {
if calendar_date_after(item, best) {
item
} else {
best
}
}),
)
}
}
///|
pub fn temporal_range_text(values : Array[CalendarDate]) -> String {
match (temporal_min_date(values), temporal_max_date(values)) {
(Some(left), Some(right)) => "\{left.to_iso()}..\{right.to_iso()}"
_ => "empty"
}
}
///|
pub fn temporal_policy_for_tenant(tenant : String) -> TemporalPolicy {
let normalized = normalize_for_matching(tenant)
let digest = stable_hash(normalized)
let mut delta = 0
for c in digest {
if c.is_ascii_hexdigit() {
delta += c.to_int()
}
}
{
..TemporalPolicy::default(),
salt: "tenant:" + tenant,
day_delta: delta % 731 - 365,
}
}
///|
pub fn temporal_age_replacement(
age_text : String,
policy : TemporalPolicy,
) -> String {
match parse_age(age_text) {
Some(age) =>
if policy.shift_ages {
bucket_age(age, policy.age_cutoff)
} else {
age_text
}
None => age_text
}
}
///|
pub fn temporal_value_json(value : TemporalValue) -> String {
"{" +
"\"original\":\{json_escape(value.original)}," +
"\"shifted\":\{json_escape(value.shifted)}," +
"\"precision\":\{json_escape(temporal_precision_name(value.precision))}," +
"\"start\":\{value.start},\"end\":\{value.end}" +
"}"
}
///|
pub fn temporal_values_json(values : Array[TemporalValue]) -> String {
"[" + values.map(temporal_value_json).join(",") + "]"
}
///|
pub fn temporal_compliance_summary(
input : String,
policy : TemporalPolicy,
) -> String raise DeidError {
let values = temporal_values_for_findings(input, scan(input), policy)
[
"candidates=\{values.length()}",
"histogram=\{map_to_json(temporal_histogram(input, policy))}",
"deterministic=\{temporal_shift_is_deterministic(input, policy)}",
"non_date_stable=\{temporal_shift_preserves_non_dates(input, policy)}",
].join("\n")
}