Skip to main content

wyrd/authoring/
builder.rs

1//! Typed incremental authoring of a [`Weave`] with owner-scoped handles.
2//!
3//! Handles and ports are tied to one [`WeaveBuilder`] instance so foreign
4//! endpoints cannot be connected across builders. Endpoint resolution checks
5//! catalog port names and directions before threads are recorded. Final
6//! structural checks (cycles, fan-in, budgets) run in [`WeaveBuilder::build`].
7
8use core::sync::atomic::{AtomicUsize, Ordering};
9
10use std::collections::BTreeMap;
11use std::string::String;
12use std::vec::Vec;
13
14use crate::foundation::{
15    port_domain, port_slot, ports_of, KnotKind, NumericPath, PortDir, PortDomain, PortSlot,
16};
17
18use crate::authoring::pattern::{expand, Pattern};
19use crate::{BuildError, KnotDef, PortRefDef, ThreadDef, ValidationError, Weave, WeaveDef};
20
21static NEXT_OWNER: AtomicUsize = AtomicUsize::new(1);
22
23/// Opaque knot reference valid only for the builder that created it.
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub struct KnotHandle {
26    owner: usize,
27    index: u16,
28}
29
30/// Catalog-checked input endpoint for connecting a thread.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct InputPort {
33    owner: usize,
34    knot: u16,
35    name: String,
36}
37
38/// Catalog-checked output endpoint for connecting a thread.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct OutputPort {
41    owner: usize,
42    knot: u16,
43    name: String,
44}
45
46/// Expanded pattern include: export names map to parent-builder ports.
47#[derive(Clone, Debug)]
48pub struct PatternInstance {
49    id: String,
50    inputs: BTreeMap<String, InputPort>,
51    outputs: BTreeMap<String, OutputPort>,
52}
53
54impl PatternInstance {
55    /// Instance id used as the expansion prefix (`id/inner_knot`).
56    pub fn id(&self) -> &str {
57        &self.id
58    }
59
60    /// Resolve a pattern input export to a parent-builder input port.
61    pub fn input(&self, export: &str) -> Result<InputPort, BuildError> {
62        self.inputs
63            .get(export)
64            .cloned()
65            .ok_or_else(|| BuildError::UnknownExport {
66                instance_id: self.id.clone(),
67                export: String::from(export),
68                direction: PortDir::In,
69            })
70    }
71
72    /// Resolve a pattern output export to a parent-builder output port.
73    pub fn output(&self, export: &str) -> Result<OutputPort, BuildError> {
74        self.outputs
75            .get(export)
76            .cloned()
77            .ok_or_else(|| BuildError::UnknownExport {
78                instance_id: self.id.clone(),
79                export: String::from(export),
80                direction: PortDir::Out,
81            })
82    }
83}
84
85/// Incremental weave construction; [`Self::build`] validates into a [`Weave`].
86pub struct WeaveBuilder {
87    owner: usize,
88    id: String,
89    knots: Vec<KnotDef>,
90    threads: Vec<ThreadDef>,
91    numeric: NumericPath,
92    names: BTreeMap<String, u16>,
93}
94
95impl WeaveBuilder {
96    /// Start a weave with a non-empty author id and a fresh owner token.
97    pub fn new(id: impl Into<String>) -> Result<Self, BuildError> {
98        let id = id.into();
99        if id.is_empty() {
100            return Err(BuildError::InvalidId {
101                id,
102                reason: "weave ids must be non-empty",
103            });
104        }
105        let owner = NEXT_OWNER.fetch_add(1, Ordering::Relaxed);
106        validate_owner(owner)?;
107        Ok(Self {
108            owner,
109            id,
110            knots: Vec::new(),
111            threads: Vec::new(),
112            numeric: NumericPath::compiled(),
113            names: BTreeMap::new(),
114        })
115    }
116
117    /// Override the numeric path tag (must match the compiled feature at validate).
118    pub fn set_numeric(&mut self, path: NumericPath) -> Result<&mut Self, BuildError> {
119        self.numeric = path;
120        Ok(self)
121    }
122
123    /// Add a knot with a unique non-empty author id.
124    pub fn knot(
125        &mut self,
126        id: impl Into<String>,
127        kind: KnotKind,
128    ) -> Result<KnotHandle, BuildError> {
129        let id = id.into();
130        if id.is_empty() {
131            return Err(BuildError::InvalidId {
132                id,
133                reason: "knot ids must be non-empty",
134            });
135        }
136        if self.names.contains_key(&id) {
137            return Err(BuildError::DuplicateKnotId { knot_id: id });
138        }
139        let index =
140            u16::try_from(self.knots.len()).map_err(|_| BuildError::RepresentationOverflow {
141                what: "knot",
142                actual: self.knots.len(),
143                limit: u16::MAX as usize,
144            })?;
145        self.names.insert(id.clone(), index);
146        self.knots.push(KnotDef { id, kind });
147        Ok(KnotHandle {
148            owner: self.owner,
149            index,
150        })
151    }
152
153    /// Catalog-checked input port on a knot owned by this builder.
154    pub fn input(&self, knot: &KnotHandle, name: &str) -> Result<InputPort, BuildError> {
155        self.endpoint(knot, name, PortDir::In)
156            .map(|name| InputPort {
157                owner: self.owner,
158                knot: knot.index,
159                name,
160            })
161    }
162
163    /// Catalog-checked output port on a knot owned by this builder.
164    pub fn output(&self, knot: &KnotHandle, name: &str) -> Result<OutputPort, BuildError> {
165        self.endpoint(knot, name, PortDir::Out)
166            .map(|name| OutputPort {
167                owner: self.owner,
168                knot: knot.index,
169                name,
170            })
171    }
172
173    /// Record a directed thread; both endpoints must belong to this builder.
174    pub fn connect(&mut self, from: OutputPort, to: InputPort) -> Result<&mut Self, BuildError> {
175        if from.owner != self.owner || to.owner != self.owner {
176            return Err(BuildError::ForeignHandle);
177        }
178        let from_knot = self
179            .knots
180            .get(from.knot as usize)
181            .ok_or(BuildError::ForeignHandle)?;
182        let to_knot = self
183            .knots
184            .get(to.knot as usize)
185            .ok_or(BuildError::ForeignHandle)?;
186        if let (Some(from_domain), Some(to_domain)) = (
187            fixed_port_domain(&from_knot.kind, &from.name),
188            fixed_port_domain(&to_knot.kind, &to.name),
189        ) {
190            if from_domain != to_domain {
191                return Err(BuildError::SignalDomainMismatch {
192                    from_knot: from_knot.id.clone(),
193                    from_port: from.name,
194                    from_domain,
195                    to_knot: to_knot.id.clone(),
196                    to_port: to.name,
197                    to_domain,
198                });
199            }
200        }
201        self.threads.push(ThreadDef {
202            from: PortRefDef::new(from_knot.id.clone(), from.name),
203            to: PortRefDef::new(to_knot.id.clone(), to.name),
204        });
205        Ok(self)
206    }
207
208    /// Expand a validated [`Pattern`] under `instance_id/` and return export ports.
209    pub fn include(
210        &mut self,
211        instance_id: impl Into<String>,
212        pattern: &Pattern,
213    ) -> Result<PatternInstance, BuildError> {
214        let instance_id = instance_id.into();
215        if pattern.inner().numeric != self.numeric {
216            return Err(BuildError::NumericMismatch {
217                expected: self.numeric,
218                actual: pattern.inner().numeric,
219            });
220        }
221        let expanded = expand(&instance_id, pattern)?;
222        for knot in &expanded.knots {
223            if self.names.contains_key(&knot.id) {
224                return Err(BuildError::DuplicateKnotId {
225                    knot_id: knot.id.clone(),
226                });
227            }
228        }
229        for knot in expanded.knots {
230            let index = u16::try_from(self.knots.len()).map_err(|_| {
231                BuildError::RepresentationOverflow {
232                    what: "knot",
233                    actual: self.knots.len(),
234                    limit: u16::MAX as usize,
235                }
236            })?;
237            self.names.insert(knot.id.clone(), index);
238            self.knots.push(knot);
239        }
240        self.threads.extend(expanded.threads);
241        let inputs = expanded
242            .inputs
243            .into_iter()
244            .map(|(name, port)| {
245                let knot = self.names[port.knot.as_str()];
246                (
247                    name,
248                    InputPort {
249                        owner: self.owner,
250                        knot,
251                        name: port.port,
252                    },
253                )
254            })
255            .collect();
256        let outputs = expanded
257            .outputs
258            .into_iter()
259            .map(|(name, port)| {
260                let knot = self.names[port.knot.as_str()];
261                (
262                    name,
263                    OutputPort {
264                        owner: self.owner,
265                        knot,
266                        name: port.port,
267                    },
268                )
269            })
270            .collect();
271        Ok(PatternInstance {
272            id: instance_id,
273            inputs,
274            outputs,
275        })
276    }
277
278    /// Validate structure and produce an immutable [`Weave`].
279    pub fn build(self) -> Result<Weave, ValidationError> {
280        Weave::try_from(WeaveDef {
281            id: self.id,
282            numeric: self.numeric,
283            knots: self.knots,
284            threads: self.threads,
285        })
286    }
287
288    fn endpoint(
289        &self,
290        knot: &KnotHandle,
291        name: &str,
292        expected: PortDir,
293    ) -> Result<String, BuildError> {
294        if knot.owner != self.owner {
295            return Err(BuildError::ForeignHandle);
296        }
297        let knot_def = self
298            .knots
299            .get(knot.index as usize)
300            .ok_or(BuildError::ForeignHandle)?;
301        let ports = ports_of(&knot_def.kind);
302        let info =
303            ports
304                .iter()
305                .find(|port| port.name == name)
306                .ok_or_else(|| BuildError::UnknownPort {
307                    knot_id: knot_def.id.clone(),
308                    port: String::from(name),
309                    expected: ports.iter().map(|port| String::from(port.name)).collect(),
310                })?;
311        if info.dir != expected {
312            return Err(BuildError::WrongPortDirection {
313                knot_id: knot_def.id.clone(),
314                port: String::from(name),
315                expected,
316                actual: info.dir,
317            });
318        }
319        Ok(String::from(name))
320    }
321}
322
323fn validate_owner(owner: usize) -> Result<(), BuildError> {
324    if owner == usize::MAX {
325        return Err(BuildError::RepresentationOverflow {
326            what: "builder owner token",
327            actual: owner,
328            limit: usize::MAX - 1,
329        });
330    }
331    Ok(())
332}
333
334fn fixed_port_domain(kind: &KnotKind, name: &str) -> Option<crate::foundation::SignalDomain> {
335    let slot = port_slot(kind, name)?;
336    match port_domain(kind, slot)? {
337        PortDomain::Fixed(domain) => Some(domain),
338        PortDomain::Variable(_) | PortDomain::Any => None,
339    }
340}
341
342/// Resolve a catalog port name to its compact slot.
343pub fn slot_of(kind: &KnotKind, name: &str) -> Result<PortSlot, BuildError> {
344    port_slot(kind, name).ok_or_else(|| BuildError::UnknownPort {
345        knot_id: String::from("<kind>"),
346        port: String::from(name),
347        expected: ports_of(kind)
348            .iter()
349            .map(|port| String::from(port.name))
350            .collect(),
351    })
352}
353
354#[cfg(test)]
355mod tests {
356    use super::validate_owner;
357    use crate::BuildError;
358
359    #[test]
360    fn owner_tokens_reserve_the_overflow_sentinel() {
361        assert!(matches!(
362            validate_owner(usize::MAX),
363            Err(BuildError::RepresentationOverflow {
364                what: "builder owner token",
365                actual: usize::MAX,
366                limit,
367            }) if limit == usize::MAX - 1
368        ));
369        assert!(validate_owner(1).is_ok());
370    }
371}