Skip to main content

waggle_core/
contract.rs

1//! Consumption contracts (design doc `19 §4.2`): the author's mint-time
2//! declaration of which regions of the artifact a consumer must actually
3//! reach. Declared in the **immutable core** (a contract you can
4//! re-negotiate after delegation is not a contract), evaluated by the
5//! coverage fold over region-touch bits stamped on `read` events.
6//!
7//! Regions are 1-based inclusive line ranges — at most
8//! [`MAX_CONTRACT_REGIONS`], because touches travel on events as a bitmask
9//! ([`crate::Event::regions`]) that must stay fixed-width (I-1's
10//! fixed-width consequence, doc `03 §4`). The bitmask is
11//! manifest-referencing — an index into *this* declared list — which is
12//! the same I-1-compatibility argument the `variant` field makes.
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// The most regions one contract may declare — the width of the
18/// region-touch bitmask on events.
19pub const MAX_CONTRACT_REGIONS: usize = 8;
20
21/// The threshold meaning "every declared region must be touched".
22pub const FULL_COVERAGE_PERMILLE: u16 = 1000;
23
24/// Longest permitted region label (labels live in the signed core; caps
25/// keep the manifest inside its size budget).
26const MAX_LABEL_LEN: usize = 80;
27
28/// Why a contract was rejected at construction.
29#[derive(Debug, Error, PartialEq, Eq)]
30pub enum ContractError {
31    /// A contract with nothing required requires nothing.
32    #[error("a contract needs at least one region")]
33    NoRegions,
34    /// More regions than the event bitmask can name.
35    #[error("{0} regions exceeds the {MAX_CONTRACT_REGIONS}-region cap — merge adjacent ranges")]
36    TooManyRegions(usize),
37    /// Lines are 1-based and ranges are inclusive; `start` must be ≥ 1
38    /// and ≤ `end`.
39    #[error("region {index}: lines {start}-{end} is not a 1-based inclusive range")]
40    BadRange {
41        /// Which region (0-based, declaration order).
42        index: usize,
43        /// Declared start line.
44        start: u32,
45        /// Declared end line.
46        end: u32,
47    },
48    /// The threshold is a permille of declared regions in `1..=1000`.
49    #[error("min-permille {0} outside 1..=1000 (1000 = every region)")]
50    BadThreshold(u16),
51    /// Labels are short human names, not documents.
52    #[error("region {0}: label exceeds {MAX_LABEL_LEN} characters")]
53    LabelTooLong(usize),
54}
55
56/// One required region: a 1-based inclusive line range with an optional
57/// human label (the section heading it came from, typically) so misses
58/// can be *named*, not just numbered.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(try_from = "RegionWire")]
61pub struct Region {
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    label: Option<String>,
64    start: u32,
65    end: u32,
66}
67
68/// The unvalidated wire shape — [`Region`] deserializes through it so an
69/// invalid range can never enter the domain (the slug discipline, doc
70/// `02 §2`).
71#[derive(Deserialize)]
72struct RegionWire {
73    #[serde(default)]
74    label: Option<String>,
75    start: u32,
76    end: u32,
77}
78
79impl TryFrom<RegionWire> for Region {
80    type Error = ContractError;
81    fn try_from(w: RegionWire) -> Result<Self, ContractError> {
82        Region::new(w.label, w.start, w.end, 0)
83    }
84}
85
86impl Region {
87    /// Validate one region. `index` only shapes the error message.
88    pub fn new(
89        label: Option<String>,
90        start: u32,
91        end: u32,
92        index: usize,
93    ) -> Result<Self, ContractError> {
94        if start == 0 || start > end {
95            return Err(ContractError::BadRange { index, start, end });
96        }
97        if label.as_deref().is_some_and(|l| l.len() > MAX_LABEL_LEN) {
98            return Err(ContractError::LabelTooLong(index));
99        }
100        Ok(Self { label, start, end })
101    }
102
103    /// The human name, if the author gave one.
104    #[must_use]
105    pub fn label(&self) -> Option<&str> {
106        self.label.as_deref()
107    }
108
109    /// First required line (1-based).
110    #[must_use]
111    pub fn start(&self) -> u32 {
112        self.start
113    }
114
115    /// Last required line (inclusive).
116    #[must_use]
117    pub fn end(&self) -> u32 {
118        self.end
119    }
120
121    /// Does the served window `[from, to]` (1-based inclusive) overlap
122    /// this region?
123    #[must_use]
124    pub fn overlaps(&self, from: u32, to: u32) -> bool {
125        from <= self.end && to >= self.start
126    }
127}
128
129/// The consumption contract: which regions must be reached, and what
130/// fraction of them (permille) satisfies the author.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(try_from = "ContractWire", rename_all = "kebab-case")]
133pub struct Contract {
134    regions: Vec<Region>,
135    min_permille: u16,
136}
137
138#[derive(Deserialize)]
139#[serde(rename_all = "kebab-case")]
140struct ContractWire {
141    regions: Vec<Region>,
142    #[serde(default = "full")]
143    min_permille: u16,
144}
145
146fn full() -> u16 {
147    FULL_COVERAGE_PERMILLE
148}
149
150impl TryFrom<ContractWire> for Contract {
151    type Error = ContractError;
152    fn try_from(w: ContractWire) -> Result<Self, ContractError> {
153        Contract::new(w.regions, w.min_permille)
154    }
155}
156
157impl Contract {
158    /// Validate a whole contract.
159    pub fn new(regions: Vec<Region>, min_permille: u16) -> Result<Self, ContractError> {
160        if regions.is_empty() {
161            return Err(ContractError::NoRegions);
162        }
163        if regions.len() > MAX_CONTRACT_REGIONS {
164            return Err(ContractError::TooManyRegions(regions.len()));
165        }
166        if min_permille == 0 || min_permille > FULL_COVERAGE_PERMILLE {
167            return Err(ContractError::BadThreshold(min_permille));
168        }
169        Ok(Self {
170            regions,
171            min_permille,
172        })
173    }
174
175    /// The declared regions, in declaration order (bit `i` of a touch
176    /// mask names `regions()[i]`).
177    #[must_use]
178    pub fn regions(&self) -> &[Region] {
179        &self.regions
180    }
181
182    /// The satisfaction threshold, as a permille of declared regions.
183    #[must_use]
184    pub fn min_permille(&self) -> u16 {
185        self.min_permille
186    }
187
188    /// Which regions a served line window `[from, to]` touches, as the
189    /// event bitmask.
190    #[must_use]
191    pub fn touched_by_span(&self, from: u32, to: u32) -> u8 {
192        let mut bits = 0u8;
193        for (i, r) in self.regions.iter().enumerate() {
194            if r.overlaps(from, to) {
195                bits |= 1 << i;
196            }
197        }
198        bits
199    }
200
201    /// Which regions a single served line touches (search hits).
202    #[must_use]
203    pub fn touched_by_line(&self, line: u32) -> u8 {
204        self.touched_by_span(line, line)
205    }
206
207    /// Evaluate accumulated touch bits against the threshold.
208    ///
209    /// # Panics
210    /// Never in practice: `touched ≤ required ≤ 8` keeps the permille
211    /// within `u16`; the `expect` documents the invariant.
212    #[must_use]
213    pub fn evaluate(&self, bits: u8) -> Coverage {
214        let required = self.regions.len();
215        let touched = (0..required).filter(|i| bits & (1 << i) != 0).count();
216        let missed = (0..required).filter(|i| bits & (1 << i) == 0).collect();
217        let permille = u16::try_from(touched * usize::from(FULL_COVERAGE_PERMILLE) / required)
218            .expect("touched ≤ required ≤ 8 keeps permille ≤ 1000");
219        Coverage {
220            required,
221            touched,
222            permille,
223            met: permille >= self.min_permille,
224            missed,
225        }
226    }
227}
228
229/// The verdict [`Contract::evaluate`] returns: how much of the contract
230/// was reached, whether that satisfies the author, and — the honest
231/// half — exactly which regions nobody touched.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
233pub struct Coverage {
234    /// Declared region count.
235    pub required: usize,
236    /// Regions with at least one touch.
237    pub touched: usize,
238    /// `touched / required`, in permille.
239    pub permille: u16,
240    /// `permille >= min_permille`.
241    pub met: bool,
242    /// Indexes (declaration order) of untouched regions — the misses,
243    /// nameable via [`Region::label`].
244    pub missed: Vec<usize>,
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn region(start: u32, end: u32) -> Region {
252        Region::new(None, start, end, 0).unwrap()
253    }
254
255    #[test]
256    fn construction_rejects_what_the_spec_rejects() {
257        assert_eq!(Contract::new(vec![], 1000), Err(ContractError::NoRegions));
258        let nine = (1..=9).map(|i| region(i * 10, i * 10 + 5)).collect();
259        assert_eq!(
260            Contract::new(nine, 1000),
261            Err(ContractError::TooManyRegions(9))
262        );
263        assert!(matches!(
264            Region::new(None, 0, 5, 3),
265            Err(ContractError::BadRange { index: 3, .. })
266        ));
267        assert!(matches!(
268            Region::new(None, 9, 5, 0),
269            Err(ContractError::BadRange { .. })
270        ));
271        assert_eq!(
272            Contract::new(vec![region(1, 2)], 0),
273            Err(ContractError::BadThreshold(0))
274        );
275        assert_eq!(
276            Contract::new(vec![region(1, 2)], 1001),
277            Err(ContractError::BadThreshold(1001))
278        );
279        assert!(matches!(
280            Region::new(Some("x".repeat(81)), 1, 2, 5),
281            Err(ContractError::LabelTooLong(5))
282        ));
283    }
284
285    #[test]
286    fn touch_bits_are_declaration_order_indexed() {
287        let c = Contract::new(vec![region(10, 20), region(30, 40), region(50, 60)], 1000).unwrap();
288        assert_eq!(c.touched_by_span(1, 9), 0b000);
289        assert_eq!(c.touched_by_span(15, 35), 0b011);
290        assert_eq!(c.touched_by_span(20, 30), 0b011, "inclusive at both edges");
291        assert_eq!(c.touched_by_line(50), 0b100);
292        assert_eq!(c.touched_by_span(1, 100), 0b111);
293    }
294
295    #[test]
296    fn evaluate_thresholds_and_misses() {
297        let c = Contract::new(vec![region(1, 10), region(20, 30), region(40, 50)], 667).unwrap();
298        let none = c.evaluate(0);
299        assert!(!none.met);
300        assert_eq!(none.missed, vec![0, 1, 2]);
301        let two = c.evaluate(0b101);
302        assert_eq!(two.touched, 2);
303        assert_eq!(two.permille, 666);
304        assert!(!two.met, "666 < 667 — permille floors, honestly");
305        let all = c.evaluate(0b111);
306        assert!(all.met);
307        assert!(all.missed.is_empty());
308        assert_eq!(all.permille, 1000);
309    }
310
311    #[test]
312    fn serde_validates_on_the_way_in_and_roundtrips() {
313        let c = Contract::new(
314            vec![Region::new(Some("Pricing".into()), 847, 920, 0).unwrap()],
315            900,
316        )
317        .unwrap();
318        let json = serde_json::to_string(&c).unwrap();
319        let back: Contract = serde_json::from_str(&json).unwrap();
320        assert_eq!(back, c);
321        // min-permille defaults to full coverage when omitted.
322        let d: Contract = serde_json::from_str(r#"{"regions":[{"start":1,"end":5}]}"#).unwrap();
323        assert_eq!(d.min_permille(), FULL_COVERAGE_PERMILLE);
324        // Invalid wire shapes never become domain values.
325        assert!(serde_json::from_str::<Contract>(r#"{"regions":[]}"#).is_err());
326        assert!(serde_json::from_str::<Contract>(r#"{"regions":[{"start":9,"end":5}]}"#).is_err());
327    }
328}