use std::collections::{HashMap, HashSet};
use rlx_ir::{Graph, NodeId};
use crate::memory::{BufferSlot, MemoryPlan, collect_view_aliases};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanFindingKind {
LiveOverlap,
ReadAfterOverwrite,
OutputClobbered,
OutOfArena,
OrderViolation,
DuplicateSchedule,
OutputNotScheduled,
}
impl PlanFindingKind {
pub const fn is_program_safety(self) -> bool {
matches!(
self,
Self::LiveOverlap | Self::ReadAfterOverwrite | Self::OutputClobbered | Self::OutOfArena
)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::LiveOverlap => "live-overlap",
Self::ReadAfterOverwrite => "read-after-overwrite",
Self::OutputClobbered => "output-clobbered",
Self::OutOfArena => "out-of-arena",
Self::OrderViolation => "order-violation",
Self::DuplicateSchedule => "duplicate-schedule",
Self::OutputNotScheduled => "output-not-scheduled",
}
}
}
#[derive(Debug, Clone)]
pub struct PlanFinding {
pub kind: PlanFindingKind,
pub node: NodeId,
pub other: Option<NodeId>,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct PlanReport {
pub findings: Vec<PlanFinding>,
pub checked_nodes: usize,
pub aliased_nodes: usize,
}
impl PlanReport {
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
pub fn program_safety(&self) -> impl Iterator<Item = &PlanFinding> {
self.findings.iter().filter(|f| f.kind.is_program_safety())
}
pub fn schedule_semantics(&self) -> impl Iterator<Item = &PlanFinding> {
self.findings.iter().filter(|f| !f.kind.is_program_safety())
}
pub fn render(&self) -> String {
let mut out = String::new();
if self.findings.is_empty() {
out.push_str(&format!(
"plan check: clean over {} assigned node(s) ({} aliased)\n",
self.checked_nodes, self.aliased_nodes
));
if self.checked_nodes == 0 {
out.push_str(
" NOTE: no node had an arena assignment — this plan was not \
checked, not proven safe.\n",
);
}
return out;
}
for f in &self.findings {
let other = f.other.map_or(String::new(), |o| format!(" vs %{}", o.0));
out.push_str(&format!(
" [{}] %{}{other}: {}\n",
f.kind.as_str(),
f.node.0,
f.detail
));
}
out
}
}
fn overlaps(a: &BufferSlot, b: &BufferSlot) -> bool {
a.offset < b.offset + b.size && b.offset < a.offset + a.size
}
pub fn check_plan(graph: &Graph, plan: &MemoryPlan) -> PlanReport {
let mut findings = Vec::new();
let mut step_of: HashMap<NodeId, usize> = HashMap::new();
for (step, id) in plan.schedule.iter().enumerate() {
if let Some(prev) = step_of.insert(*id, step) {
findings.push(PlanFinding {
kind: PlanFindingKind::DuplicateSchedule,
node: *id,
other: None,
detail: format!("scheduled at step {prev} and again at step {step}"),
});
}
}
for (step, id) in plan.schedule.iter().enumerate() {
for input in &graph.node(*id).inputs {
if let Some(&in_step) = step_of.get(input)
&& in_step >= step
{
findings.push(PlanFinding {
kind: PlanFindingKind::OrderViolation,
node: *id,
other: Some(*input),
detail: format!(
"runs at step {step} but reads %{} which runs at step {in_step}",
input.0
),
});
}
}
}
for out in &graph.outputs {
let produced = step_of.contains_key(out);
let is_leaf = graph.node(*out).inputs.is_empty();
if !produced && !is_leaf {
findings.push(PlanFinding {
kind: PlanFindingKind::OutputNotScheduled,
node: *out,
other: None,
detail: "declared as a graph output but never scheduled".to_string(),
});
}
}
let aliases = collect_view_aliases(graph);
let storage_of = |id: NodeId| -> NodeId { aliases.get(&id).map_or(id, |(root, _)| *root) };
for (id, slot) in &plan.assignments {
if slot.offset + slot.size > plan.arena_size {
findings.push(PlanFinding {
kind: PlanFindingKind::OutOfArena,
node: *id,
other: None,
detail: format!(
"slot [{}, {}) extends past arena_size {}",
slot.offset,
slot.offset + slot.size,
plan.arena_size
),
});
}
}
let last_step = plan.schedule.len();
let mut live: HashMap<NodeId, (usize, usize)> = HashMap::new();
for (step, id) in plan.schedule.iter().enumerate() {
let store = storage_of(*id);
live.entry(store).or_insert((step, step)).1 = step;
for input in &graph.node(*id).inputs {
let in_store = storage_of(*input);
let e = live.entry(in_store).or_insert((0, step));
e.1 = e.1.max(step);
}
}
let outputs: HashSet<NodeId> = graph.outputs.iter().map(|o| storage_of(*o)).collect();
for out in &outputs {
if let Some(e) = live.get_mut(out) {
e.1 = last_step;
}
}
let mut slots: Vec<(NodeId, &BufferSlot, (usize, usize))> = plan
.assignments
.iter()
.filter(|(id, _)| storage_of(**id) == **id) .filter_map(|(id, slot)| live.get(id).map(|r| (*id, slot, *r)))
.collect();
slots.sort_by_key(|(_, s, _)| s.offset);
let mut aliased_nodes = 0usize;
for id in plan.assignments.keys() {
if storage_of(*id) != *id {
aliased_nodes += 1;
}
}
for i in 0..slots.len() {
let (id_a, slot_a, (birth_a, death_a)) = slots[i];
for &(id_b, slot_b, (birth_b, death_b)) in slots.iter().skip(i + 1) {
if slot_b.offset >= slot_a.offset + slot_a.size {
break;
}
if !overlaps(slot_a, slot_b) {
continue;
}
let disjoint = death_a < birth_b || death_b < birth_a;
if disjoint {
continue;
}
let (kind, detail) = if outputs.contains(&id_a) || outputs.contains(&id_b) {
(
PlanFindingKind::OutputClobbered,
format!(
"graph-output bytes [{}, {}) overlap [{}, {}) while both are live \
(steps {birth_a}..={death_a} and {birth_b}..={death_b})",
slot_a.offset,
slot_a.offset + slot_a.size,
slot_b.offset,
slot_b.offset + slot_b.size
),
)
} else {
(
PlanFindingKind::LiveOverlap,
format!(
"bytes [{}, {}) overlap [{}, {}) while both are live \
(steps {birth_a}..={death_a} and {birth_b}..={death_b})",
slot_a.offset,
slot_a.offset + slot_a.size,
slot_b.offset,
slot_b.offset + slot_b.size
),
)
};
findings.push(PlanFinding {
kind,
node: id_a,
other: Some(id_b),
detail,
});
}
}
for (step, id) in plan.schedule.iter().enumerate() {
for input in &graph.node(*id).inputs {
let in_store = storage_of(*input);
let Some(in_slot) = plan.assignments.get(&in_store) else {
continue;
};
for (w_step, writer) in plan.schedule.iter().enumerate() {
if w_step >= step {
break;
}
let w_store = storage_of(*writer);
if w_store == in_store {
continue;
}
let Some(w_slot) = plan.assignments.get(&w_store) else {
continue;
};
if !overlaps(in_slot, w_slot) {
continue;
}
if live
.get(&in_store)
.is_some_and(|(birth, _)| *birth < w_step)
{
findings.push(PlanFinding {
kind: PlanFindingKind::ReadAfterOverwrite,
node: *id,
other: Some(*writer),
detail: format!(
"reads %{} at step {step}, but %{} wrote overlapping bytes at \
step {w_step}",
input.0, writer.0
),
});
}
}
}
}
let checked_nodes = plan.assignments.len();
PlanReport {
findings,
checked_nodes,
aliased_nodes,
}
}