Skip to main content

rusty_alto/
corpus.rs

1//! Reading and writing Alto corpora.
2//!
3//! An Alto corpus is a text file describing a sequence of instances. A header declares the
4//! comment prefix, whether the corpus is *annotated* (carries a derivation tree per instance),
5//! and the interpretations (name + algebra class). Each instance is one line per interpretation
6//! in declared order, optionally followed by a derivation-tree line, separated by blank lines.
7//!
8//! See <https://github.com/coli-saar/alto/wiki/Corpora>. This is a port of Alto's
9//! `corpus::{Corpus, CorpusWriter, Instance}`, generic over [`Read`]/[`Write`].
10
11use crate::{Irtg, Symbol};
12use packed_term_arena::parser::parse_tree;
13use packed_term_arena::tree::{Tree, TreeArena};
14use std::any::Any;
15use std::io::{self, BufRead, BufReader, Read, Write};
16use thiserror::Error;
17
18/// Placeholder written for an instance with no parse / null value (Alto convention).
19const NULL_MARKER: &str = "_null_";
20
21/// One parsed object of an instance: an interpretation name, its raw text line, and the
22/// type-erased parsed value (taken when the instance is fed to the parser).
23pub struct InterpObject {
24    /// Interpretation name.
25    pub name: String,
26    /// The raw line as it appeared in the corpus.
27    pub text: String,
28    /// The parsed algebra value, or `None` once it has been consumed for parsing.
29    pub value: Option<Box<dyn Any + Send>>,
30}
31
32/// A single corpus instance.
33pub struct Instance {
34    /// Parsed objects, one per interpretation, in declared order.
35    pub objects: Vec<InterpObject>,
36    /// The gold derivation tree (annotated corpora only); read for round-trip, not used by parsing.
37    pub gold_derivation: Option<(TreeArena<String>, Tree)>,
38}
39
40impl Instance {
41    /// Return the raw text of the interpretation named `name`, if present.
42    pub fn text(&self, name: &str) -> Option<&str> {
43        self.objects
44            .iter()
45            .find(|o| o.name == name)
46            .map(|o| o.text.as_str())
47    }
48}
49
50/// A corpus read from a [`Read`]: header metadata plus its instances.
51pub struct Corpus {
52    /// Whether the corpus carries a derivation tree per instance.
53    pub annotated: bool,
54    /// The comment marker found in the header (e.g. `"#"` or `"///"`).
55    pub comment_prefix: String,
56    /// Interpretation names in declared order.
57    pub interpretation_order: Vec<String>,
58    /// The parsed instances (at most `limit`, if one was given).
59    pub instances: Vec<Instance>,
60}
61
62/// Errors returned while reading a corpus.
63#[derive(Debug, Error)]
64pub enum CorpusError {
65    /// An I/O error occurred.
66    #[error("io error: {0}")]
67    Io(#[from] io::Error),
68    /// The header line did not match `<prefix>IRTG (un)annotated corpus file, v1.0`.
69    #[error("invalid corpus header (expected '<prefix>IRTG [un]annotated corpus file, v1.0')")]
70    BadHeader,
71    /// The header declared an unsupported corpus version.
72    #[error("unsupported corpus version (expected v1.0)")]
73    BadVersion,
74    /// An instance did not have the expected number of lines.
75    #[error("line {line}: malformed instance (expected {expected} lines, found {found})")]
76    MalformedInstance {
77        /// 1-based line number where the instance starts.
78        line: usize,
79        /// Number of lines expected for an instance.
80        expected: usize,
81        /// Number of non-blank lines found.
82        found: usize,
83    },
84    /// An interpretation line could not be parsed by its algebra.
85    #[error("line {line}: cannot parse object for interpretation {interpretation:?}: {message}")]
86    ObjectParse {
87        /// 1-based line number.
88        line: usize,
89        /// Interpretation name.
90        interpretation: String,
91        /// Underlying parse error.
92        message: String,
93    },
94    /// A derivation-tree line could not be parsed.
95    #[error("line {line}: cannot parse derivation tree: {message}")]
96    TreeParse {
97        /// 1-based line number.
98        line: usize,
99        /// Underlying parse error.
100        message: String,
101    },
102}
103
104/// Read an Alto corpus, parsing every interpretation line with its algebra's `parse_object`.
105///
106/// `irtg` supplies the interpretations and their algebras. If `limit` is `Some(n)`, only the
107/// first `n` instances are parsed. **Panics** if the corpus declares an interpretation the IRTG
108/// does not contain.
109pub fn read_corpus<R: Read>(
110    reader: R,
111    irtg: &Irtg,
112    limit: Option<usize>,
113) -> Result<Corpus, CorpusError> {
114    let all: Vec<String> = BufReader::new(reader).lines().collect::<io::Result<_>>()?;
115
116    let mut i = 0usize;
117
118    // --- header --------------------------------------------------------------------------
119    while i < all.len() && all[i].trim().is_empty() {
120        i += 1;
121    }
122    let header = all.get(i).ok_or(CorpusError::BadHeader)?;
123    let pos = header.find("IRTG").ok_or(CorpusError::BadHeader)?;
124    let marker = header[..pos].trim_end().to_string();
125    let rest = &header[pos..];
126    if !rest.contains("1.0") {
127        return Err(CorpusError::BadVersion);
128    }
129    let annotated = if rest.contains("unannotated") {
130        false
131    } else if rest.contains("annotated") {
132        true
133    } else {
134        return Err(CorpusError::BadHeader);
135    };
136    i += 1;
137
138    let mut interpretation_order = Vec::new();
139    while i < all.len() {
140        let line = &all[i];
141        if line.trim().is_empty() {
142            i += 1;
143            break; // a blank line ends the header
144        }
145        if !marker.is_empty() && line.starts_with(&marker) {
146            let content = line[marker.len()..].trim_start();
147            if let Some(decl) = content.strip_prefix("interpretation ") {
148                let name = decl.split(':').next().unwrap_or("").trim().to_string();
149                assert!(
150                    irtg.interpretation_ref(&name).is_some(),
151                    "corpus declares interpretation {name:?} which the IRTG does not contain",
152                );
153                interpretation_order.push(name);
154            }
155            i += 1;
156        } else {
157            break; // first non-comment, non-blank line ends the header
158        }
159    }
160
161    // --- instances -----------------------------------------------------------------------
162    let n = interpretation_order.len();
163    let block_size = n + usize::from(annotated);
164    let mut instances = Vec::new();
165
166    while i < all.len() {
167        if limit.is_some_and(|l| instances.len() >= l) {
168            break;
169        }
170
171        // An instance is the next `block_size` non-blank lines; blank lines are skipped
172        // anywhere (Alto's flexible `readCorpus` mode).
173        let mut block: Vec<usize> = Vec::with_capacity(block_size);
174        while block.len() < block_size {
175            while i < all.len() && all[i].trim().is_empty() {
176                i += 1;
177            }
178            if i >= all.len() {
179                break;
180            }
181            block.push(i);
182            i += 1;
183        }
184        if block.is_empty() {
185            break; // clean end of corpus
186        }
187        if block.len() != block_size {
188            return Err(CorpusError::MalformedInstance {
189                line: block[0] + 1,
190                expected: block_size,
191                found: block.len(),
192            });
193        }
194
195        let mut objects = Vec::with_capacity(n);
196        for (k, name) in interpretation_order.iter().enumerate() {
197            let lineno = block[k];
198            let text = &all[lineno];
199            let interp = irtg
200                .interpretation_ref(name)
201                .expect("interpretation validated while reading the header");
202            // Only parse interpretations that can be parse inputs; output-only interpretations
203            // (e.g. tree algebras) keep their raw text and are evaluated into, not parsed from.
204            let value =
205                if interp.is_inputable() {
206                    Some(interp.parse_object_erased(text).map_err(|err| {
207                        CorpusError::ObjectParse {
208                            line: lineno + 1,
209                            interpretation: name.clone(),
210                            message: err.to_string(),
211                        }
212                    })?)
213                } else {
214                    None
215                };
216            objects.push(InterpObject {
217                name: name.clone(),
218                text: text.clone(),
219                value,
220            });
221        }
222
223        let gold_derivation = if annotated {
224            let lineno = block[n];
225            let mut arena = TreeArena::new();
226            let root =
227                parse_tree(&mut arena, &all[lineno]).map_err(|err| CorpusError::TreeParse {
228                    line: lineno + 1,
229                    message: err.to_string(),
230                })?;
231            Some((arena, root))
232        } else {
233            None
234        };
235
236        instances.push(Instance {
237            objects,
238            gold_derivation,
239        });
240    }
241
242    Ok(Corpus {
243        annotated,
244        comment_prefix: marker,
245        interpretation_order,
246        instances,
247    })
248}
249
250/// Streaming writer for an Alto corpus. Output is flushed after every instance so partial
251/// results survive an interruption (the writer is not buffered).
252pub struct CorpusWriter<W: Write> {
253    writer: W,
254    annotated: bool,
255}
256
257impl<W: Write> CorpusWriter<W> {
258    /// Create a writer and emit the corpus header: version line, comment lines, interpretation
259    /// declarations, and a trailing blank line. `prefix` is the comment prefix (e.g. `"# "`).
260    pub fn new(
261        mut writer: W,
262        comment_lines: &[String],
263        prefix: &str,
264        interpretations: &[(String, String)],
265        annotated: bool,
266    ) -> io::Result<Self> {
267        let kind = if annotated {
268            "annotated"
269        } else {
270            "unannotated"
271        };
272        let blank = prefix.trim_end();
273        writeln!(writer, "{prefix}IRTG {kind} corpus file, v1.0")?;
274        writeln!(writer, "{blank}")?;
275        for line in comment_lines {
276            writeln!(writer, "{prefix}{line}")?;
277        }
278        writeln!(writer, "{blank}")?;
279        for (name, class) in interpretations {
280            writeln!(writer, "{prefix}interpretation {name}: {class}")?;
281        }
282        writeln!(writer)?;
283        writer.flush()?;
284        Ok(Self { writer, annotated })
285    }
286
287    /// Write one instance: the interpreted value of each interpretation (in `interp_order`),
288    /// then (for annotated corpora) the derivation-tree line, then a blank separator.
289    ///
290    /// `derivation` is the best derivation tree (over grammar symbols) or `None` when the
291    /// instance did not parse; in the latter case interpretation values are echoed from
292    /// `fallback` and the derivation line is `_null_`. Flushes before returning.
293    pub fn write_instance(
294        &mut self,
295        irtg: &Irtg,
296        interp_order: &[String],
297        derivation: Option<(&TreeArena<Symbol>, Tree)>,
298        fallback: &Instance,
299    ) -> io::Result<()> {
300        for name in interp_order {
301            let line = match derivation {
302                Some((arena, root)) => irtg
303                    .interpretation_ref(name)
304                    .expect("interpretation present")
305                    .interpret_to_string(arena, root)
306                    .map_err(|err| io::Error::other(err.to_string()))?,
307                None => fallback.text(name).unwrap_or(NULL_MARKER).to_string(),
308            };
309            writeln!(self.writer, "{line}")?;
310        }
311
312        if self.annotated {
313            let tree_line = match derivation {
314                Some((arena, root)) => {
315                    let (resolved, resolved_root) =
316                        irtg.grammar_signature().resolve_tree(arena, root);
317                    resolved_root.display(&resolved).to_string()
318                }
319                None => NULL_MARKER.to_string(),
320            };
321            writeln!(self.writer, "{tree_line}")?;
322        }
323
324        writeln!(self.writer)?;
325        self.writer.flush()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::{MaterializationStrategy, parse_irtg};
333
334    const GRAMMAR: &str = "\
335interpretation i: de.up.ling.irtg.algebra.StringAlgebra
336
337S! -> r1(NP,VP)
338  [i] *(?1,?2)
339NP -> r2
340  [i] john
341NP -> r3
342  [i] mary
343VP -> r4(V,NP)
344  [i] *(?1,?2)
345V -> r5
346  [i] watches
347";
348
349    fn parse_best(irtg: &Irtg, instance: &mut Instance) -> Option<crate::ViterbiTree> {
350        let mut inputs = Vec::new();
351        for obj in &mut instance.objects {
352            let value = obj.value.take().unwrap();
353            inputs.push(
354                irtg.interpretation_ref(&obj.name)
355                    .unwrap()
356                    .input_erased(value),
357            );
358        }
359        irtg.best_with(inputs, &MaterializationStrategy::TopDownCondensed)
360            .unwrap()
361    }
362
363    #[test]
364    fn reads_unannotated_corpus() {
365        let irtg = parse_irtg(GRAMMAR.as_bytes()).unwrap();
366        let text = "# IRTG unannotated corpus file, v1.0\n\
367                    # interpretation i: de.up.ling.irtg.algebra.StringAlgebra\n\n\
368                    john watches mary\nmary watches john\n";
369        let corpus = read_corpus(text.as_bytes(), &irtg, None).unwrap();
370
371        assert!(!corpus.annotated);
372        assert_eq!(corpus.comment_prefix, "#");
373        assert_eq!(corpus.interpretation_order, vec!["i".to_string()]);
374        assert_eq!(corpus.instances.len(), 2);
375        assert_eq!(corpus.instances[0].text("i"), Some("john watches mary"));
376    }
377
378    #[test]
379    fn limit_caps_instances() {
380        let irtg = parse_irtg(GRAMMAR.as_bytes()).unwrap();
381        let text = "# IRTG unannotated corpus file, v1.0\n\
382                    # interpretation i: de.up.ling.irtg.algebra.StringAlgebra\n\n\
383                    john watches mary\nmary watches john\n";
384        let corpus = read_corpus(text.as_bytes(), &irtg, Some(1)).unwrap();
385        assert_eq!(corpus.instances.len(), 1);
386    }
387
388    #[test]
389    fn write_instance_emits_yield_and_tree() {
390        let irtg = parse_irtg(GRAMMAR.as_bytes()).unwrap();
391        let text = "# IRTG unannotated corpus file, v1.0\n\
392                    # interpretation i: de.up.ling.irtg.algebra.StringAlgebra\n\n\
393                    john watches mary\n";
394        let mut corpus = read_corpus(text.as_bytes(), &irtg, None).unwrap();
395
396        let best = parse_best(&irtg, &mut corpus.instances[0]);
397        let tree = best.expect("parse should succeed");
398
399        // The derivation tree interprets back to the input yield.
400        let interp = irtg.interpretation_ref("i").unwrap();
401        assert_eq!(
402            interp
403                .interpret_to_string(tree.arena(), tree.root())
404                .unwrap(),
405            "john watches mary",
406        );
407
408        let mut buf = Vec::new();
409        let interps = vec![(
410            "i".to_string(),
411            "de.up.ling.irtg.algebra.StringAlgebra".to_string(),
412        )];
413        {
414            let mut writer = CorpusWriter::new(&mut buf, &[], "# ", &interps, true).unwrap();
415            writer
416                .write_instance(
417                    &irtg,
418                    &corpus.interpretation_order,
419                    Some((tree.arena(), tree.root())),
420                    &corpus.instances[0],
421                )
422                .unwrap();
423        }
424
425        let out = String::from_utf8(buf).unwrap();
426        assert!(out.contains("# IRTG annotated corpus file, v1.0"));
427        assert!(out.contains("# interpretation i: de.up.ling.irtg.algebra.StringAlgebra"));
428        assert!(out.contains("\njohn watches mary\n"));
429        assert!(out.contains("r1(r2, r4(r5, r3))")); // S(NP=john, VP(V=watches, NP=mary))
430    }
431
432    #[test]
433    fn write_instance_uses_null_marker_for_failed_parse() {
434        let irtg = parse_irtg(GRAMMAR.as_bytes()).unwrap();
435        let text = "# IRTG unannotated corpus file, v1.0\n\
436                    # interpretation i: de.up.ling.irtg.algebra.StringAlgebra\n\n\
437                    john watches mary\n";
438        let corpus = read_corpus(text.as_bytes(), &irtg, None).unwrap();
439
440        let mut buf = Vec::new();
441        let interps = vec![(
442            "i".to_string(),
443            "de.up.ling.irtg.algebra.StringAlgebra".to_string(),
444        )];
445        {
446            let mut writer = CorpusWriter::new(&mut buf, &[], "# ", &interps, true).unwrap();
447            writer
448                .write_instance(
449                    &irtg,
450                    &corpus.interpretation_order,
451                    None,
452                    &corpus.instances[0],
453                )
454                .unwrap();
455        }
456
457        let out = String::from_utf8(buf).unwrap();
458        assert!(out.contains("\njohn watches mary\n_null_\n")); // echoed input + null tree
459    }
460}