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#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct LoomObligation {
13 pub path: String,
15 pub reason: String,
17 pub expected: String,
19 pub actual: String,
21 pub valid_replacements: Vec<String>,
23}
24
25impl LoomObligation {
26 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 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
55pub(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
90pub 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
172pub 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}