1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
//! §10.7 initial-letter contractions gated by morpheme STRUCTURE, not pronunciation
//! (feature `english_ueb_cmudict`).
//!
//! §10.7 is spelling-first — an initial-letter contraction may be used wherever its
//! letters occur — but §10.11 forbids using it where the letters do not begin a real
//! word component (it would bridge into / out of an unrelated morpheme and hinder
//! recognition). This rule applies a contraction at `pos` only when it STARTS A
//! COMPONENT (the word start, just after a hyphen/apostrophe, or right after a real
//! dictionary word) AND does not bridge an internal compound seam.
//!
//! - `lord` (⠐⠇), `work` (⠐⠺), `know` (⠐⠅) use the COMPONENT-START gate:
//! `Gay·lord`, `stone·work`, `ac·know·ledge` start a real component; `ch·lordane`,
//! `D·workin` (a non-word prefix) and `Luck·now` (bridges a `luck|now` seam) spell
//! out. A 1-letter prefix is not a component — CMUdict records `d`/`p` as words.
//! - `part` (⠐⠏) is spelling-first with NO component-start gate (`Spar·tan`,
//! `im·part·ial`, `part·erre` contract); only a `part`+`h` (`Par·thenon` /θ/ spells
//! vs `apart·heid` /t/+/h/ contracts) is deferred to the pronunciation gate.
//!
//! Boundaries come from word STRUCTURE (the CMUdict word list), never from braille
//! test outputs. Conservative by design: an unrecognised prefix → spell out, a safe
//! miss rather than a wrong contraction.
use phf::phf_map;
use super::contraction::{ContractionMatch, ContractionRule};
use super::pronunciation::PronunciationProvider;
use crate::unicode::decode_unicode;
/// Structure-gated §10.7 initial-letter contractions → (prefix cell, first-letter cell).
static STRUCT_CONTRACTIONS: phf::Map<&'static str, [u8; 2]> = phf_map! {
"lord" => [decode_unicode('⠐'), decode_unicode('⠇')],
"work" => [decode_unicode('⠐'), decode_unicode('⠺')],
"know" => [decode_unicode('⠐'), decode_unicode('⠅')],
};
/// Structurally-gated §10.7 initial-letter contraction rule.
pub struct StructuralInitialContractionRule {
provider: Box<dyn PronunciationProvider>,
}
impl StructuralInitialContractionRule {
/// Build the rule with the word source used to detect component boundaries.
pub fn new(provider: Box<dyn PronunciationProvider>) -> Self {
Self { provider }
}
fn is_word(&self, chars: &[char]) -> bool {
let s: String = chars.iter().collect();
!self.provider.pronunciations(&s).is_empty()
}
/// The contraction at `pos` begins a real word component: the word start, just
/// after a hyphen/apostrophe boundary, or right after a recorded dictionary word
/// (`Gay|lord`, `stone|work`). A non-word prefix (`ch`, `D`) is NOT a component,
/// so a contraction whose letters merely fall there spells out.
fn starts_component(&self, word: &[char], pos: usize) -> bool {
pos == 0
|| matches!(word.get(pos - 1), Some('-' | '\u{2013}' | '\u{2014}' | '\''))
// A real word prefix of ≥2 letters — CMUdict records single letters
// (`d`) as words, so a 1-letter prefix is NOT a component (`D·workin`).
|| (pos >= 2 && self.is_word(&word[..pos]))
}
/// §10.11: the contraction's letters bridge an internal compound seam — some
/// split inside `[pos, pos+len)` has a real ≥2-letter word on BOTH sides
/// (`Luck|now`). The ≥2 floor excludes CMUdict single-letter entries (`p`, `d`)
/// that would spuriously split a non-compound.
fn crosses_seam(&self, word: &[char], pos: usize, len: usize) -> bool {
(1..len).any(|split| {
let left = &word[..pos + split];
let right = &word[pos + split..];
left.len() >= 2 && right.len() >= 2 && self.is_word(left) && self.is_word(right)
})
}
/// §10.7 `part` (⠐⠏): spelling-first — `part` is the morpheme almost wherever
/// `p·a·r·t` occurs (`Spar·tan`, `im·part·ial`, `part·erre`, `passe-part·out`), so
/// unlike `lord`/`work` it does NOT need the component-start gate. The ONE
/// ambiguity is `part`+`h`, where `t·h` may be a /θ/ digraph (`Par·thenon` spells,
/// `apart·heid` contracts); resolving that needs the word's sound, so a `part`+`h`
/// is DEFERRED to the §10.7 pronunciation gate ([`super::rule_10_7_pron`]) and not
/// claimed here. No compound-seam check is applied: the corpus shows no `part`
/// spell-out from bridging, and a coincidental word|word split (`Spar|tan`,
/// `par|ty`) must NOT block the genuine `part` morpheme.
fn part_allowed(&self, word: &[char], pos: usize) -> bool {
let end = pos + 4;
end <= word.len()
&& ['p', 'a', 'r', 't']
.iter()
.zip(&word[pos..end])
.all(|(k, w)| k == w)
&& word.get(end) != Some(&'h')
}
}
impl ContractionRule for StructuralInitialContractionRule {
fn try_match(&self, word: &[char], pos: usize) -> Option<ContractionMatch> {
for (key, &cells) in STRUCT_CONTRACTIONS.entries() {
let klen = key.chars().count();
let end = pos + klen;
if end <= word.len()
&& key.chars().zip(&word[pos..end]).all(|(k, w)| k == *w)
&& self.starts_component(word, pos)
&& !self.crosses_seam(word, pos, klen)
{
return Some(ContractionMatch {
cells: vec![cells[0], cells[1]],
consumed: klen,
priority: 55,
// §10.10.1: structurally validated at a component start, so the
// cell-minimiser must not split it with a cheaper generic sign.
protect_span: true,
});
}
}
self.part_allowed(word, pos).then(|| ContractionMatch {
cells: vec![decode_unicode('⠐'), decode_unicode('⠏')],
consumed: 4,
priority: 55,
protect_span: true,
})
}
}
#[cfg(test)]
mod tests {
use super::super::pronunciation::cmudict::CmuDictProvider;
use super::*;
fn rule() -> StructuralInitialContractionRule {
StructuralInitialContractionRule::new(Box::new(CmuDictProvider::new()))
}
fn try_at(word: &str, pos: usize) -> Option<(Vec<u8>, usize)> {
let chars: Vec<char> = word.chars().collect();
rule().try_match(&chars, pos).map(|m| (m.cells, m.consumed))
}
/// The contraction starts a real component → used (the prefix is the word start
/// or a recorded dictionary word).
#[rstest::rstest]
#[case::lordosis("lordosis", 0, '⠇')] // word start
#[case::landlord("landlord", 4, '⠇')] // land|lord
#[case::lordship("lordship", 0, '⠇')]
#[case::stonework("stonework", 5, '⠺')] // stone|work
#[case::network("network", 3, '⠺')] // net|work
#[case::homework("homework", 4, '⠺')] // home|work
#[case::acknowledge("acknowledge", 2, '⠅')] // ac|know
#[case::knowledge("knowledge", 0, '⠅')] // word start
fn contracts_at_component_start(#[case] word: &str, #[case] pos: usize, #[case] letter: char) {
assert_eq!(
try_at(word, pos),
Some((vec![decode_unicode('⠐'), decode_unicode(letter)], 4))
);
}
/// The contraction does NOT start a component (a non-word prefix) → spelled out.
#[rstest::rstest]
#[case::chlordane("chlordane", 2)] // `ch` is not a word
#[case::dworkin("dworkin", 1)] // `D` is not a word
fn spells_out_off_component_start(#[case] word: &str, #[case] pos: usize) {
assert_eq!(try_at(word, pos), None);
}
/// §10.11: a contraction whose letters bridge a compound seam spells out, even
/// when it starts a component (`Luck|now` — both halves ≥2-letter words).
#[rstest::rstest]
#[case::lucknow("lucknow", 3)] // luck|now
fn spells_out_across_compound_seam(#[case] word: &str, #[case] pos: usize) {
assert_eq!(try_at(word, pos), None);
}
#[test]
fn structural_contraction_can_start_component_without_crossing_seam() {
let chars: Vec<char> = "stonework".chars().collect();
let rule = rule();
assert!(rule.starts_component(&chars, 5));
assert!(!rule.crosses_seam(&chars, 5, 4));
assert!(rule.try_match(&chars, 5).is_some());
}
#[test]
fn structural_contraction_detects_crossing_compound_seam() {
let chars: Vec<char> = "lucknow".chars().collect();
let rule = rule();
assert!(rule.starts_component(&chars, 3));
assert!(rule.crosses_seam(&chars, 3, 4));
}
/// After a hyphen/apostrophe the contraction starts a component (`m'lord`).
#[rstest::rstest]
#[case::mlord("m'lord", 2, '⠇')]
fn contracts_after_punctuation_boundary(
#[case] word: &str,
#[case] pos: usize,
#[case] letter: char,
) {
assert_eq!(
try_at(word, pos),
Some((vec![decode_unicode('⠐'), decode_unicode(letter)], 4))
);
}
/// §10.7 `part`: spelling-first, contracted wherever `part` is the morpheme and
/// it is NOT followed by `h` (the only sound-sensitive case).
#[rstest::rstest]
#[case::impartial("impartial", 2)] // im·part·ial — t not before h
#[case::parterre("parterre", 0)]
#[case::spartan("spartan", 1)] // mid-word is fine (no component-start gate)
#[case::party("party", 0)]
fn part_contracts(#[case] word: &str, #[case] pos: usize) {
assert_eq!(
try_at(word, pos),
Some((vec![decode_unicode('⠐'), decode_unicode('⠏')], 4))
);
}
/// `part`+`h` is deferred to the pronunciation gate (this rule returns `None`):
/// `Parthenon` (t·h = /θ/, spells) vs `apartheid` (t·h = /t/+/h/, contracts).
#[rstest::rstest]
#[case::parthenon("parthenon", 0)]
#[case::apartheid("apartheid", 1)]
fn part_deferred_when_followed_by_h(#[case] word: &str, #[case] pos: usize) {
assert_eq!(try_at(word, pos), None);
}
}