Skip to main content

omgbase_reconcile/
types.rs

1//! The matcher's types (`spec/reconcile/README.md` §1.2, §2, §6): match
2//! blocks, dispositions, the configuration and the result.
3
4use std::collections::BTreeMap;
5use std::fmt;
6use std::str::FromStr;
7
8use crate::MATCHER_V;
9
10/// A flattened block (§1.2). Old blocks carry their `id`; new blocks do not
11/// until assigned. The positional `key` is stable within one tree and lets
12/// the phases compare parents and sibling order across the two trees — it is
13/// **not** an identity.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MatchBlock {
16    /// The block's persisted id: present on the old side, `None` on the new.
17    pub id: Option<String>,
18    /// The block type name — a spec/format §3 kind (`paragraph`, `list`, …).
19    pub kind: String,
20    /// `sha256(raw)`, lowercase hex (spec/format §4.2).
21    pub raw_hash: String,
22    /// `sha256(text)`, lowercase hex.
23    pub norm_hash: String,
24    /// The block's visible text (spec/format §4.1), computed in tree context.
25    pub text: String,
26    /// Authored `^block-ref` anchors on this block.
27    pub anchors: Vec<String>,
28    /// The parent's positional key, or `None` at the top level.
29    pub parent_key: Option<String>,
30    /// Ordinal among its siblings, from 0.
31    pub index: usize,
32    /// Positional key: `(parent_key ?? "") + "/" + index` (`/0`, `/1/2`).
33    pub key: String,
34}
35
36impl MatchBlock {
37    /// The positional key of the block at `index` under `parent_key`.
38    #[must_use]
39    pub fn positional_key(parent_key: Option<&str>, index: usize) -> String {
40        format!("{}/{index}", parent_key.unwrap_or(""))
41    }
42}
43
44/// A name that is not one of an enum's spec spellings.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct UnknownName(pub String);
47
48impl fmt::Display for UnknownName {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "unknown name {:?}", self.0)
51    }
52}
53
54impl std::error::Error for UnknownName {}
55
56macro_rules! spec_enum {
57    ($(#[$meta:meta])* $name:ident { $($variant:ident => $s:literal),+ $(,)? }) => {
58        $(#[$meta])*
59        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
60        pub enum $name {
61            $($variant),+
62        }
63
64        impl $name {
65            /// Every value, in spec order.
66            pub const ALL: &'static [$name] = &[$($name::$variant),+];
67
68            /// The spec's string form.
69            #[must_use]
70            pub const fn as_str(&self) -> &'static str {
71                match self {
72                    $($name::$variant => $s),+
73                }
74            }
75        }
76
77        impl fmt::Display for $name {
78            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79                f.write_str(self.as_str())
80            }
81        }
82
83        impl FromStr for $name {
84            type Err = UnknownName;
85
86            fn from_str(s: &str) -> Result<Self, Self::Err> {
87                Self::ALL
88                    .iter()
89                    .copied()
90                    .find(|v| v.as_str() == s)
91                    .ok_or_else(|| UnknownName(s.to_owned()))
92            }
93        }
94    };
95}
96
97spec_enum! {
98    /// What a disposition records about a block (§2).
99    DispositionKind {
100        Same => "same",
101        Edited => "edited",
102        Moved => "moved",
103        EditedMoved => "edited_moved",
104        Inserted => "inserted",
105        Deleted => "deleted",
106        SplitFrom => "split_from",
107        MergedInto => "merged_into",
108        CopiedFrom => "copied_from",
109        Resurrected => "resurrected",
110        BulkRewrite => "bulk_rewrite",
111    }
112}
113
114spec_enum! {
115    /// Why a decision was made (§2). `Api` is never produced by the matcher:
116    /// the reference's mutation kernel stamps it on blocks created through
117    /// the API, and a store that persists dispositions needs the spelling.
118    Reason {
119        ExactHash => "exact_hash",
120        NormalizedHash => "normalized_hash",
121        Anchor => "anchor",
122        ContextUnique => "context_unique",
123        ContextChildren => "context_children",
124        Scored => "scored",
125        Tombstone => "tombstone",
126        Api => "api",
127    }
128}
129
130/// A disposition's `detail` (§2): a JSON object with the reference's key
131/// names (§10 "detail key spelling"), `{}` when there is nothing to record.
132/// Keys are sorted; fixtures compare with key order ignored.
133pub type Detail = BTreeMap<String, DetailValue>;
134
135/// A JSON-like value inside a [`Detail`]. The matcher records strings (ids,
136/// keys), integers (child counts), doubles (fractions, scores), lists (keys
137/// of a split run, near misses) and objects (one near miss). Kept as a small
138/// enum of its own so the core crate needs no serde; the `json` feature
139/// converts it to `serde_json::Value`.
140#[derive(Clone, Debug, PartialEq)]
141pub enum DetailValue {
142    Str(String),
143    Int(i64),
144    Num(f64),
145    List(Vec<DetailValue>),
146    Map(Detail),
147}
148
149impl From<&str> for DetailValue {
150    fn from(s: &str) -> Self {
151        DetailValue::Str(s.to_owned())
152    }
153}
154
155impl From<String> for DetailValue {
156    fn from(s: String) -> Self {
157        DetailValue::Str(s)
158    }
159}
160
161impl From<i64> for DetailValue {
162    fn from(n: i64) -> Self {
163        DetailValue::Int(n)
164    }
165}
166
167impl From<f64> for DetailValue {
168    fn from(n: f64) -> Self {
169        DetailValue::Num(n)
170    }
171}
172
173impl From<Vec<DetailValue>> for DetailValue {
174    fn from(list: Vec<DetailValue>) -> Self {
175        DetailValue::List(list)
176    }
177}
178
179impl From<Detail> for DetailValue {
180    fn from(map: Detail) -> Self {
181        DetailValue::Map(map)
182    }
183}
184
185/// Build a [`Detail`] from `(key, value)` pairs.
186pub fn detail<V: Into<DetailValue>, const N: usize>(entries: [(&str, V); N]) -> Detail {
187    entries
188        .into_iter()
189        .map(|(k, v)| (k.to_owned(), v.into()))
190        .collect()
191}
192
193/// One decision (§2). `block_id` is the old id (carries, deletions, merges),
194/// the pool id (resurrections), the minted id (mints) or `"DOC"` for the
195/// document-scoped bulk rewrite.
196#[derive(Clone, Debug, PartialEq)]
197pub struct Disposition {
198    pub block_id: String,
199    pub kind: DispositionKind,
200    /// A number in (0, 1], or `None` when the decision is not a carry.
201    pub confidence: Option<f64>,
202    pub reason: Option<Reason>,
203    /// The matcher version ([`Config::matcher_v`]).
204    pub matcher_v: String,
205    pub detail: Detail,
206}
207
208/// The thresholds and weights (§6), named as the fixtures spell them. The
209/// reference's `ReconcileConfig` uses the camelCase spellings.
210#[derive(Clone, Debug, PartialEq)]
211pub struct Config {
212    /// Stamped on every disposition (`"m" + VERSION`).
213    pub matcher_v: String,
214    /// Phase 5 acceptance.
215    pub theta_accept: f64,
216    /// Phase 5 acceptance when the new block has fewer than
217    /// `small_block_tokens` tokens.
218    pub theta_small: f64,
219    /// The tiny-block boundary.
220    pub small_block_tokens: usize,
221    /// Phase 4a `text_sim` floor.
222    pub context_sim_floor: f64,
223    /// Phase 4b: fraction of an old container's children that must have
224    /// carried into one new container.
225    pub children_vouch_frac: f64,
226    /// Phase 6a split/merge coverage.
227    pub split_coverage: f64,
228    /// Phase 6a dominant-fragment inheritance; `1.01` disables it.
229    pub split_dominant_share: f64,
230    /// Phase 6a copy detection.
231    pub copy_sim: f64,
232    /// Bulk-rewrite trigger (strictly exceeded).
233    pub bulk_unmatched_frac: f64,
234    /// Bulk-rewrite minimum document size.
235    pub bulk_min_blocks: usize,
236    /// Phase 5 skips when the unmatched total exceeds twice this.
237    pub max_scored_blocks: usize,
238    /// Cross-document acceptance (§7).
239    pub theta_xdoc: f64,
240}
241
242impl Default for Config {
243    /// The §6 defaults.
244    fn default() -> Self {
245        Self {
246            matcher_v: MATCHER_V.to_owned(),
247            theta_accept: 0.62,
248            theta_small: 0.62,
249            small_block_tokens: 8,
250            context_sim_floor: 0.35,
251            children_vouch_frac: 0.5,
252            split_coverage: 0.8,
253            split_dominant_share: 0.7,
254            copy_sim: 0.95,
255            bulk_unmatched_frac: 0.45,
256            bulk_min_blocks: 100,
257            max_scored_blocks: 2000,
258            theta_xdoc: 0.8,
259        }
260    }
261}
262
263/// A resurrection-pool entry (§5 phase 6b): a block deleted in an earlier
264/// checkpoint, matched by hash only.
265#[derive(Clone, Debug, PartialEq, Eq)]
266pub struct PoolEntry {
267    pub id: String,
268    /// The block type name.
269    pub kind: String,
270    /// Lowercase hex.
271    pub raw_hash: String,
272    /// Lowercase hex.
273    pub norm_hash: String,
274}
275
276/// The result of reconciling one document (§2).
277#[derive(Clone, Debug, PartialEq, Default)]
278pub struct ReconcileResult {
279    /// New key → id: a carried old id, a resurrected pool id or a freshly
280    /// minted id. Every new key is assigned exactly once.
281    pub assignment: BTreeMap<String, String>,
282    /// One per decision.
283    pub dispositions: Vec<Disposition>,
284    /// Every old id whose disposition kind is `deleted` — phase 7 tombstones
285    /// and phase 6a non-dominant split tombstones — in old document order.
286    pub deleted: Vec<String>,
287    /// The pool ids consumed by phase 6b, in the order consumed.
288    pub consumed_pool: Vec<String>,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn enum_names_round_trip() {
297        for k in DispositionKind::ALL {
298            assert_eq!(k.as_str().parse::<DispositionKind>(), Ok(*k));
299            assert_eq!(k.to_string(), k.as_str());
300        }
301        for r in Reason::ALL {
302            assert_eq!(r.as_str().parse::<Reason>(), Ok(*r));
303        }
304        assert_eq!(
305            "renamed".parse::<DispositionKind>(),
306            Err(UnknownName("renamed".to_owned()))
307        );
308        assert_eq!(DispositionKind::ALL.len(), 11);
309    }
310
311    #[test]
312    fn positional_keys() {
313        assert_eq!(MatchBlock::positional_key(None, 0), "/0");
314        assert_eq!(MatchBlock::positional_key(Some("/1/2"), 0), "/1/2/0");
315    }
316
317    #[test]
318    fn detail_builder() {
319        let d = detail([("a", DetailValue::Int(1)), ("b", "x".into())]);
320        assert_eq!(d.len(), 2);
321        assert_eq!(d["b"], DetailValue::Str("x".to_owned()));
322        assert_eq!(Detail::new(), detail::<DetailValue, 0>([]));
323    }
324}