glossia 0.2.0

Encode binary data (BIP39 mnemonics, keys, arbitrary payloads) into grammatically correct, human-readable natural language
Documentation
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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Semantic sentence-planning support.
//!
//! Loads a per-word semantic dataset (`languages/<lang>/semantics.yaml`) and
//! scores a candidate POS placement by how well payload words land in
//! semantically coherent verb-argument roles. The generator uses this only as a
//! *soft* bias when choosing among equally-dense candidate sentence skeletons in
//! `plan_sentence`: it never drops, reorders, or blocks a payload word, so
//! decoding is completely unaffected whether or not this data is present.
//!
//! Classes are top-level (`animate | agentive | thing | place | abstract`); a
//! verb frame states which classes its subject and object accept. Roles are
//! inferred from the flat POS sequence: the nearest payload-filled noun slot to
//! a verb's left is its subject, to its right its object (bounded by clause
//! punctuation and other verbs). This mirrors the offline prototype in
//! `experiments/semantic_planner/`.

use crate::generator::types::PayloadTok;
use crate::types::Pos;
use serde::Deserialize;
use std::collections::HashMap;

/// Top-level semantic class of a noun/entity.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SemClass {
    Animate,
    Agentive,
    Thing,
    Place,
    Abstract,
}

impl SemClass {
    fn parse(s: &str) -> Option<SemClass> {
        match s {
            "animate" => Some(SemClass::Animate),
            "agentive" => Some(SemClass::Agentive),
            "thing" => Some(SemClass::Thing),
            "place" => Some(SemClass::Place),
            "abstract" => Some(SemClass::Abstract),
            _ => None,
        }
    }
}

/// Selectional restriction on a verb argument: accept anything, or one of a set.
#[derive(Clone, Debug)]
pub enum Sel {
    Any,
    Classes(Vec<SemClass>),
}

impl Sel {
    pub fn accepts(&self, c: SemClass) -> bool {
        match self {
            Sel::Any => true,
            Sel::Classes(v) => v.contains(&c),
        }
    }
}

/// A verb's subject/object expectations.
#[derive(Clone, Debug)]
pub struct Frame {
    pub subj: Sel,
    pub obj: Sel,
}

/// Multiplicative penalty applied per incoherent verb-argument edge. Chosen so
/// coherent skeletons are strongly preferred but incoherent ones are never
/// impossible (payload placement stays exempt from hard constraints).
const EDGE_PENALTY: f64 = 0.15;
/// Score floor so a candidate weight never collapses to exactly zero.
const SCORE_FLOOR: f64 = 0.02;

#[derive(Clone, Debug, Default)]
pub struct SemanticModel {
    classes: HashMap<String, SemClass>,
    frames: HashMap<String, Frame>,
}

// --- YAML shape ---------------------------------------------------------- //

#[derive(Deserialize)]
struct RawFile {
    #[serde(default)]
    classes: HashMap<String, String>,
    #[serde(default)]
    frames: HashMap<String, RawFrame>,
}

#[derive(Deserialize)]
struct RawFrame {
    subj: RawSel,
    obj: RawSel,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum RawSel {
    /// The scalar `any` (any other bare string is treated as `any` too).
    /// The captured string is only used by serde to match the scalar form.
    Any(#[allow(dead_code)] String),
    /// A list like `[animate, agentive]`.
    List(Vec<String>),
}

impl RawSel {
    fn into_sel(self) -> Sel {
        match self {
            RawSel::Any(_) => Sel::Any,
            RawSel::List(v) => {
                let classes: Vec<SemClass> = v.iter().filter_map(|s| SemClass::parse(s)).collect();
                // An empty/unparseable list means "no constraint we can enforce".
                if classes.is_empty() {
                    Sel::Any
                } else {
                    Sel::Classes(classes)
                }
            }
        }
    }
}

impl SemanticModel {
    /// Parse a `semantics.yaml` document. Unknown class strings are dropped
    /// (the word simply carries no class), so a malformed entry degrades to
    /// "no semantic opinion" rather than an error.
    pub fn from_yaml(content: &str) -> Result<SemanticModel, String> {
        let raw: RawFile =
            serde_yaml::from_str(content).map_err(|e| format!("semantics.yaml parse error: {e}"))?;
        let classes = raw
            .classes
            .into_iter()
            .filter_map(|(w, c)| SemClass::parse(&c).map(|cls| (w.to_lowercase(), cls)))
            .collect();
        let frames = raw
            .frames
            .into_iter()
            .map(|(w, rf)| {
                (
                    w.to_lowercase(),
                    Frame {
                        subj: rf.subj.into_sel(),
                        obj: rf.obj.into_sel(),
                    },
                )
            })
            .collect();
        Ok(SemanticModel { classes, frames })
    }

    pub fn is_empty(&self) -> bool {
        self.classes.is_empty() && self.frames.is_empty()
    }

    /// `(number of classified words, number of verb frames)` — for diagnostics
    /// and tests that a real dataset loaded.
    pub fn stats(&self) -> (usize, usize) {
        (self.classes.len(), self.frames.len())
    }

    pub fn class_of(&self, word: &str) -> Option<SemClass> {
        self.classes.get(&word.to_lowercase()).copied()
    }

    /// The selectional frame for a verb (payload or cover), if known.
    pub fn frame(&self, verb: &str) -> Option<&Frame> {
        self.frames.get(&verb.to_lowercase())
    }

    /// Coherence of a finished encoding, scored on the surface text: the fraction
    /// of verb-argument edges (subject/object, over payload AND cover nouns) that
    /// satisfy the verb's frame. Roles are inferred from token adjacency within a
    /// sentence — the nearest classified noun on each side of a verb, bounded by
    /// other verbs. Returns 1.0 when there are no scorable edges.
    ///
    /// This is the candidate-selection objective for best-of-N generation: the
    /// grammar proposes a full encoding, this scores it. It re-parses text rather
    /// than touching generator internals, so it works on any output.
    pub fn coherence_score(&self, text: &str) -> f64 {
        let mut edges = 0u32;
        let mut good = 0u32;
        for sentence in text.split(|c| c == '.' || c == '\n' || c == '!' || c == '?') {
            let toks: Vec<String> = sentence
                .split_whitespace()
                .map(|w| {
                    w.trim_matches(|c: char| !c.is_alphanumeric())
                        .to_lowercase()
                })
                .filter(|w| !w.is_empty())
                .collect();
            for (i, w) in toks.iter().enumerate() {
                let fr = match self.frame(w) {
                    Some(f) => f,
                    None => continue,
                };
                // subject: nearest classified noun to the left, stop at a verb
                for j in (0..i).rev() {
                    if j != i && self.frame(&toks[j]).is_some() {
                        break;
                    }
                    if let Some(c) = self.class_of(&toks[j]) {
                        edges += 1;
                        if fr.subj.accepts(c) {
                            good += 1;
                        }
                        break;
                    }
                }
                // object: nearest classified noun to the right, stop at a verb
                for tok in toks.iter().skip(i + 1) {
                    if self.frame(tok).is_some() {
                        break;
                    }
                    if let Some(c) = self.class_of(tok) {
                        edges += 1;
                        if fr.obj.accepts(c) {
                            good += 1;
                        }
                        break;
                    }
                }
            }
        }
        if edges == 0 {
            1.0
        } else {
            f64::from(good) / f64::from(edges)
        }
    }

    /// Class of the nearest payload-filled noun slot on one side of `from`,
    /// stopping at clause punctuation or another verb (a clause boundary).
    fn nearest_payload_noun(
        &self,
        slots: &[Pos],
        placement: &HashMap<usize, usize>,
        payload: &[PayloadTok],
        from: usize,
        forward: bool,
    ) -> Option<SemClass> {
        let idxs: Vec<usize> = if forward {
            (from + 1..slots.len()).collect()
        } else {
            (0..from).rev().collect()
        };
        for i in idxs {
            match slots[i] {
                Pos::Dot | Pos::V => break, // clause boundary
                Pos::N => {
                    if let Some(&pidx) = placement.get(&i) {
                        if let Some(c) = self.class_of(&payload[pidx].word) {
                            return Some(c);
                        }
                    }
                    // an unclassified or cover-filled noun: keep looking is wrong
                    // (nearest noun is the argument); stop at the first noun slot.
                    break;
                }
                _ => {} // Det / Adj / Prep etc. — skip over
            }
        }
        None
    }

    /// Coherence multiplier in (0, 1] for a candidate placement. 1.0 means every
    /// payload verb whose subject/object is also a payload word is satisfied (or
    /// unknown). Each violated edge multiplies the score by `EDGE_PENALTY`.
    pub fn placement_score(
        &self,
        slots: &[Pos],
        placement: &HashMap<usize, usize>,
        payload: &[PayloadTok],
    ) -> f64 {
        let mut score = 1.0f64;
        for (i, pos) in slots.iter().enumerate() {
            if *pos != Pos::V {
                continue;
            }
            let vidx = match placement.get(&i) {
                Some(&x) => x,
                None => continue, // cover verb — frame unknown at plan time
            };
            let frame = match self.frames.get(&payload[vidx].word.to_lowercase()) {
                Some(f) => f,
                None => continue,
            };
            if let Some(c) = self.nearest_payload_noun(slots, placement, payload, i, false) {
                if !frame.subj.accepts(c) {
                    score *= EDGE_PENALTY;
                }
            }
            if let Some(c) = self.nearest_payload_noun(slots, placement, payload, i, true) {
                if !frame.obj.accepts(c) {
                    score *= EDGE_PENALTY;
                }
            }
        }
        score.max(SCORE_FLOOR)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn model() -> SemanticModel {
        let yaml = r#"
classes:
  clock: thing
  captain: animate
  engine: agentive
  mountain: place
  idea: abstract
frames:
  discover: { subj: [animate], obj: any }
  process:  { subj: [animate, agentive], obj: any }
  exist:    { subj: any, obj: any }
"#;
        SemanticModel::from_yaml(yaml).unwrap()
    }

    fn tok(word: &str, pos: Pos) -> PayloadTok {
        PayloadTok::new(word, &[pos])
    }

    #[test]
    fn parses_classes_and_frames() {
        let m = model();
        assert_eq!(m.class_of("clock"), Some(SemClass::Thing));
        assert_eq!(m.class_of("CAPTAIN"), Some(SemClass::Animate)); // case-insensitive
        assert!(m.frames.contains_key("discover"));
        assert!(m.class_of("nonesuch").is_none());
    }

    #[test]
    fn sel_any_accepts_all() {
        let m = model();
        // exist has subj: any -> no penalty regardless of subject class
        let slots = vec![Pos::N, Pos::V, Pos::Dot];
        let payload = vec![tok("clock", Pos::N), tok("exist", Pos::V)];
        let mut placement = HashMap::new();
        placement.insert(0, 0);
        placement.insert(1, 1);
        assert_eq!(m.placement_score(&slots, &placement, &payload), 1.0);
    }

    #[test]
    fn incoherent_subject_penalized() {
        let m = model();
        // "clock discover ..." — discover wants animate subject, clock is thing.
        let slots = vec![Pos::N, Pos::V, Pos::N, Pos::Dot];
        let payload = vec![tok("clock", Pos::N), tok("discover", Pos::V), tok("idea", Pos::N)];
        let mut p = HashMap::new();
        p.insert(0, 0);
        p.insert(1, 1);
        p.insert(2, 2);
        let s = m.placement_score(&slots, &p, &payload);
        assert!(s < 1.0, "incoherent subject should be penalized, got {s}");
        assert!((s - EDGE_PENALTY).abs() < 1e-9, "one violated edge, got {s}");
    }

    #[test]
    fn coherent_subject_unpenalized() {
        let m = model();
        // "captain discover idea" — animate subject, obj any -> fully coherent.
        let slots = vec![Pos::N, Pos::V, Pos::N, Pos::Dot];
        let payload = vec![tok("captain", Pos::N), tok("discover", Pos::V), tok("idea", Pos::N)];
        let mut p = HashMap::new();
        p.insert(0, 0);
        p.insert(1, 1);
        p.insert(2, 2);
        assert_eq!(m.placement_score(&slots, &p, &payload), 1.0);
    }

    #[test]
    fn agentive_subject_allowed_for_process() {
        let m = model();
        // "engine process idea" — process accepts agentive; must NOT be penalized.
        let slots = vec![Pos::N, Pos::V, Pos::N, Pos::Dot];
        let payload = vec![tok("engine", Pos::N), tok("process", Pos::V), tok("idea", Pos::N)];
        let mut p = HashMap::new();
        p.insert(0, 0);
        p.insert(1, 1);
        p.insert(2, 2);
        assert_eq!(m.placement_score(&slots, &p, &payload), 1.0);
    }

    #[test]
    fn cover_filled_verb_is_ignored() {
        let m = model();
        // verb slot not in placement (cover verb) -> no scoring, score 1.0
        let slots = vec![Pos::N, Pos::V, Pos::Dot];
        let payload = vec![tok("clock", Pos::N)];
        let mut p = HashMap::new();
        p.insert(0, 0); // only the noun is a payload word
        assert_eq!(m.placement_score(&slots, &p, &payload), 1.0);
    }

    #[test]
    fn coherence_score_surface_text() {
        let m = model();
        // coherent: captain (animate) discover (any obj) -> 1.0
        assert_eq!(m.coherence_score("The captain discover the idea."), 1.0);
        // incoherent subject: clock (thing) discover (wants animate subj)
        assert!(m.coherence_score("The clock discover the mountain.") < 1.0);
        // agentive subject with process is fine
        assert_eq!(m.coherence_score("The engine process the idea."), 1.0);
        // no framed verbs -> no edges -> 1.0
        assert_eq!(m.coherence_score("The clock. The mountain."), 1.0);
    }

    #[test]
    fn score_never_zero() {
        let m = model();
        // many violations still floored above zero
        let slots = vec![Pos::N, Pos::V, Pos::Dot, Pos::N, Pos::V, Pos::Dot];
        let payload = vec![
            tok("clock", Pos::N),
            tok("discover", Pos::V),
            tok("clock", Pos::N),
            tok("discover", Pos::V),
        ];
        // Not a fully realistic placement, but exercises the floor.
        let mut p = HashMap::new();
        p.insert(0, 0);
        p.insert(1, 1);
        p.insert(3, 2);
        p.insert(4, 3);
        let s = m.placement_score(&slots, &p, &payload);
        assert!(s >= SCORE_FLOOR);
    }
}