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;