Skip to main content

markdown_prose_hooks/
transcript.rs

1//! Transcript detection: the one classifier the transform tier cannot pin.
2//!
3//! `is_transcript_like_markdown` is not reachable from `unwrap_markdown_prose` —
4//! the CLI asks it before the transform runs, and answers yes by leaving the
5//! file alone entirely. So `corpus/cases/` says nothing about it and
6//! `corpus/cli/` says everything, which is why the process tier was built first.
7//!
8//! Both heading patterns count characters over an ASCII class, so the counted
9//! quantifiers here are the only ones in the crate where the character count and
10//! the byte count could part company — and they cannot, because a non-ASCII
11//! character is not in either class and stops the run before the count is
12//! reached.
13//!
14//! Every expected value in the tests below came from running the Python.
15
16use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
17
18/// `_TRANSCRIPT_HEADING_FLOOR`: fewer headings than this is not a transcript.
19pub const TRANSCRIPT_HEADING_FLOOR: usize = 2;
20
21/// `_TRANSCRIPT_HEADING_RATIO`: the density a transcript has to clear.
22///
23/// Real transcripts run 0.12 to 0.49; a prose document with two stray
24/// colon-terminated intro lines runs about 0.004. The gate exists so a long
25/// design document is never skipped wholesale over a couple of them.
26pub const TRANSCRIPT_HEADING_RATIO: f64 = 0.05;
27
28/// `_MATCH_BARE_SPEAKER_HEADING`: `^[A-Z][a-zA-Z0-9_. -]{0,39}:$`.
29///
30/// A heading that stands alone above a blank line, such as `MC:`.
31#[must_use]
32pub fn match_bare_speaker_heading(body: &str) -> bool {
33    let bytes = body.as_bytes();
34    if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
35        return false;
36    }
37    let mut end = 1;
38    while bytes.get(end).is_some_and(|b| is_bare_class_byte(*b)) {
39        end += 1;
40    }
41    // The run is maximal and giving characters back cannot help: the colon the
42    // pattern wants next is not in the class, so every shorter reading stops on
43    // a class character instead. An over-long run therefore just fails.
44    if end - 1 > 39 {
45        return false;
46    }
47    ends_after_colon(body, bytes, end)
48}
49
50/// `_MATCH_TIMESTAMPED_SPEAKER_HEADING`: `^[A-Z][a-zA-Z0-9_.-]{0,19} [0-9]{1,2}:[0-9]{2}$`.
51///
52/// A heading carrying an inline timestamp, such as `MC 0:15`, which sits
53/// directly above its utterance rather than above a blank line.
54///
55/// The digits are `[0-9]` and not `\d`: the specification narrowed them, because
56/// `\d` selected 650 code points on 3.10 and 680 on 3.13 and so was not one
57/// behavior to port. `corpus/cli/a-non-ascii-digit-timestamp-is-not-a-transcript`
58/// pins the narrowed reading.
59#[must_use]
60pub fn match_timestamped_speaker_heading(body: &str) -> bool {
61    let bytes = body.as_bytes();
62    if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
63        return false;
64    }
65    let mut end = 1;
66    // The same class as the speaker prefix in `label`, minus the space, which is
67    // what separates the name from the timestamp here.
68    while bytes.get(end).is_some_and(|b| is_name_class_byte(*b)) {
69        end += 1;
70    }
71    if end - 1 > 19 || bytes.get(end) != Some(&b' ') {
72        return false;
73    }
74    let start = end + 1;
75    let mut run = 0;
76    while run < 2 && bytes.get(start + run).is_some_and(u8::is_ascii_digit) {
77        run += 1;
78    }
79    // `[0-9]{1,2}` is greedy, so two digits are tried before one. `MC 1:23` only
80    // matches on the one-digit reading, and it is reached by backtracking.
81    (1..=run).rev().any(|hours| {
82        let colon = start + hours;
83        bytes.get(colon) == Some(&b':')
84            && bytes.get(colon + 1).is_some_and(u8::is_ascii_digit)
85            && bytes.get(colon + 2).is_some_and(u8::is_ascii_digit)
86            && ends_here(body, colon + 3)
87    })
88}
89
90/// `_is_transcript_like_markdown`: does this document use repeated speaker turns?
91#[must_use]
92pub fn is_transcript_like_markdown(text: &str) -> bool {
93    let bodies: Vec<&str> = py_splitlines_keepends(text)
94        .iter()
95        .map(|line| py_trim(split_eol(line).0))
96        .collect();
97    let mut headings = 0;
98    let mut non_blank = 0;
99    for (index, body) in bodies.iter().enumerate() {
100        if !body.is_empty() {
101            non_blank += 1;
102        }
103        let next_body = bodies.get(index + 1).copied().unwrap_or("");
104        // The two shapes want opposite neighbors, which is the whole reason
105        // they are two shapes: a bare heading stands above a blank line, and a
106        // timestamped one sits directly above the utterance it labels.
107        let counts = if match_bare_speaker_heading(body) {
108            next_body.is_empty()
109        } else if match_timestamped_speaker_heading(body) {
110            !next_body.is_empty()
111        } else {
112            false
113        };
114        if counts {
115            headings += 1;
116        }
117    }
118    if headings < TRANSCRIPT_HEADING_FLOOR || non_blank == 0 {
119        return false;
120    }
121    // True division on both sides, so a document of 40 lines needs two headings
122    // and a document of 41 needs three.
123    headings as f64 / non_blank as f64 >= TRANSCRIPT_HEADING_RATIO
124}
125
126/// `[a-zA-Z0-9_. -]`, the bare heading's class. Note the space.
127fn is_bare_class_byte(b: u8) -> bool {
128    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b' ' | b'-')
129}
130
131/// `[a-zA-Z0-9_.-]`, the timestamped heading's name class. No space.
132fn is_name_class_byte(b: u8) -> bool {
133    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')
134}
135
136/// A colon at `at`, and then the end of the pattern.
137fn ends_after_colon(body: &str, bytes: &[u8], at: usize) -> bool {
138    bytes.get(at) == Some(&b':') && ends_here(body, at + 1)
139}
140
141/// Python's `$` outside `re.MULTILINE`.
142///
143/// It matches at the end of the string or just before a newline that *is* the
144/// last character, and only `\n`: a trailing `\r` fails where a trailing `\n`
145/// passes. Callers here always pass a stripped line, so this can only matter to
146/// somebody calling the pattern directly — which the tests do.
147fn ends_here(body: &str, at: usize) -> bool {
148    matches!(&body[at..], "" | "\n")
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn a_bare_heading_counts_characters_over_an_ascii_class() {
157        assert!(match_bare_speaker_heading("MC:"));
158        assert!(match_bare_speaker_heading("A:"));
159        assert!(match_bare_speaker_heading("A B C D E F:"));
160        assert!(match_bare_speaker_heading("A.B-C_1:"));
161        // 'A' plus 39 is the boundary; 'A' plus 40 is not a heading.
162        assert!(match_bare_speaker_heading(&format!("A{}:", "e".repeat(39))));
163        assert!(!match_bare_speaker_heading(&format!(
164            "A{}:",
165            "e".repeat(40)
166        )));
167        // Non-ASCII is rejected by the class, and never reaches the count.
168        assert!(!match_bare_speaker_heading(&format!(
169            "A{}:",
170            "\u{e9}".repeat(39)
171        )));
172        assert!(!match_bare_speaker_heading("a:"));
173        assert!(!match_bare_speaker_heading("A"));
174        assert!(!match_bare_speaker_heading("A::"));
175        assert!(!match_bare_speaker_heading("A:b"));
176        assert!(!match_bare_speaker_heading(":"));
177    }
178
179    #[test]
180    fn a_timestamped_heading_is_ascii_digits_only() {
181        // The plan drafted the opposite expectation, from a belief that the
182        // Python's `\d` was one set to be ported faithfully. It was not.
183        assert!(match_timestamped_speaker_heading("MC 0:15"));
184        assert!(match_timestamped_speaker_heading("MC 12:34"));
185        assert!(!match_timestamped_speaker_heading(
186            "MC \u{660}:\u{661}\u{665}"
187        ));
188        assert!(!match_timestamped_speaker_heading(
189            "MC \u{967}\u{968}:\u{969}\u{969}"
190        ));
191    }
192
193    #[test]
194    fn a_timestamp_takes_one_or_two_digits_then_exactly_two() {
195        // `MC 1:23` matches only on the one-digit reading of a greedy `{1,2}`.
196        assert!(match_timestamped_speaker_heading("MC 1:23"));
197        assert!(!match_timestamped_speaker_heading("MC 123:45"));
198        assert!(!match_timestamped_speaker_heading("MC 1:2"));
199        assert!(!match_timestamped_speaker_heading("MC 1:234"));
200        assert!(!match_timestamped_speaker_heading("MC1:23"));
201        // The name class holds no space, so a two-word name is not a heading.
202        assert!(!match_timestamped_speaker_heading("M C 1:23"));
203        assert!(match_timestamped_speaker_heading(&format!(
204            "A{} 1:23",
205            "e".repeat(19)
206        )));
207        assert!(!match_timestamped_speaker_heading(&format!(
208            "A{} 1:23",
209            "e".repeat(20)
210        )));
211    }
212
213    #[test]
214    fn the_end_anchor_takes_one_newline_and_only_a_newline() {
215        assert!(match_bare_speaker_heading("A:\n"));
216        assert!(match_timestamped_speaker_heading("MC 0:15\n"));
217        assert!(!match_timestamped_speaker_heading("MC 0:15\r"));
218    }
219
220    #[test]
221    fn two_headings_over_a_short_document_do_classify() {
222        assert!(is_transcript_like_markdown("MC:\n\na\nb\n\nJR:\n\nc\nd\n"));
223        assert!(is_transcript_like_markdown(
224            "MC 0:15\nhello\n\nJR 0:20\nthere\n"
225        ));
226    }
227
228    #[test]
229    fn the_density_gate_keeps_prose_out() {
230        let mut doc = String::from("Concretely:\n\n");
231        for _ in 0..200 {
232            doc.push_str("A line of ordinary prose.\n");
233        }
234        doc.push_str("\nFinal note:\n");
235        assert!(!is_transcript_like_markdown(&doc));
236    }
237
238    #[test]
239    fn a_heading_needs_the_right_neighbor_to_count() {
240        // Two bare headings, but the first has a non-blank line under it, so
241        // only one of them counts and the floor is not cleared.
242        assert!(!is_transcript_like_markdown("MC:\nJR:\n"));
243        assert!(!is_transcript_like_markdown("MC:\n\na\n"));
244        assert!(!is_transcript_like_markdown(""));
245    }
246}