Skip to main content

bitloom_sim/
coverage.rs

1//! Stable, parseable simulation coverage (FR34 toggle + FR105 branch + FR109 FSM).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5/// Toggle + Mux branch + FSM state-visit coverage over named signals / labels.
6#[derive(Debug, Clone, Default)]
7pub struct Coverage {
8    prev: BTreeMap<String, u64>,
9    toggled: BTreeSet<String>,
10    seen: BTreeSet<String>,
11    /// Decision points `mux:<sel>:t` / `mux:<sel>:f` that were taken at least once.
12    branch_hits: BTreeSet<String>,
13    /// Decision points registered (from Mux eval) but never taken.
14    branch_seen: BTreeSet<String>,
15    /// FSM state ids `fsm:<name>:<State>` visited at least once (FR109 / C3).
16    state_hits: BTreeSet<String>,
17    /// FSM states registered for visit tracking.
18    state_seen: BTreeSet<String>,
19}
20
21impl Coverage {
22    pub fn sample(&mut self, name: impl Into<String>, value: u64) {
23        let name = name.into();
24        self.seen.insert(name.clone());
25        if let Some(old) = self.prev.get(&name)
26            && *old != value
27        {
28            self.toggled.insert(name.clone());
29        }
30        self.prev.insert(name, value);
31    }
32
33    /// Register both arms of a Mux and record which arm was taken this eval.
34    pub fn sample_mux_branch(&mut self, sel: &str, took_true: bool) {
35        let t = format!("mux:{sel}:t");
36        let f = format!("mux:{sel}:f");
37        self.branch_seen.insert(t.clone());
38        self.branch_seen.insert(f.clone());
39        if took_true {
40            self.branch_hits.insert(t);
41        } else {
42            self.branch_hits.insert(f);
43        }
44    }
45
46    /// Register an explicit FSM state enum (or label set) for state-visit (FR109 / C3).
47    pub fn register_fsm_states<I, S>(&mut self, fsm: &str, states: I)
48    where
49        I: IntoIterator<Item = S>,
50        S: Into<String>,
51    {
52        for st in states {
53            let id = format!("fsm:{fsm}:{}", st.into());
54            self.state_seen.insert(id);
55        }
56    }
57
58    /// Record that `state` of `fsm` was visited (must be registered first for miss tracking).
59    pub fn sample_state_visit(&mut self, fsm: &str, state: &str) {
60        let id = format!("fsm:{fsm}:{state}");
61        self.state_seen.insert(id.clone());
62        self.state_hits.insert(id);
63    }
64
65    pub fn hits(&self) -> impl Iterator<Item = &str> {
66        self.toggled.iter().map(|s| s.as_str())
67    }
68
69    pub fn misses(&self) -> impl Iterator<Item = &str> {
70        self.seen.difference(&self.toggled).map(|s| s.as_str())
71    }
72
73    pub fn branch_hits(&self) -> impl Iterator<Item = &str> {
74        self.branch_hits.iter().map(|s| s.as_str())
75    }
76
77    pub fn branch_misses(&self) -> impl Iterator<Item = &str> {
78        self.branch_seen
79            .difference(&self.branch_hits)
80            .map(|s| s.as_str())
81    }
82
83    pub fn state_hits(&self) -> impl Iterator<Item = &str> {
84        self.state_hits.iter().map(|s| s.as_str())
85    }
86
87    pub fn state_misses(&self) -> impl Iterator<Item = &str> {
88        self.state_seen
89            .difference(&self.state_hits)
90            .map(|s| s.as_str())
91    }
92
93    /// Stable line-oriented report.
94    ///
95    /// - No FSM registered → `# bitloom-sim coverage v2` (FR105 dialect).
96    /// - FSM registered → `# bitloom-sim coverage v3` + `# FR109 C3 FSM/state-visit`
97    ///   with `state_hit` / `state_miss` lines (toggle + branch retained).
98    pub fn report(&self) -> String {
99        let has_fsm = !self.state_seen.is_empty();
100        let mut out = if has_fsm {
101            String::from("# bitloom-sim coverage v3\n# FR109 C3 FSM/state-visit\n")
102        } else {
103            String::from("# bitloom-sim coverage v2\n")
104        };
105        for n in self.hits() {
106            out.push_str(&format!("hit {n}\n"));
107        }
108        for n in self.misses() {
109            out.push_str(&format!("miss {n}\n"));
110        }
111        for n in self.branch_hits() {
112            out.push_str(&format!("branch_hit {n}\n"));
113        }
114        for n in self.branch_misses() {
115            out.push_str(&format!("branch_miss {n}\n"));
116        }
117        for n in self.state_hits() {
118            out.push_str(&format!("state_hit {n}\n"));
119        }
120        for n in self.state_misses() {
121            out.push_str(&format!("state_miss {n}\n"));
122        }
123        out
124    }
125
126    /// True when no toggle/branch/state points were ever registered.
127    pub fn is_empty(&self) -> bool {
128        self.seen.is_empty() && self.branch_seen.is_empty() && self.state_seen.is_empty()
129    }
130
131    /// Emit LCOV (`coverage.lcov` dialect) for FR114 — one DA line per coverage point.
132    ///
133    /// Hits use `DA:line,1`; misses use `DA:line,0`. Synthetic `SF` path documents the
134    /// Bitloom coverage namespace (not a Rust source map — Tywaves typed IDE is deferred).
135    pub fn to_lcov(&self, source_file: &str) -> String {
136        let mut points: Vec<(String, u64)> = Vec::new();
137        for n in self.hits() {
138            points.push((format!("toggle:{n}"), 1));
139        }
140        for n in self.misses() {
141            points.push((format!("toggle:{n}"), 0));
142        }
143        for n in self.branch_hits() {
144            points.push((n.to_string(), 1));
145        }
146        for n in self.branch_misses() {
147            points.push((n.to_string(), 0));
148        }
149        for n in self.state_hits() {
150            points.push((n.to_string(), 1));
151        }
152        for n in self.state_misses() {
153            points.push((n.to_string(), 0));
154        }
155        points.sort_by(|a, b| a.0.cmp(&b.0));
156
157        let mut out = String::from("TN:bitloom-sim\n");
158        out.push_str(&format!("SF:{source_file}\n"));
159        let mut lh = 0usize;
160        let lf = points.len();
161        for (i, (name, hits)) in points.iter().enumerate() {
162            let line = i + 1;
163            out.push_str(&format!("# {name}\n"));
164            out.push_str(&format!("DA:{line},{hits}\n"));
165            if *hits > 0 {
166                lh += 1;
167            }
168        }
169        out.push_str(&format!("LH:{lh}\n"));
170        out.push_str(&format!("LF:{lf}\n"));
171        out.push_str("end_of_record\n");
172        out
173    }
174
175    /// In-tree Bitloom coverage GUI HTML (FR114) — browse hit/miss with search.
176    pub fn coverage_gui_html(&self, title: &str) -> String {
177        let mut rows = String::new();
178        let mut push_row = |kind: &str, name: &str, hit: bool| {
179            let status = if hit { "hit" } else { "miss" };
180            rows.push_str(&format!(
181                "<tr data-kind=\"{kind}\" data-name=\"{name}\" data-status=\"{status}\">\
182                 <td>{kind}</td><td>{name}</td><td class=\"{status}\">{status}</td></tr>\n"
183            ));
184        };
185        for n in self.hits() {
186            push_row("toggle", n, true);
187        }
188        for n in self.misses() {
189            push_row("toggle", n, false);
190        }
191        for n in self.branch_hits() {
192            push_row("branch", n, true);
193        }
194        for n in self.branch_misses() {
195            push_row("branch", n, false);
196        }
197        for n in self.state_hits() {
198            push_row("state", n, true);
199        }
200        for n in self.state_misses() {
201            push_row("state", n, false);
202        }
203
204        format!(
205            r#"<!DOCTYPE html>
206<html lang="en">
207<head>
208<meta charset="utf-8"/>
209<title>Bitloom coverage — {title}</title>
210<style>
211body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 1.5rem; background: #f7f4ef; color: #1a1a1a; }}
212h1 {{ font-size: 1.4rem; margin: 0 0 0.25rem; }}
213.brand {{ letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.75rem; color: #5c5346; }}
214.hit {{ color: #0b6b3a; font-weight: 600; }}
215.miss {{ color: #8a1f1f; font-weight: 600; }}
216input {{ margin: 0.75rem 0; padding: 0.4rem 0.6rem; width: min(28rem, 100%); }}
217table {{ border-collapse: collapse; width: min(48rem, 100%); background: #fff; }}
218th, td {{ border: 1px solid #d9d2c5; padding: 0.35rem 0.55rem; text-align: left; }}
219th {{ background: #ebe4d8; }}
220</style>
221</head>
222<body data-bitloom-coverage-gui="fr114">
223<p class="brand">Bitloom</p>
224<h1>Coverage GUI — {title}</h1>
225<p>FR114 LCOV companion view (≠ FR104 interactive wave; ≠ Tywaves). Filter by name:</p>
226<input id="cov-search" type="search" placeholder="search coverage points…" />
227<table>
228<thead><tr><th>Kind</th><th>Point</th><th>Status</th></tr></thead>
229<tbody id="cov-body">
230{rows}</tbody>
231</table>
232<script>
233const q = document.getElementById('cov-search');
234const body = document.getElementById('cov-body');
235q.addEventListener('input', () => {{
236  const needle = q.value.toLowerCase();
237  for (const tr of body.querySelectorAll('tr')) {{
238    const name = (tr.getAttribute('data-name') || '').toLowerCase();
239    tr.style.display = !needle || name.includes(needle) ? '' : 'none';
240  }}
241}});
242</script>
243</body>
244</html>
245"#
246        )
247    }
248}
249
250/// Write `coverage.lcov` + `coverage.html` for FR114. Fails if coverage is empty.
251pub fn write_coverage_artifacts(
252    cov: &Coverage,
253    out_dir: &std::path::Path,
254) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
255    use std::fs;
256    if cov.is_empty() {
257        return Err(std::io::Error::new(
258            std::io::ErrorKind::InvalidData,
259            "bitloom-sim.coverage-empty: no coverage points — cannot claim FR114 (LCOV+GUI)",
260        ));
261    }
262    fs::create_dir_all(out_dir)?;
263    let lcov_path = out_dir.join("coverage.lcov");
264    let html_path = out_dir.join("coverage.html");
265    fs::write(&lcov_path, cov.to_lcov("bitloom://coverage/points"))?;
266    fs::write(&html_path, cov.coverage_gui_html("sim"))?;
267    Ok((lcov_path, html_path))
268}
269
270/// Parse FR34-compatible toggle `hit` / `miss` lines (ignores `branch_*` / `state_*`).
271pub fn parse_report(text: &str) -> (Vec<String>, Vec<String>) {
272    let mut hits = Vec::new();
273    let mut misses = Vec::new();
274    for line in text.lines() {
275        if let Some(n) = line.strip_prefix("hit ") {
276            hits.push(n.to_string());
277        } else if let Some(n) = line.strip_prefix("miss ") {
278            misses.push(n.to_string());
279        }
280    }
281    (hits, misses)
282}
283
284/// Parse FR105 `branch_hit` / `branch_miss` lines.
285pub fn parse_branch_report(text: &str) -> (Vec<String>, Vec<String>) {
286    let mut hits = Vec::new();
287    let mut misses = Vec::new();
288    for line in text.lines() {
289        if let Some(n) = line.strip_prefix("branch_hit ") {
290            hits.push(n.to_string());
291        } else if let Some(n) = line.strip_prefix("branch_miss ") {
292            misses.push(n.to_string());
293        }
294    }
295    (hits, misses)
296}
297
298/// Parse FR109 `state_hit` / `state_miss` lines.
299pub fn parse_state_report(text: &str) -> (Vec<String>, Vec<String>) {
300    let mut hits = Vec::new();
301    let mut misses = Vec::new();
302    for line in text.lines() {
303        if let Some(n) = line.strip_prefix("state_hit ") {
304            hits.push(n.to_string());
305        } else if let Some(n) = line.strip_prefix("state_miss ") {
306            misses.push(n.to_string());
307        }
308    }
309    (hits, misses)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn state_visit_hit_and_miss() {
318        let mut c = Coverage::default();
319        c.register_fsm_states("demo", ["Idle", "Busy", "Done"]);
320        c.sample_state_visit("demo", "Idle");
321        c.sample_state_visit("demo", "Busy");
322        let r = c.report();
323        assert!(r.contains("# bitloom-sim coverage v3"));
324        assert!(r.contains("# FR109 C3 FSM/state-visit"));
325        assert!(r.contains("state_hit fsm:demo:Idle"));
326        assert!(r.contains("state_hit fsm:demo:Busy"));
327        assert!(r.contains("state_miss fsm:demo:Done"));
328        let (h, m) = parse_state_report(&r);
329        assert!(h.contains(&"fsm:demo:Idle".into()));
330        assert!(m.contains(&"fsm:demo:Done".into()));
331    }
332
333    #[test]
334    fn lcov_and_gui_nonempty() {
335        let mut c = Coverage::default();
336        c.sample("y", 0);
337        c.sample("y", 1);
338        c.sample_mux_branch("sel", false);
339        let lcov = c.to_lcov("bitloom://coverage/points");
340        assert!(lcov.contains("end_of_record") && lcov.contains("DA:"));
341        let html = c.coverage_gui_html("t");
342        assert!(html.contains("data-bitloom-coverage-gui"));
343    }
344}