Skip to main content

logicaffeine_compile/page_host/
ledger.rs

1//! `lgx_known_divergences.txt` — the shrink-only record of every known tier divergence and
2//! refusal, one file for every pair of tiers the locks compare. A row's TAG names its pair
3//! ([`LedgerTier`]): plain rows are `lowered_vm_tier_lock`'s (the reference against the lowered VM),
4//! `browser:` rows `browser_tier_lock`'s (against a real Chrome). ONE reader, so the locks cannot
5//! drift on what a row means, which lock a row belongs to, or when a row counts as genuinely stale.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Which comparison a ledger row records — named on the row by its tag.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub enum LedgerTier {
12    /// No tag: the reference against the page's lowered LOGOS on the bytecode VM.
13    LoweredVm,
14    /// `browser:` — the reference against the page in a real Chrome.
15    Browser,
16}
17
18impl LedgerTier {
19    /// Every tier a row can belong to.
20    pub const ALL: [LedgerTier; 2] = [LedgerTier::LoweredVm, LedgerTier::Browser];
21
22    /// The tag a row of this tier carries before its axis (`browser` in `browser:document`), or
23    /// `None` for the one untagged tier.
24    pub fn tag(self) -> Option<&'static str> {
25        match self {
26            LedgerTier::LoweredVm => None,
27            LedgerTier::Browser => Some("browser"),
28        }
29    }
30}
31
32/// One parsed ledger line.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct LedgerRow {
35    pub scenario: String,
36    /// The comparison this row belongs to, read from its tag.
37    pub tier: LedgerTier,
38    /// The axis name, or `refused` — the tag, if any, already stripped.
39    pub kind: String,
40    pub statement: usize,
41    pub cause: String,
42}
43
44/// Parse the ledger's text into every row: three `|`-separated fields
45/// (`scenario | [tag:]kind @ statement | cause`), at most one tag and only a [`LedgerTier`]'s own, a
46/// parseable statement number, a non-empty cause, and a `kind` that is `refused` or a real
47/// [`super::Axis`] name. Panics naming the exact line on a malformed row — this runs at test time,
48/// checking an invariant, never a runtime path.
49pub fn parse_ledger(text: &str) -> Vec<LedgerRow> {
50    let mut rows = Vec::new();
51    for (n, line) in text.lines().enumerate() {
52        let line = line.trim();
53        if line.is_empty() || line.starts_with('#') {
54            continue;
55        }
56        let parts: Vec<&str> = line.split(" | ").collect();
57        assert_eq!(
58            parts.len(),
59            3,
60            "ledger line {}: `scenario | kind @ statement | cause`: {line}",
61            n + 1
62        );
63        let (kind_field, at) = parts[1]
64            .split_once(" @ ")
65            .unwrap_or_else(|| panic!("ledger line {}: `kind @ statement`: {line}", n + 1));
66        let statement: usize = at
67            .trim()
68            .parse()
69            .unwrap_or_else(|_| panic!("ledger line {}: a statement number: {line}", n + 1));
70        let kind_field = kind_field.trim();
71        let (tier, kind) = match kind_field.split_once(':') {
72            None => (LedgerTier::LoweredVm, kind_field.to_string()),
73            Some((tag, axis)) => {
74                let tier = LedgerTier::ALL
75                    .into_iter()
76                    .find(|t| t.tag() == Some(tag))
77                    .unwrap_or_else(|| {
78                        panic!(
79                            "ledger line {}: `{tag}:` is not a ledger tag: {line}",
80                            n + 1
81                        )
82                    });
83                assert!(
84                    !axis.contains(':'),
85                    "ledger line {}: `{kind_field}` carries more than one tag",
86                    n + 1
87                );
88                (tier, axis.to_string())
89            }
90        };
91        assert!(
92            kind == "refused" || super::Axis::from_name(&kind).is_some(),
93            "ledger line {}: `{kind_field}` names neither `refused` nor a real axis",
94            n + 1
95        );
96        let cause = parts[2].trim();
97        assert!(
98            !cause.is_empty(),
99            "ledger line {}: every entry says why: {line}",
100            n + 1
101        );
102        rows.push(LedgerRow {
103            scenario: parts[0].trim().to_string(),
104            tier,
105            kind,
106            statement,
107            cause: cause.to_string(),
108        });
109    }
110    rows
111}
112
113/// `rows` of one tier, collected into the `(scenario, kind, statement) -> cause` map that tier's
114/// lock compares what it found against. Panics on a duplicate key WITHIN the tier — two tiers'
115/// rows sharing a scenario/kind/statement are not a collision, since each lock reads only its own.
116pub fn ledger_map(
117    rows: &[LedgerRow],
118    tier: LedgerTier,
119) -> BTreeMap<(String, String, usize), String> {
120    let mut out = BTreeMap::new();
121    for row in rows.iter().filter(|r| r.tier == tier) {
122        let key = (row.scenario.clone(), row.kind.clone(), row.statement);
123        assert!(
124            out.insert(key.clone(), row.cause.clone()).is_none(),
125            "duplicate ledger entry {key:?}"
126        );
127    }
128    out
129}
130
131/// The ledger rows a run's OWN results say have genuinely closed — narrower than "not in
132/// `found`": a row whose scenario never ran far enough to be checked at all (its page did not
133/// parse, or the scenario itself refused at statement 0, before any axis was ever compared) is not
134/// evidence the divergence closed, only that THIS run could not see it. Left alone until a run
135/// that actually reaches it says otherwise, rather than deleting a real, still-true cause out from
136/// under whoever needs it back the moment the page parses or the scenario runs again.
137///
138/// `unparsed_pages` names each page (by the id its OWN scenarios are prefixed with, `<id>::…`)
139/// whose source did not parse at all; `refused_at_zero` names each scenario (`<page id>::<test>`)
140/// refused at statement 0. A scenario refused at a LATER statement is not exempted at all — every
141/// statement before that refusal DID run, so a row at an earlier statement is checked normally.
142///
143/// Shared by every tier's lock (over that tier's own [`ledger_map`]) so the SAME rule protects all
144/// of them: an unparsed page or an early refusal can mark no lock's rows stale.
145pub fn stale_rows(
146    known: &BTreeMap<(String, String, usize), String>,
147    found: &BTreeMap<(String, String, usize), String>,
148    unparsed_pages: &[String],
149    refused_at_zero: &BTreeSet<String>,
150) -> Vec<(String, String, usize)> {
151    known
152        .keys()
153        .filter(|k| !found.contains_key(*k))
154        .filter(|(id, ..)| {
155            !refused_at_zero.contains(id)
156                && !unparsed_pages.iter().any(|p| {
157                    id.strip_prefix(p.as_str())
158                        .is_some_and(|rest| rest.starts_with("::"))
159                })
160        })
161        .cloned()
162        .collect()
163}
164
165#[cfg(test)]
166#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/page_host/ledger/stale_rows_tests.rs"]
167mod stale_rows_tests;
168
169#[cfg(test)]
170#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/page_host/ledger/tag_tests.rs"]
171mod tag_tests;