///|
/// A stable identity for an entry. The label is part of the identity so a
/// keyword may intentionally participate in more than one domain.
pub fn entry_key(entry : Entry) -> String {
"\{entry.keyword}\u{1f}\{entry.label}"
}
///|
/// Return a human-readable identity that is safe to use in reports.
pub fn entry_display_key(entry : Entry) -> String {
if entry.label.length() == 0 {
entry.keyword
} else {
"\{entry.keyword} [\{entry.label}]"
}
}
///|
/// Return a stable copy of entries stored in the lexicon.
pub fn Lexicon::entries(self : Lexicon) -> Array[Entry] {
[
for item in self.entries => item
]
}
///|
/// Validate one entry and return stable diagnostic codes.
///
/// The validator is deliberately non-raising so import pipelines can collect
/// every problem in one pass instead of failing on the first malformed row.
pub fn validate_entry(entry : Entry) -> Array[String] {
let issues : Array[String] = []
if entry.keyword.length() == 0 {
issues.push("empty_keyword")
}
if entry.keyword.length() > 4096 {
issues.push("keyword_too_long")
}
if entry.label.length() > 256 {
issues.push("label_too_long")
}
if entry.keyword.has_prefix("\u{0}") || entry.label.has_prefix("\u{0}") {
issues.push("nul_character")
}
issues
}
///|
/// Validate a dictionary and include the zero-based entry index in each issue.
pub fn validate_entries(entries : ArrayView[Entry]) -> Array[String] {
let issues : Array[String] = []
let seen : Map[String, Int] = Map([])
for index, item in entries {
for issue in validate_entry(item) {
issues.push("\{index}:\{issue}")
}
let key = entry_key(item)
match seen.get(key) {
Some(previous) => issues.push("\{index}:duplicate_of_\{previous}")
None => seen[key] = index
}
}
issues
}
///|
/// True when two entries represent the same keyword and label.
pub fn entries_equal_identity(left : Entry, right : Entry) -> Bool {
entry_key(left) == entry_key(right)
}
///|
/// Keep one entry for each keyword/label pair, retaining the highest weight.
pub fn deduplicate_entries(entries : ArrayView[Entry]) -> Array[Entry] {
let output : Array[Entry] = []
let positions : Map[String, Int] = Map([])
for item in entries {
let key = entry_key(item)
match positions.get(key) {
Some(index) =>
if item.weight > output[index].weight {
output[index] = item
}
None => {
positions[key] = output.length()
output.push(item)
}
}
}
output
}
///|
/// Merge two dictionaries while preserving first-seen order.
pub fn merge_entries(
left : ArrayView[Entry],
right : ArrayView[Entry],
) -> Array[Entry] {
let all : Array[Entry] = []
for item in left {
all.push(item)
}
for item in right {
all.push(item)
}
deduplicate_entries(all)
}
///|
/// Select entries that belong to one label.
pub fn entries_for_label(
entries : ArrayView[Entry],
label : String,
) -> Array[Entry] {
[
for item in entries if item.label == label => item
]
}
///|
/// Select entries whose weight reaches `minimum`.
pub fn entries_with_min_weight(
entries : ArrayView[Entry],
minimum : Int,
) -> Array[Entry] {
[
for item in entries if item.weight >= minimum => item
]
}
///|
/// Return entries grouped by label, retaining source order inside each group.
pub fn group_entries_by_label(
entries : ArrayView[Entry],
) -> Map[String, Array[Entry]] {
let groups : Map[String, Array[Entry]] = Map([])
for item in entries {
match groups.get(item.label) {
Some(bucket) => bucket.push(item)
None => groups[item.label] = [item]
}
}
groups
}
///|
/// Count entries by label without compiling a trie.
pub fn count_entries_by_label(entries : ArrayView[Entry]) -> Map[String, Int] {
let counts : Map[String, Int] = Map([])
for item in entries {
counts[item.label] = counts.get_or_default(item.label, 0) + 1
}
counts
}
///|
/// Return the number of distinct keyword/label identities.
pub fn distinct_entry_count(entries : ArrayView[Entry]) -> Int {
let identities : Map[String, Bool] = Map([])
for item in entries {
identities[entry_key(item)] = true
}
identities.length()
}
///|
/// Return the longest keyword in a dictionary, or an empty string.
pub fn longest_keyword(entries : ArrayView[Entry]) -> String {
let mut best = ""
for item in entries {
if item.keyword.length() > best.length() {
best = item.keyword
}
}
best
}
///|
/// Return the shortest keyword in a dictionary, or an empty string.
pub fn shortest_keyword(entries : ArrayView[Entry]) -> String {
let mut best = ""
for item in entries {
if best.length() == 0 || item.keyword.length() < best.length() {
best = item.keyword
}
}
best
}
///|
/// Sort entries by keyword, label, then descending weight.
pub fn sort_entries(entries : ArrayView[Entry]) -> Array[Entry] {
let output = entries.to_owned()
output.sort_by(fn(left, right) {
let keyword_order = left.keyword.compare(right.keyword)
if keyword_order != 0 {
keyword_order
} else {
let label_order = left.label.compare(right.label)
if label_order != 0 {
label_order
} else {
right.weight - left.weight
}
}
})
output
}
///|
/// Sort entries by descending weight, then longest keyword first.
pub fn sort_entries_by_weight(entries : ArrayView[Entry]) -> Array[Entry] {
let output = entries.to_owned()
output.sort_by(fn(left, right) {
if left.weight != right.weight {
right.weight - left.weight
} else if left.keyword.length() != right.keyword.length() {
right.keyword.length() - left.keyword.length()
} else {
left.keyword.compare(right.keyword)
}
})
output
}
///|
/// A change set that can be applied to a base dictionary.
pub(all) struct DictionaryDelta {
added : Array[Entry]
removed : Array[Entry]
updated : Array[Entry]
} derive(Debug, Eq, ToJson)
///|
/// Return an empty change set.
pub fn DictionaryDelta::empty() -> DictionaryDelta {
{ added: [], removed: [], updated: [] }
}
///|
/// Count all operations represented by a change set.
pub fn DictionaryDelta::change_count(self : DictionaryDelta) -> Int {
self.added.length() + self.removed.length() + self.updated.length()
}
///|
/// True if applying the change set would leave the dictionary unchanged.
pub fn DictionaryDelta::is_empty(self : DictionaryDelta) -> Bool {
self.change_count() == 0
}
///|
/// Compare dictionaries by keyword/label identity and weight.
pub fn diff_entries(
before : ArrayView[Entry],
after : ArrayView[Entry],
) -> DictionaryDelta {
let old : Map[String, Entry] = Map([])
let new : Map[String, Entry] = Map([])
for item in before {
old[entry_key(item)] = item
}
for item in after {
new[entry_key(item)] = item
}
let delta = DictionaryDelta::empty()
for item in before {
let key = entry_key(item)
match new.get(key) {
None => delta.removed.push(item)
Some(next) => if next.weight != item.weight { delta.updated.push(next) }
}
}
for item in after {
if !old.contains(entry_key(item)) {
delta.added.push(item)
}
}
delta
}
///|
/// Apply a dictionary change set and deduplicate the result.
pub fn apply_delta(
base : ArrayView[Entry],
delta : DictionaryDelta,
) -> Array[Entry] {
let removed : Map[String, Bool] = Map([])
for item in delta.removed {
removed[entry_key(item)] = true
}
for item in delta.updated {
removed[entry_key(item)] = true
}
let output : Array[Entry] = []
for item in base {
if !removed.contains(entry_key(item)) {
output.push(item)
}
}
for item in delta.updated {
output.push(item)
}
for item in delta.added {
output.push(item)
}
deduplicate_entries(output)
}
///|
/// Invert a change set so it can be used for rollback.
pub fn invert_delta(
before : ArrayView[Entry],
after : ArrayView[Entry],
delta : DictionaryDelta,
) -> DictionaryDelta {
let inverse = DictionaryDelta::empty()
for item in delta.added {
inverse.removed.push(item)
}
for item in delta.removed {
inverse.added.push(item)
}
let old : Map[String, Entry] = Map([])
for item in before {
old[entry_key(item)] = item
}
for item in delta.updated {
match old.get(entry_key(item)) {
Some(previous) => inverse.updated.push(previous)
None => ()
}
}
ignore(after)
inverse
}
///|
/// Return the frequency of each keyword length.
pub fn keyword_length_histogram(entries : ArrayView[Entry]) -> Map[Int, Int] {
let histogram : Map[Int, Int] = Map([])
for item in entries {
let length = item.keyword.length()
histogram[length] = histogram.get_or_default(length, 0) + 1
}
histogram
}
///|
/// Return the total weight of all entries.
pub fn total_entry_weight(entries : ArrayView[Entry]) -> Int {
let mut total = 0
for item in entries {
total += item.weight
}
total
}
///|
/// Return a copy with weights clamped to the supplied inclusive range.
pub fn clamp_entry_weights(
entries : ArrayView[Entry],
minimum : Int,
maximum : Int,
) -> Array[Entry] {
let low = if minimum <= maximum { minimum } else { maximum }
let high = if minimum <= maximum { maximum } else { minimum }
[
for item in entries => {
let weight = if item.weight < low {
low
} else if item.weight > high {
high
} else {
item.weight
}
{ keyword: item.keyword, label: item.label, weight }
}
]
}
///|
/// Remove entries with empty keywords while retaining all other fields.
pub fn remove_empty_entries(entries : ArrayView[Entry]) -> Array[Entry] {
[
for item in entries if item.keyword.length() > 0 => item
]
}
///|
/// Return only the first occurrence of each keyword, regardless of label.
pub fn unique_keywords(entries : ArrayView[Entry]) -> Array[Entry] {
let seen : Map[String, Bool] = Map([])
let output : Array[Entry] = []
for item in entries {
if !seen.contains(item.keyword) {
seen[item.keyword] = true
output.push(item)
}
}
output
}
///|
/// Replace one keyword while preserving label and weight.
pub fn replace_keyword(entry : Entry, keyword : String) -> Entry {
{ keyword, label: entry.label, weight: entry.weight }
}
///|
/// Return a copy with a new label.
pub fn replace_label(entry : Entry, label : String) -> Entry {
{ keyword: entry.keyword, label, weight: entry.weight }
}
///|
/// Return a copy with a new weight.
pub fn replace_weight(entry : Entry, weight : Int) -> Entry {
{ keyword: entry.keyword, label: entry.label, weight }
}