Skip to main content

bookspec_checker/
derive.rs

1//! The specification's own checker (`plan/served.md` P13 step 5):
2//! every example's expected figures derived from its rule's symbolic
3//! form, by an engine-free reference model with exact decimals.
4//!
5//! The runner asks an *engine* whether it agrees with an example. This
6//! module asks whether the *example* agrees with the specification: it
7//! replays the example's events on the smallest ledger the rules of
8//! `bookspec/spec/*.md` describe (entries, reversals, a close, a bill, an
9//! invoice) and computes each expectation it can from the symbolic forms
10//! (`sum` of signed values, normal balances, the accounting equation,
11//! net income). An expectation whose figure disagrees with the
12//! derivation, or an event whose refusal the rules do not justify, fails
13//! the specification's build, before any engine is consulted.
14//!
15//! What the model does not cover (depreciation, schedules, members,
16//! 1099s, formats, the cash-flow sections) is not derived here: an
17//! expectation is [`Derived::Skipped`] with the reason, never silently
18//! passed, and the example is still checked by the engines.
19
20use std::collections::BTreeMap;
21use std::str::FromStr;
22
23use chrono::NaiveDate;
24use rust_decimal::Decimal;
25
26use crate::engine::RuleId;
27use crate::rules::Area;
28use crate::schema::{Event, Example, Expect, Values};
29
30/// What the derivation found of one expectation or event.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum Derived {
33    /// The specification derives the example's figures, and they agree.
34    Agrees {
35        /// The rule whose symbolic form derived them.
36        rule: RuleId,
37    },
38    /// The specification derives different figures, or does not justify
39    /// a refusal: the example is wrong.
40    Disagrees {
41        /// The rule whose symbolic form was applied.
42        rule: RuleId,
43        /// What differs, in words.
44        why: String,
45    },
46    /// The reference model does not cover it.
47    Skipped {
48        /// Why not.
49        why: String,
50    },
51}
52
53/// The derivation of one example: one result per event that was refused
54/// and per expectation.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Derivation {
57    /// The example's id.
58    pub example: String,
59    /// What was derived, in the order met.
60    pub results: Vec<(String, Derived)>,
61}
62
63impl Derivation {
64    /// The disagreements, each as a sentence.
65    pub fn disagreements(&self) -> Vec<String> {
66        self.results
67            .iter()
68            .filter_map(|(what, d)| match d {
69                Derived::Disagrees { rule, why } => {
70                    Some(format!("{}: {what}: {why} ({rule})", self.example))
71                }
72                _ => None,
73            })
74            .collect()
75    }
76
77    /// How many results agree.
78    pub fn agreed(&self) -> usize {
79        self.results
80            .iter()
81            .filter(|(_, d)| matches!(d, Derived::Agrees { .. }))
82            .count()
83    }
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87enum Side {
88    Debit,
89    Credit,
90}
91
92#[derive(Clone, Debug)]
93struct Line {
94    account: String,
95    side: Side,
96    amount: Decimal,
97}
98
99#[derive(Clone, Debug)]
100struct Posted {
101    date: NaiveDate,
102    lines: Vec<Line>,
103    closing: bool,
104    reversed: bool,
105}
106
107/// The reference model: posted entries, the closed periods, the types.
108struct Model {
109    types: BTreeMap<String, String>,
110    entries: Vec<Posted>,
111    /// Event number (from 1) to the entry it posted.
112    event_entry: BTreeMap<usize, usize>,
113    closed_through: Vec<NaiveDate>,
114    minor_digits: u32,
115}
116
117fn sign(side: Side) -> Decimal {
118    match side {
119        Side::Debit => Decimal::ONE,
120        Side::Credit => -Decimal::ONE,
121    }
122}
123
124fn dec(s: &str) -> Option<Decimal> {
125    Decimal::from_str(s).ok()
126}
127
128fn date(s: &str) -> Option<NaiveDate> {
129    NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
130}
131
132fn rule(area: Area, n: u32) -> RuleId {
133    RuleId::new(area, n)
134}
135
136/// The accounts the books create for a document or a close, with their
137/// types (rules.md B-23's default roles).
138fn role_type(code: &str) -> Option<&'static str> {
139    match code {
140        "1200" => Some("asset"),
141        "2000" => Some("liability"),
142        "3999" => Some("equity"),
143        "4000" => Some("revenue"),
144        "6999" => Some("expense"),
145        _ => None,
146    }
147}
148
149impl Model {
150    fn new(ex: &Example) -> Self {
151        let mut types: BTreeMap<String, String> = ex
152            .setup
153            .accounts
154            .iter()
155            .map(|a| (a.code.clone(), a.kind.clone()))
156            .collect();
157        for code in ["1200", "2000", "3999", "4000", "6999"] {
158            types
159                .entry(code.to_string())
160                .or_insert_with(|| role_type(code).unwrap().to_string());
161        }
162        // ISO 4217: the currency's minor-unit digits (two unless listed).
163        let minor_digits = match ex.setup.currency.as_str() {
164            "JPY" | "KRW" | "VND" | "CLP" | "ISK" => 0,
165            "BHD" | "KWD" | "OMR" | "JOD" => 3,
166            _ => 2,
167        };
168        Self {
169            types,
170            entries: Vec::new(),
171            event_entry: BTreeMap::new(),
172            closed_through: Vec::new(),
173            minor_digits,
174        }
175    }
176
177    /// Signed sum of an account's lines dated on or before `through`
178    /// (debit positive), counting entries that stand as written: the
179    /// reversal is its own entry, so the original is always counted.
180    fn signed(
181        &self,
182        code: &str,
183        from: Option<NaiveDate>,
184        through: NaiveDate,
185        closings: bool,
186    ) -> Decimal {
187        self.entries
188            .iter()
189            .filter(|e| {
190                e.date <= through && from.is_none_or(|f| e.date >= f) && (closings || !e.closing)
191            })
192            .flat_map(|e| e.lines.iter())
193            .filter(|l| l.account == code)
194            .map(|l| sign(l.side) * l.amount)
195            .sum()
196    }
197
198    fn is_debit_normal(&self, code: &str) -> bool {
199        matches!(
200            self.types.get(code).map(String::as_str),
201            Some("asset") | Some("expense")
202        )
203    }
204
205    fn on_normal_side(&self, code: &str, signed: Decimal) -> Decimal {
206        if self.is_debit_normal(code) {
207            signed
208        } else {
209            -signed
210        }
211    }
212
213    /// Why the rules refuse this entry, if they do: (reason, rule).
214    fn refusal_justified(&self, e: &Event, reason: &str) -> Result<RuleId, String> {
215        let on = |side: &str| -> Vec<Decimal> {
216            e.lines
217                .iter()
218                .filter(|l| l.side == side)
219                .filter_map(|l| dec(&l.amount))
220                .collect()
221        };
222        let d: Decimal = on("debit").into_iter().sum();
223        let c: Decimal = on("credit").into_iter().sum();
224        let amounts: Vec<Decimal> = e.lines.iter().filter_map(|l| dec(&l.amount)).collect();
225        match reason {
226            "unbalanced" if d != c => Ok(rule(Area::Entry, 1)),
227            "unbalanced" => Err(format!("debits {d} equal credits {c}")),
228            "too_few_lines" if e.lines.len() < 2 => Ok(rule(Area::Entry, 1)),
229            "negative_amount" if amounts.iter().any(|a| *a < Decimal::ZERO) => {
230                Ok(rule(Area::Entry, 2))
231            }
232            "zero_amount" if amounts.iter().any(|a| a.is_zero()) => Ok(rule(Area::Entry, 2)),
233            "finer_than_minor_unit"
234                if amounts
235                    .iter()
236                    .any(|a| a.normalize().scale() > self.minor_digits) =>
237            {
238                Ok(rule(Area::Ledger, 1))
239            }
240            "period_closed" => {
241                let day = e.date.as_deref().and_then(date).ok_or("no date")?;
242                if self.closed_through.iter().any(|end| day <= *end) {
243                    Ok(rule(Area::Period, 2))
244                } else {
245                    Err(format!("{day} is in no closed period"))
246                }
247            }
248            "already_reversed" => {
249                let of = e.reverses.ok_or("no reversal named")?;
250                let idx = self.event_entry.get(&of).ok_or("names no entry")?;
251                if self.entries[*idx].reversed {
252                    Ok(rule(Area::Ledger, 6))
253                } else {
254                    Err(format!("event {of} was not yet reversed"))
255                }
256            }
257            other => Err(format!("{other:?} is not derived by the reference model")),
258        }
259    }
260
261    /// Post an accepted event; `Err` says what the model cannot post.
262    fn post(&mut self, n: usize, e: &Event) -> Result<(), String> {
263        let day = e.date.as_deref().and_then(date);
264        let lines = |ls: &[crate::schema::LineSpec]| -> Option<Vec<Line>> {
265            ls.iter()
266                .map(|l| {
267                    Some(Line {
268                        account: l.account.clone(),
269                        side: if l.side == "debit" {
270                            Side::Debit
271                        } else {
272                            Side::Credit
273                        },
274                        amount: dec(&l.amount)?,
275                    })
276                })
277                .collect()
278        };
279        let posted = if !e.lines.is_empty() {
280            Posted {
281                date: day.ok_or("undated")?,
282                lines: lines(&e.lines).ok_or("an unreadable amount")?,
283                closing: false,
284                reversed: false,
285            }
286        } else if let Some(of) = e.reverses {
287            let idx = *self
288                .event_entry
289                .get(&of)
290                .ok_or("reverses an event that posted nothing")?;
291            let original = self.entries[idx].clone();
292            self.entries[idx].reversed = true;
293            Posted {
294                date: day.ok_or("undated")?,
295                lines: original
296                    .lines
297                    .iter()
298                    .map(|l| Line {
299                        account: l.account.clone(),
300                        side: match l.side {
301                            Side::Debit => Side::Credit,
302                            Side::Credit => Side::Debit,
303                        },
304                        amount: l.amount,
305                    })
306                    .collect(),
307                closing: false,
308                reversed: false,
309            }
310        } else if let Some(b) = &e.bill {
311            let total = document_total(
312                b.amount.as_deref(),
313                &b.lines.iter().map(|l| l.amount.clone()).collect::<Vec<_>>(),
314            )?;
315            if b.lines.iter().any(|l| l.project.is_some()) {
316                return Err("project tags are not modelled".into());
317            }
318            Posted {
319                date: day.ok_or("undated")?,
320                lines: vec![
321                    Line {
322                        account: "6999".into(),
323                        side: Side::Debit,
324                        amount: total,
325                    },
326                    Line {
327                        account: "2000".into(),
328                        side: Side::Credit,
329                        amount: total,
330                    },
331                ],
332                closing: false,
333                reversed: false,
334            }
335        } else if let Some(i) = &e.invoice {
336            let total = document_total(
337                i.amount.as_deref(),
338                &i.lines.iter().map(|l| l.amount.clone()).collect::<Vec<_>>(),
339            )?;
340            if i.lines.iter().any(|l| l.project.is_some()) {
341                return Err("project tags are not modelled".into());
342            }
343            Posted {
344                date: day.ok_or("undated")?,
345                lines: vec![
346                    Line {
347                        account: "1200".into(),
348                        side: Side::Debit,
349                        amount: total,
350                    },
351                    Line {
352                        account: "4000".into(),
353                        side: Side::Credit,
354                        amount: total,
355                    },
356                ],
357                closing: false,
358                reversed: false,
359            }
360        } else if let Some(c) = &e.close {
361            if c.to != "retained_earnings" {
362                return Err("a close to the members is not modelled".into());
363            }
364            let end = day.ok_or("undated")?;
365            let mut out = Vec::new();
366            let mut net = Decimal::ZERO;
367            let codes: Vec<String> = self.types.keys().cloned().collect();
368            for code in codes {
369                let ty = self.types[&code].as_str();
370                if ty != "revenue" && ty != "expense" {
371                    continue;
372                }
373                let s = self.signed(&code, None, end, true);
374                if s.is_zero() {
375                    continue;
376                }
377                net -= s; // net income is credit-normal: revenue - expense
378                out.push(Line {
379                    account: code,
380                    side: if s > Decimal::ZERO {
381                        Side::Credit
382                    } else {
383                        Side::Debit
384                    },
385                    amount: s.abs(),
386                });
387            }
388            if !net.is_zero() {
389                out.push(Line {
390                    account: "3999".into(),
391                    side: if net > Decimal::ZERO {
392                        Side::Credit
393                    } else {
394                        Side::Debit
395                    },
396                    amount: net.abs(),
397                });
398            }
399            self.closed_through.push(end);
400            Posted {
401                date: end,
402                lines: out,
403                closing: true,
404                reversed: false,
405            }
406        } else {
407            return Err("an event the reference model does not post".into());
408        };
409        // The symbolic form of BS-entry-1: what is posted balances.
410        let (d, c): (Decimal, Decimal) =
411            posted
412                .lines
413                .iter()
414                .fold((Decimal::ZERO, Decimal::ZERO), |(d, c), l| match l.side {
415                    Side::Debit => (d + l.amount, c),
416                    Side::Credit => (d, c + l.amount),
417                });
418        if d != c {
419            return Err(format!(
420                "the example posts an unbalanced entry (debits {d}, credits {c})"
421            ));
422        }
423        self.entries.push(posted);
424        self.event_entry.insert(n, self.entries.len() - 1);
425        Ok(())
426    }
427}
428
429fn document_total(amount: Option<&str>, lines: &[String]) -> Result<Decimal, String> {
430    match amount {
431        Some(a) => dec(a).ok_or_else(|| "an unreadable amount".into()),
432        None => lines
433            .iter()
434            .map(|l| dec(l).ok_or_else(|| "an unreadable amount".to_string()))
435            .sum(),
436    }
437}
438
439/// Derive what the specification says about `ex`.
440pub fn derive(ex: &Example) -> Derivation {
441    let mut results = Vec::new();
442    let mut model = Model::new(ex);
443    // The expectations asked after event N are checked right after it; the
444    // rest at the end. The model keeps one state per point, so replay.
445    let mut usable = true;
446    let mut why_not = String::new();
447    let unsupported_setup = !ex.setup.assets.is_empty()
448        || !ex.setup.schedules.is_empty()
449        || !ex.setup.fixed_assets.is_empty()
450        || !ex.setup.members.is_empty();
451    if unsupported_setup {
452        usable = false;
453        why_not = "the setup holds assets, schedules or members, which the reference model does not carry".into();
454    }
455    for (i, e) in ex.events.iter().enumerate() {
456        let n = i + 1;
457        if usable {
458            match &e.refused {
459                Some(reason) => match model.refusal_justified(e, reason) {
460                    Ok(rule) => results.push((
461                        format!("event {n} refused {reason}"),
462                        Derived::Agrees { rule },
463                    )),
464                    Err(why) => results.push((
465                        format!("event {n} refused {reason}"),
466                        if why.contains("not derived") {
467                            Derived::Skipped { why }
468                        } else {
469                            Derived::Disagrees {
470                                rule: rule(Area::Entry, 1),
471                                why,
472                            }
473                        },
474                    )),
475                },
476                None => {
477                    if let Err(why) = model.post(n, e) {
478                        usable = false;
479                        why_not = format!("event {n}: {why}");
480                    }
481                }
482            }
483        }
484        for x in ex.expect.iter().filter(|x| x.after == Some(n)) {
485            results.push(expectation(&model, x, usable, &why_not));
486        }
487    }
488    for x in ex.expect.iter().filter(|x| x.after.is_none()) {
489        results.push(expectation(&model, x, usable, &why_not));
490    }
491    Derivation {
492        example: ex.id.clone(),
493        results,
494    }
495}
496
497fn expectation(model: &Model, x: &Expect, usable: bool, why_not: &str) -> (String, Derived) {
498    let what = format!(
499        "{} {}",
500        x.query,
501        x.as_of.as_deref().or(x.to.as_deref()).unwrap_or("")
502    );
503    if !usable {
504        return (
505            what,
506            Derived::Skipped {
507                why: why_not.to_string(),
508            },
509        );
510    }
511    let Some(Values::Figures(want)) = &x.values else {
512        return (
513            what,
514            Derived::Skipped {
515                why: "no figures to derive".into(),
516            },
517        );
518    };
519    let derived = match x.query.as_str() {
520        "trial_balance" => x.as_of.as_deref().and_then(date).map(|d| {
521            (
522                rule(Area::Statement, 1),
523                want.keys()
524                    .map(|k| (k.clone(), model.signed(k, None, d, true)))
525                    .collect::<Vec<_>>(),
526            )
527        }),
528        "balances" => x.as_of.as_deref().and_then(date).map(|d| {
529            (
530                rule(Area::Ledger, 3),
531                want.keys()
532                    .map(|k| {
533                        (
534                            k.clone(),
535                            model.on_normal_side(k, model.signed(k, None, d, true)),
536                        )
537                    })
538                    .collect(),
539            )
540        }),
541        "profit_and_loss" => {
542            let from = x.from.as_deref().and_then(date);
543            let to = x.to.as_deref().and_then(date);
544            from.zip(to).map(|(f, t)| {
545                let mut figs = Vec::new();
546                for k in want.keys() {
547                    if k == "net_income" {
548                        let mut net = Decimal::ZERO;
549                        for (code, ty) in &model.types {
550                            if ty == "revenue" || ty == "expense" {
551                                net += model
552                                    .on_normal_side(code, model.signed(code, Some(f), t, false))
553                                    * if ty == "revenue" {
554                                        Decimal::ONE
555                                    } else {
556                                        -Decimal::ONE
557                                    };
558                            }
559                        }
560                        figs.push((k.clone(), net));
561                    } else {
562                        figs.push((
563                            k.clone(),
564                            model.on_normal_side(k, model.signed(k, Some(f), t, false)),
565                        ));
566                    }
567                }
568                (rule(Area::Statement, 3), figs)
569            })
570        }
571        "entry" => x.entry.and_then(|n| model.event_entry.get(&n)).map(|idx| {
572            let e = &model.entries[*idx];
573            let figs = want
574                .keys()
575                .map(|k| {
576                    let (code, side) = k.split_once('.').unwrap_or((k, ""));
577                    let v: Decimal = e
578                        .lines
579                        .iter()
580                        .filter(|l| {
581                            l.account == code
582                                && matches!(
583                                    (side, l.side),
584                                    ("debit", Side::Debit) | ("credit", Side::Credit)
585                                )
586                        })
587                        .map(|l| l.amount)
588                        .sum();
589                    (k.clone(), v)
590                })
591                .collect();
592            (rule(Area::Ledger, 4), figs)
593        }),
594        other => {
595            return (
596                what,
597                Derived::Skipped {
598                    why: format!("{other} is not derived by the reference model"),
599                },
600            );
601        }
602    };
603    let Some((rule, figs)) = derived else {
604        return (
605            what,
606            Derived::Skipped {
607                why: "an undated or unresolved query".into(),
608            },
609        );
610    };
611    for (k, got) in figs {
612        match want.get(&k).and_then(|w| dec(w)) {
613            Some(w) if w == got => {}
614            Some(w) => {
615                return (
616                    what,
617                    Derived::Disagrees {
618                        rule,
619                        why: format!("{k}: the example says {w}, the rule derives {got}"),
620                    },
621                );
622            }
623            None => {
624                return (
625                    what,
626                    Derived::Disagrees {
627                        rule,
628                        why: format!("{k}: no exact figure"),
629                    },
630                );
631            }
632        }
633    }
634    // The laws on the whole book, at the day asked: BS-statement-2.
635    if let (true, Some(d)) = (
636        matches!(x.query.as_str(), "trial_balance" | "balances"),
637        x.as_of.as_deref().and_then(date),
638    ) {
639        let (mut a, mut l, mut e, mut r, mut xp) = (
640            Decimal::ZERO,
641            Decimal::ZERO,
642            Decimal::ZERO,
643            Decimal::ZERO,
644            Decimal::ZERO,
645        );
646        let mut net = Decimal::ZERO;
647        for (code, ty) in &model.types {
648            let s = model.signed(code, None, d, true);
649            net += s;
650            let n = model.on_normal_side(code, s);
651            match ty.as_str() {
652                "asset" => a += n,
653                "liability" => l += n,
654                "equity" => e += n,
655                "revenue" => r += n,
656                "expense" => xp += n,
657                _ => {}
658            }
659        }
660        if !net.is_zero() {
661            return (
662                what,
663                Derived::Disagrees {
664                    rule: self::rule(Area::Statement, 1),
665                    why: format!("the books do not net to zero ({net})"),
666                },
667            );
668        }
669        if a != l + e + (r - xp) {
670            return (
671                what,
672                Derived::Disagrees {
673                    rule: self::rule(Area::Statement, 2),
674                    why: format!("A {a} != L {l} + E {e} + (R {r} - X {xp})"),
675                },
676            );
677        }
678    }
679    (what, Derived::Agrees { rule })
680}