///|
/// Input normalization is explicit so audit records can explain every
/// transformation before detection.
pub(all) enum NormalizationMode {
NoNormalization
MatchingInput
ClinicalInput
StrictInput
} derive(Debug, Eq)
///|
pub(all) struct NormalizationProfile {
name : String
mode : NormalizationMode
trim_input : Bool
collapse_space : Bool
normalize_punctuation : Bool
normalize_line_endings : Bool
remove_control : Bool
preserve_newlines : Bool
} derive(Debug, Eq)
///|
pub(all) struct NormalizationChange {
code : String
start : Int
end : Int
before : String
after : String
} derive(Debug, Eq)
///|
pub(all) struct NormalizedDocument {
original : String
normalized : String
profile : NormalizationProfile
changes : Array[NormalizationChange]
checksum : String
} derive(Debug, Eq)
///|
pub fn normalization_mode_name(mode : NormalizationMode) -> String {
match mode {
NoNormalization => "none"
MatchingInput => "matching"
ClinicalInput => "clinical"
StrictInput => "strict"
}
}
///|
pub fn NormalizationProfile::none() -> NormalizationProfile {
{
name: "none",
mode: NoNormalization,
trim_input: false,
collapse_space: false,
normalize_punctuation: false,
normalize_line_endings: false,
remove_control: false,
preserve_newlines: true,
}
}
///|
pub fn NormalizationProfile::matching() -> NormalizationProfile {
{
name: "matching",
mode: MatchingInput,
trim_input: true,
collapse_space: true,
normalize_punctuation: true,
normalize_line_endings: true,
remove_control: true,
preserve_newlines: true,
}
}
///|
pub fn NormalizationProfile::clinical() -> NormalizationProfile {
{
..NormalizationProfile::matching(),
name: "clinical",
mode: ClinicalInput,
collapse_space: false,
preserve_newlines: true,
}
}
///|
pub fn NormalizationProfile::strict() -> NormalizationProfile {
{
..NormalizationProfile::matching(),
name: "strict",
mode: StrictInput,
preserve_newlines: false,
}
}
///|
pub fn normalization_profile_for_mode(
mode : NormalizationMode,
) -> NormalizationProfile {
match mode {
NoNormalization => NormalizationProfile::none()
MatchingInput => NormalizationProfile::matching()
ClinicalInput => NormalizationProfile::clinical()
StrictInput => NormalizationProfile::strict()
}
}
///|
pub fn is_control_character(c : Char) -> Bool {
let value = c.to_int()
value < 32 && c != '\n' && c != '\r' && c != '\t'
}
///|
pub fn remove_control_characters(text : String) -> String {
let builder = StringBuilder()
for c in text {
if !is_control_character(c) {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalize_line_endings(
text : String,
preserve_newlines : Bool,
) -> String {
let builder = StringBuilder()
let mut previous_cr = false
for c in text {
if c == '\r' {
if preserve_newlines {
builder.write_char('\n')
}
previous_cr = true
} else if c == '\n' {
if !previous_cr && preserve_newlines {
builder.write_char('\n')
}
previous_cr = false
} else {
builder.write_char(c)
previous_cr = false
}
}
builder.to_string()
}
///|
pub fn normalize_fullwidth_digits(text : String) -> String {
let builder = StringBuilder()
for c in text {
let value = c.to_int()
if value >= '0'.to_int() && value <= '9'.to_int() {
builder.write_char(
(value - '0'.to_int() + '0'.to_int()).to_char().unwrap(),
)
} else {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalize_fullwidth_letters(text : String) -> String {
let builder = StringBuilder()
for c in text {
let value = c.to_int()
if value >= 'A'.to_int() && value <= 'Z'.to_int() {
builder.write_char(
(value - 'A'.to_int() + 'A'.to_int()).to_char().unwrap(),
)
} else if value >= 'a'.to_int() && value <= 'z'.to_int() {
builder.write_char(
(value - 'a'.to_int() + 'a'.to_int()).to_char().unwrap(),
)
} else {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalize_width(text : String) -> String {
normalize_fullwidth_letters(normalize_fullwidth_digits(text))
}
///|
pub fn normalize_clinical_text(text : String) -> String {
let width = normalize_width(text)
let punctuation = normalize_punctuation(width)
let lines = normalize_line_endings(punctuation, true)
remove_control_characters(lines)
}
///|
pub fn normalize_for_profile(
text : String,
profile : NormalizationProfile,
) -> String {
let mut result = text
if profile.normalize_line_endings {
result = normalize_line_endings(result, profile.preserve_newlines)
}
if profile.remove_control {
result = remove_control_characters(result)
}
result = normalize_width(result)
if profile.normalize_punctuation {
result = normalize_punctuation(result)
}
if profile.collapse_space {
result = collapse_spaces(result)
}
if profile.trim_input {
result = result.trim().to_owned()
}
result
}
///|
pub fn normalization_changes(
original : String,
normalized : String,
) -> Array[NormalizationChange] {
if original == normalized {
[]
} else {
[
{
code: "NORMALIZED",
start: 0,
end: original.length(),
before: original,
after: normalized,
},
]
}
}
///|
pub fn normalize_document(
text : String,
profile : NormalizationProfile,
) -> NormalizedDocument {
let normalized = normalize_for_profile(text, profile)
{
original: text,
normalized,
profile,
changes: normalization_changes(text, normalized),
checksum: stable_hash(normalized),
}
}
///|
pub fn NormalizedDocument::changed(self : NormalizedDocument) -> Bool {
self.original != self.normalized
}
///|
pub fn NormalizedDocument::change_count(self : NormalizedDocument) -> Int {
self.changes.length()
}
///|
pub fn NormalizedDocument::summary(self : NormalizedDocument) -> String {
[
"profile=\{self.profile.name}",
"mode=\{normalization_mode_name(self.profile.mode)}",
"original_length=\{self.original.length()}",
"normalized_length=\{self.normalized.length()}",
"changed=\{self.changed()}",
"checksum=\{self.checksum}",
].join("\n")
}
///|
pub fn normalization_change_json(change : NormalizationChange) -> String {
"{" +
"\"code\":\{json_escape(change.code)}," +
"\"start\":\{change.start}," +
"\"end\":\{change.end}," +
"\"before\":\{json_escape(change.before)}," +
"\"after\":\{json_escape(change.after)}" +
"}"
}
///|
pub fn NormalizedDocument::to_json(self : NormalizedDocument) -> String {
"{" +
"\"profile\":\{json_escape(self.profile.name)}," +
"\"mode\":\{json_escape(normalization_mode_name(self.profile.mode))}," +
"\"original_length\":\{self.original.length()}," +
"\"normalized_length\":\{self.normalized.length()}," +
"\"checksum\":\{json_escape(self.checksum)}," +
"\"changes\":[" +
self.changes.map(normalization_change_json).join(",") +
"]}"
}
///|
pub fn normalization_is_idempotent(
text : String,
profile : NormalizationProfile,
) -> Bool {
let once = normalize_for_profile(text, profile)
normalize_for_profile(once, profile) == once
}
///|
pub fn normalization_preserves_char_count(
original : String,
normalized : String,
) -> Bool {
original.char_length() == normalized.char_length()
}
///|
pub fn normalization_preserves_newline_count(
original : String,
normalized : String,
) -> Bool {
count_char(original, '\n') + count_char(original, '\r') ==
count_char(normalized, '\n')
}
///|
pub fn normalization_safe_for_offsets(profile : NormalizationProfile) -> Bool {
!profile.normalize_punctuation &&
!profile.collapse_space &&
!profile.remove_control &&
profile.preserve_newlines
}
///|
pub fn normalization_profile_summary(profile : NormalizationProfile) -> String {
[
"name=\{profile.name}",
"mode=\{normalization_mode_name(profile.mode)}",
"trim=\{profile.trim_input}",
"collapse_space=\{profile.collapse_space}",
"punctuation=\{profile.normalize_punctuation}",
"line_endings=\{profile.normalize_line_endings}",
"remove_control=\{profile.remove_control}",
"preserve_newlines=\{profile.preserve_newlines}",
].join("\n")
}
///|
pub fn text_has_mixed_width(text : String) -> Bool {
let mut ascii = false
let mut full = false
for c in text {
let value = c.to_int()
if c.is_ascii_digit() || c.is_ascii_alphabetic() {
ascii = true
}
if (value >= '0'.to_int() && value <= '9'.to_int()) ||
(value >= 'A'.to_int() && value <= 'Z'.to_int()) ||
(value >= 'a'.to_int() && value <= 'z'.to_int()) {
full = true
}
}
ascii && full
}
///|
pub fn text_has_control_characters(text : String) -> Bool {
text.to_array().any(is_control_character)
}
///|
pub fn text_has_noncanonical_punctuation(text : String) -> Bool {
text
.to_array()
.any(fn(c) {
c == ':' ||
c == ',' ||
c == ';' ||
c == '(' ||
c == ')' ||
c == '[' ||
c == ']' ||
c == '—' ||
c == '–' ||
c == '/'
})
}
///|
pub fn normalization_risk_flags(text : String) -> Array[String] {
let flags = []
if text_has_mixed_width(text) {
flags.push("mixed_width")
}
if text_has_control_characters(text) {
flags.push("control_characters")
}
if text_has_noncanonical_punctuation(text) {
flags.push("noncanonical_punctuation")
}
if text.contains("\r\n") {
flags.push("crlf")
}
flags
}
///|
pub fn normalization_risk_score(text : String) -> Int {
normalization_risk_flags(text).length() * 25
}
///|
pub fn normalization_requires_review(text : String) -> Bool {
normalization_risk_score(text) >= 50
}
///|
pub fn normalization_replace_char(
text : String,
from : Char,
to : Char,
) -> String {
let builder = StringBuilder()
for c in text {
if c == from {
builder.write_char(to)
} else {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalization_remove_chars(text : String, chars : Array[Char]) -> String {
let builder = StringBuilder()
for c in text {
if !chars.contains(c) {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalization_keep_chars(text : String, chars : Array[Char]) -> String {
let builder = StringBuilder()
for c in text {
if chars.contains(c) {
builder.write_char(c)
}
}
builder.to_string()
}
///|
pub fn normalization_line_lengths(text : String) -> Array[Int] {
split_lines_with_offsets(text).map(fn(line) { line.text.length() })
}
///|
pub fn normalization_longest_line(text : String) -> Int {
normalization_line_lengths(text).fold(init=0, (best, item) => {
if item > best {
item
} else {
best
}
})
}
///|
pub fn normalization_blank_line_count(text : String) -> Int {
split_lines_with_offsets(text)
.filter(fn(line) { line.text.trim().is_empty() })
.length()
}
///|
pub fn normalization_word_count(text : String) -> Int {
text
.split(" ")
.to_array()
.filter(fn(item) { !item.trim().is_empty() })
.length()
}
///|
pub fn normalization_checksum(
text : String,
profile : NormalizationProfile,
) -> String {
stable_hash(profile.name + "\u{1f}" + normalize_for_profile(text, profile))
}
///|
pub fn normalization_compare(
left : NormalizedDocument,
right : NormalizedDocument,
) -> String {
[
"same_original=\{left.original == right.original}",
"same_normalized=\{left.normalized == right.normalized}",
"left_checksum=\{left.checksum}",
"right_checksum=\{right.checksum}",
"left_changes=\{left.change_count()}",
"right_changes=\{right.change_count()}",
].join("\n")
}
///|
pub fn normalization_pipeline(
text : String,
modes : Array[NormalizationMode],
) -> Array[NormalizedDocument] {
let result = []
let mut current = text
for mode in modes {
let item = normalize_document(current, normalization_profile_for_mode(mode))
result.push(item)
current = item.normalized
}
result
}
///|
pub fn normalization_pipeline_text(
text : String,
modes : Array[NormalizationMode],
) -> String {
match normalization_pipeline(text, modes).last() {
Some(item) => item.normalized
None => text
}
}
///|
pub fn normalization_report(text : String) -> String {
let flags = normalization_risk_flags(text)
let clinical = normalize_document(text, NormalizationProfile::clinical())
let flag_text = flags.join(",")
[
"input_length=\{text.length()}",
"risk_score=\{normalization_risk_score(text)}",
"flags=\{flag_text}",
"clinical_length=\{clinical.normalized.length()}",
"idempotent=\{normalization_is_idempotent(text, NormalizationProfile::clinical())}",
"requires_review=\{normalization_requires_review(text)}",
].join("\n")
}