Skip to main content

sim_lib_bridge/
loom_validate.rs

1use std::collections::BTreeSet;
2
3use sim_codec_bridge::{BridgePacket, BridgeWeavePayload, validate_weave_payload};
4use sim_kernel::{Cx, Expr, Result, Symbol};
5use sim_value::access::field;
6
7use crate::frontier::{FrontierMenu, frontier};
8use crate::{BridgeObligation, BridgeReport};
9
10/// One row-scoped LOOM validation defect.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct LoomObligation {
13    /// Stable row path where the defect was found.
14    pub path: String,
15    /// Human-facing reason.
16    pub reason: String,
17    /// Expected menu, Shape, reference, or capability.
18    pub expected: String,
19    /// Actual row value.
20    pub actual: String,
21    /// Valid replacements for the row position.
22    pub valid_replacements: Vec<String>,
23}
24
25impl LoomObligation {
26    /// Builds a row-scoped LOOM obligation.
27    pub fn new(
28        path: impl Into<String>,
29        reason: impl Into<String>,
30        expected: impl Into<String>,
31        actual: impl Into<String>,
32        valid_replacements: Vec<String>,
33    ) -> Self {
34        Self {
35            path: path.into(),
36            reason: reason.into(),
37            expected: expected.into(),
38            actual: actual.into(),
39            valid_replacements,
40        }
41    }
42
43    /// Projects this LOOM obligation into the shared BRIDGE report format.
44    pub fn to_bridge_obligation(&self) -> BridgeObligation {
45        BridgeObligation::new(
46            self.path.clone(),
47            self.reason.clone(),
48            self.expected.clone(),
49            self.actual.clone(),
50            self.valid_replacements.clone(),
51        )
52    }
53}
54
55/// Validates every `bridge/Weave` part in `packet`, appending row obligations
56/// into `report`.
57pub(crate) fn validate_packet_weaves(
58    cx: &mut Cx,
59    packet: &BridgePacket,
60    report: &mut BridgeReport,
61) -> Result<()> {
62    for part in &packet.body {
63        if part.kind != Symbol::qualified("bridge", "Weave") {
64            continue;
65        }
66        let weave = match validate_weave_payload(&part.payload) {
67            Ok(weave) => weave,
68            Err(err) => {
69                report.reject(&part.id);
70                report.obligate(BridgeObligation::repair_packet(
71                    format!("body/{}/payload", part.id.as_qualified_str()),
72                    "invalid LOOM weave payload",
73                    "derived result-shape agrees with rows",
74                    err.to_string(),
75                ));
76                continue;
77            }
78        };
79        let weave_report = validate_weave(cx, packet, &weave)?;
80        if !weave_report.accepted() {
81            report.reject(&part.id);
82            for obligation in weave_report.obligations {
83                report.obligate(obligation);
84            }
85        }
86    }
87    Ok(())
88}
89
90/// Validates one LOOM weave payload against the shared frontier.
91pub fn validate_weave(
92    cx: &mut Cx,
93    packet: &BridgePacket,
94    weave: &BridgeWeavePayload,
95) -> Result<BridgeReport> {
96    let mut report = BridgeReport::new(
97        packet
98            .header
99            .cid
100            .clone()
101            .unwrap_or_else(|| "unstamped".to_owned()),
102    );
103    let mut available_refs = packet_refs(packet);
104    let all_slots = weave
105        .rows
106        .iter()
107        .map(|row| row.slot.clone())
108        .collect::<BTreeSet<_>>();
109    let mut defined_slots = BTreeSet::new();
110
111    for (index, row) in weave.rows.iter().enumerate() {
112        let row_path = format!("loom/rows/{index}");
113        let menu = next_frontier_menu(cx, packet)?;
114        let heads = head_choices(&menu);
115        let head_names = heads
116            .iter()
117            .map(Symbol::as_qualified_str)
118            .collect::<Vec<_>>();
119        if !heads.contains(&row.head) {
120            report.obligate(
121                LoomObligation::new(
122                    format!("{row_path}/head"),
123                    "LOOM row selected an off-menu head",
124                    head_names.join(", "),
125                    row.head.as_qualified_str(),
126                    head_names.clone(),
127                )
128                .to_bridge_obligation(),
129            );
130        }
131        if !defined_slots.insert(row.slot.clone()) {
132            report.obligate(
133                LoomObligation::new(
134                    format!("{row_path}/slot"),
135                    "LOOM row redefines a slot",
136                    "fresh row slot",
137                    row.slot.clone(),
138                    Vec::new(),
139                )
140                .to_bridge_obligation(),
141            );
142        }
143        if let Some(required) = required_head_capability(&row.head)
144            && !packet.header.ceiling.contains(&required)
145        {
146            report.obligate(
147                LoomObligation::new(
148                    format!("{row_path}/head"),
149                    "LOOM row head requires a missing capability",
150                    required.as_qualified_str(),
151                    format!("{:?}", packet.header.ceiling),
152                    vec![required.as_qualified_str()],
153                )
154                .to_bridge_obligation(),
155            );
156        }
157        for (role, value) in &row.roles {
158            check_role_ref(
159                &mut report,
160                &format!("{row_path}/roles/{}", role.as_qualified_str()),
161                value,
162                &available_refs,
163                &all_slots,
164            );
165        }
166        available_refs.insert(row.slot.clone());
167    }
168
169    Ok(report)
170}
171
172/// Computes the next LOOM frontier menu from the shared BRIDGE frontier engine.
173pub fn next_frontier_menu(cx: &mut Cx, packet: &BridgePacket) -> Result<FrontierMenu> {
174    frontier(cx, packet)
175}
176
177fn packet_refs(packet: &BridgePacket) -> BTreeSet<String> {
178    packet
179        .body
180        .iter()
181        .filter(|part| part.kind != Symbol::qualified("bridge", "Weave"))
182        .map(|part| part.id.as_qualified_str())
183        .chain(packet.header.context.iter().map(Symbol::as_qualified_str))
184        .collect()
185}
186
187fn head_choices(menu: &FrontierMenu) -> Vec<Symbol> {
188    match field(&menu.heads, "choices") {
189        Some(Expr::Vector(items)) | Some(Expr::List(items)) => items
190            .iter()
191            .filter_map(|item| match item {
192                Expr::Symbol(symbol) => Some(symbol.clone()),
193                _ => None,
194            })
195            .collect(),
196        _ => Vec::new(),
197    }
198}
199
200fn required_head_capability(head: &Symbol) -> Option<Symbol> {
201    match head.name.as_ref() {
202        "fetch" => Some(Symbol::qualified("bridge", "fetch")),
203        "patch" => Some(Symbol::qualified("bridge", "patch")),
204        _ => None,
205    }
206}
207
208fn check_role_ref(
209    report: &mut BridgeReport,
210    path: &str,
211    value: &Expr,
212    available_refs: &BTreeSet<String>,
213    all_slots: &BTreeSet<String>,
214) {
215    let Some(reference) = role_ref(value) else {
216        report.obligate(
217            LoomObligation::new(
218                path,
219                "LOOM role binding is not a reference",
220                "symbol or @slot string",
221                format!("{value:?}"),
222                available_refs.iter().cloned().collect(),
223            )
224            .to_bridge_obligation(),
225        );
226        return;
227    };
228    if available_refs.contains(&reference) {
229        return;
230    }
231    let reason = if all_slots.contains(&reference) {
232        "LOOM role binding is a forward reference"
233    } else {
234        "LOOM role binding is dangling"
235    };
236    report.obligate(
237        LoomObligation::new(
238            path,
239            reason,
240            "previous row slot or packet context reference",
241            reference,
242            available_refs.iter().cloned().collect(),
243        )
244        .to_bridge_obligation(),
245    );
246}
247
248fn role_ref(value: &Expr) -> Option<String> {
249    match value {
250        Expr::Symbol(symbol) => Some(symbol.as_qualified_str()),
251        Expr::String(text) => text
252            .strip_prefix('@')
253            .filter(|slot| is_token(slot))
254            .map(str::to_owned),
255        _ => None,
256    }
257}
258
259fn is_token(text: &str) -> bool {
260    !text.is_empty()
261        && text
262            .chars()
263            .all(|ch| ch.is_ascii() && !ch.is_whitespace() && !matches!(ch, '{' | '}'))
264}