panproto_schema/schema.rs
1//! Core schema data structures.
2//!
3//! A [`Schema`] is a model of a protocol's schema theory GAT. It stores
4//! vertices, binary edges, hyper-edges, constraints, required-edge
5//! declarations, and NSID mappings. Precomputed adjacency indices
6//! (`outgoing`, `incoming`, `between`) enable fast traversal.
7
8use std::collections::HashMap;
9
10use panproto_gat::Name;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13
14/// A schema vertex.
15///
16/// Each vertex has a unique `id`, a `kind` drawn from the protocol's
17/// recognized vertex kinds, and an optional NSID (namespace identifier).
18#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Vertex {
20 /// Unique vertex identifier within the schema.
21 pub id: Name,
22 /// The vertex kind (e.g., `"record"`, `"object"`, `"string"`).
23 pub kind: Name,
24 /// Optional namespace identifier (e.g., `"app.bsky.feed.post"`).
25 pub nsid: Option<Name>,
26}
27
28/// A binary edge between two vertices.
29///
30/// Edges are directed: they go from `src` to `tgt`. The `kind` determines
31/// the structural role (e.g., `"prop"`, `"record-schema"`), and `name`
32/// provides an optional label (e.g., the property name).
33#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
34pub struct Edge {
35 /// Source vertex ID.
36 pub src: Name,
37 /// Target vertex ID.
38 pub tgt: Name,
39 /// Edge kind (e.g., `"prop"`, `"record-schema"`).
40 pub kind: Name,
41 /// Optional edge label (e.g., a property name like `"text"`).
42 pub name: Option<Name>,
43}
44
45/// A hyper-edge (present only when the schema theory includes `ThHypergraph`).
46///
47/// Hyper-edges connect multiple vertices via a labeled signature.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct HyperEdge {
50 /// Unique hyper-edge identifier.
51 pub id: Name,
52 /// Hyper-edge kind.
53 pub kind: Name,
54 /// Maps label names to vertex IDs.
55 #[serde(with = "crate::serde_helpers::sorted_map")]
56 pub signature: HashMap<Name, Name>,
57 /// The label that identifies the parent vertex.
58 pub parent_label: Name,
59}
60
61/// A constraint on a vertex.
62///
63/// Constraints restrict the values a vertex can hold (e.g., maximum
64/// string length, format pattern).
65#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
66pub struct Constraint {
67 /// The constraint sort (e.g., `"maxLength"`, `"format"`).
68 pub sort: Name,
69 /// The constraint value (e.g., `"3000"`, `"at-uri"`).
70 pub value: String,
71}
72
73/// A variant in a coproduct (sum type / union).
74///
75/// Each variant is injected into a parent vertex (the union/coproduct)
76/// with an optional discriminant tag.
77#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub struct Variant {
79 /// Unique variant identifier.
80 pub id: Name,
81 /// The parent coproduct vertex this variant belongs to.
82 pub parent_vertex: Name,
83 /// Optional discriminant tag.
84 pub tag: Option<Name>,
85}
86
87/// An ordering annotation on an edge.
88///
89/// Records that the children reached via this edge are ordered,
90/// with a specific position index.
91#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
92pub struct Ordering {
93 /// The edge being ordered.
94 pub edge: Edge,
95 /// Position in the ordered collection.
96 pub position: u32,
97}
98
99/// A recursion point (fixpoint marker) in the schema.
100///
101/// Marks a vertex as a recursive reference to another vertex,
102/// satisfying the fold-unfold law: `unfold(fold(v)) = v`.
103///
104/// The marker vertex is the key this is filed under in
105/// [`Schema::recursion_points`], and is deliberately not repeated here. Naming
106/// it twice would make the two copies independently settable, and
107/// deserialisation accepts whatever a file says: a schema filing a marker under
108/// one name while the marker claimed another would be a contradiction no
109/// constructor could rule out and every reader would have to pick a side.
110/// Inducing, validation, diffing, hashing and the protocol emitters key on the
111/// map; the search network and the span's right leg read the marker. With one
112/// copy they cannot disagree, which is a stronger guarantee than any check
113/// could give, since a check has to be run and this cannot be skipped.
114/// [`ValidationError::DanglingRecursionPoint`](crate::ValidationError) covers
115/// what remains, a marker naming a vertex the schema does not have.
116#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
117pub struct RecursionPoint {
118 /// The target vertex this unfolds to.
119 pub target_vertex: Name,
120}
121
122/// A span connecting two vertices through a common source.
123///
124/// Spans model correspondences, diffs, and migrations:
125/// `left ← span → right`.
126#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
127pub struct Span {
128 /// Unique span identifier.
129 pub id: Name,
130 /// Left vertex of the span.
131 pub left: Name,
132 /// Right vertex of the span.
133 pub right: Name,
134}
135
136/// Use-counting mode for an edge.
137///
138/// Captures the substructural distinction between edges that can
139/// be used freely (structural), exactly once (linear), or at most
140/// once (affine).
141#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
142pub enum UsageMode {
143 /// Can be used any number of times (default).
144 #[default]
145 Structural,
146 /// Must be used exactly once (e.g., protobuf `oneof`).
147 Linear,
148 /// Can be used at most once.
149 Affine,
150}
151
152/// Specification of a coercion between two value kinds.
153///
154/// Contains the forward coercion expression, an optional inverse for
155/// round-tripping, and the coercion class classifying the round-trip behavior.
156#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
157pub struct CoercionSpec {
158 /// Forward coercion expression (source to target).
159 pub forward: panproto_expr::Expr,
160 /// Inverse coercion expression (target to source) for the `put` direction.
161 pub inverse: Option<panproto_expr::Expr>,
162 /// Round-trip classification.
163 pub class: panproto_gat::CoercionClass,
164}
165
166/// A schema: a model of the protocol's schema theory.
167///
168/// Contains both the raw data (vertices, edges, constraints, etc.) and
169/// precomputed adjacency indices for efficient graph traversal.
170#[derive(Clone, Debug, Serialize, Deserialize)]
171pub struct Schema {
172 /// The protocol this schema belongs to.
173 pub protocol: String,
174 /// Vertices keyed by their ID.
175 #[serde(with = "crate::serde_helpers::sorted_map")]
176 pub vertices: HashMap<Name, Vertex>,
177 /// Edges keyed by the edge itself, value is the edge kind.
178 #[serde(with = "crate::serde_helpers::map_as_vec")]
179 pub edges: HashMap<Edge, Name>,
180 /// Hyper-edges keyed by their ID.
181 #[serde(with = "crate::serde_helpers::sorted_map")]
182 pub hyper_edges: HashMap<Name, HyperEdge>,
183 /// Constraints per vertex ID.
184 #[serde(with = "crate::serde_helpers::sorted_map")]
185 pub constraints: HashMap<Name, Vec<Constraint>>,
186 /// Required edges per vertex ID.
187 #[serde(with = "crate::serde_helpers::sorted_map")]
188 pub required: HashMap<Name, Vec<Edge>>,
189 /// NSID mapping: vertex ID to NSID string.
190 #[serde(with = "crate::serde_helpers::sorted_map")]
191 pub nsids: HashMap<Name, Name>,
192 /// Declared entry vertices.
193 ///
194 /// Semantically, this is the finite family of basepoints that makes
195 /// the schema a *pointed* schema: `E → Ob(C_S)` selecting the sorts
196 /// at which the W-algebra of instances may be rooted. Parsers set
197 /// this explicitly per their protocol's notion of a top-level
198 /// definition (a record, a top-level type, a path root, etc.);
199 /// consumers that need to choose an instance root consult it via
200 /// [`primary_entry`].
201 ///
202 /// Empty means the parser declined to supply a pointing; consumers
203 /// should then either fall back to a deterministic (but non-
204 /// canonical) selection or report an error. Order is preserved for
205 /// reproducibility; the set carries no duplicates.
206 #[serde(default)]
207 pub entries: Vec<Name>,
208
209 /// Coproduct variants per union vertex ID.
210 #[serde(default, with = "crate::serde_helpers::sorted_map")]
211 pub variants: HashMap<Name, Vec<Variant>>,
212 /// Edge ordering positions (edge → position index).
213 #[serde(default, with = "crate::serde_helpers::map_as_vec_default")]
214 pub orderings: HashMap<Edge, u32>,
215 /// Recursion points (fixpoint markers).
216 #[serde(default, with = "crate::serde_helpers::sorted_map")]
217 pub recursion_points: HashMap<Name, RecursionPoint>,
218 /// Spans connecting pairs of vertices.
219 #[serde(default, with = "crate::serde_helpers::sorted_map")]
220 pub spans: HashMap<Name, Span>,
221 /// Edge usage modes (default: `Structural` for all).
222 #[serde(default, with = "crate::serde_helpers::map_as_vec_default")]
223 pub usage_modes: HashMap<Edge, UsageMode>,
224 /// Whether each vertex uses nominal identity (`true`) or
225 /// structural identity (`false`). Absent = structural.
226 #[serde(default, with = "crate::serde_helpers::sorted_map")]
227 pub nominal: HashMap<Name, bool>,
228
229 // -- enrichment fields --
230 /// Coercion specifications: `(source_kind, target_kind)` to coercion spec.
231 #[serde(default, with = "crate::serde_helpers::map_as_vec_default")]
232 pub coercions: HashMap<(Name, Name), CoercionSpec>,
233 /// Merger expressions: `vertex_id` to merger expression.
234 #[serde(default, with = "crate::serde_helpers::sorted_map")]
235 pub mergers: HashMap<Name, panproto_expr::Expr>,
236 /// Default value expressions: `vertex_id` to default expression.
237 #[serde(default, with = "crate::serde_helpers::sorted_map")]
238 pub defaults: HashMap<Name, panproto_expr::Expr>,
239 /// Conflict resolution policy expressions: `sort_name` to policy expression.
240 #[serde(default, with = "crate::serde_helpers::sorted_map")]
241 pub policies: HashMap<Name, panproto_expr::Expr>,
242
243 // -- precomputed indices --
244 /// Outgoing edges per vertex ID.
245 #[serde(with = "crate::serde_helpers::sorted_map")]
246 pub outgoing: HashMap<Name, SmallVec<Edge, 4>>,
247 /// Incoming edges per vertex ID.
248 #[serde(with = "crate::serde_helpers::sorted_map")]
249 pub incoming: HashMap<Name, SmallVec<Edge, 4>>,
250 /// Edges between a specific `(src, tgt)` pair.
251 #[serde(with = "crate::serde_helpers::map_as_vec")]
252 pub between: HashMap<(Name, Name), SmallVec<Edge, 2>>,
253}
254
255impl Schema {
256 /// Strip every constraint whose sort belongs to the layout
257 /// enrichment fibre (per [`panproto_gat::is_layout_sort`]).
258 ///
259 /// This is the schema-level forgetful U sending a decorated schema
260 /// to its abstract base in the parse/decorate/emit lens. Idempotent.
261 #[must_use]
262 pub fn forget_layout(&self) -> Self {
263 let mut clone = self.clone();
264 clone.forget_layout_in_place();
265 clone
266 }
267
268 /// In-place variant of [`Self::forget_layout`].
269 pub fn forget_layout_in_place(&mut self) {
270 for constraints in self.constraints.values_mut() {
271 constraints.retain(|c| !panproto_gat::is_layout_sort(c.sort.as_ref()));
272 }
273 // Drop now-empty vertex entries so equality is structural.
274 self.constraints.retain(|_, cs| !cs.is_empty());
275 }
276
277 /// Returns `true` when no constraint sort belongs to the layout
278 /// enrichment fibre. This is the well-formedness predicate for an
279 /// [`AbstractSchema`](crate::AbstractSchema).
280 #[must_use]
281 pub fn is_layout_free(&self) -> bool {
282 self.constraints.values().all(|cs| {
283 cs.iter()
284 .all(|c| !panproto_gat::is_layout_sort(c.sort.as_ref()))
285 })
286 }
287
288 /// Look up a vertex by ID.
289 #[must_use]
290 pub fn vertex(&self, id: &str) -> Option<&Vertex> {
291 self.vertices.get(id)
292 }
293
294 /// Return all outgoing edges from the given vertex.
295 #[must_use]
296 pub fn outgoing_edges(&self, vertex_id: &str) -> &[Edge] {
297 self.outgoing.get(vertex_id).map_or(&[], SmallVec::as_slice)
298 }
299
300 /// Return all incoming edges to the given vertex.
301 #[must_use]
302 pub fn incoming_edges(&self, vertex_id: &str) -> &[Edge] {
303 self.incoming.get(vertex_id).map_or(&[], SmallVec::as_slice)
304 }
305
306 /// Return edges between a specific `(src, tgt)` pair.
307 #[must_use]
308 #[inline]
309 pub fn edges_between(&self, src: &str, tgt: &str) -> &[Edge] {
310 self.between
311 .get(&(Name::from(src), Name::from(tgt)))
312 .map_or(&[], SmallVec::as_slice)
313 }
314
315 /// Returns `true` if the given vertex ID exists in this schema.
316 #[must_use]
317 #[inline]
318 pub fn has_vertex(&self, id: &str) -> bool {
319 self.vertices.contains_key(id)
320 }
321
322 /// Returns the number of vertices in the schema.
323 #[must_use]
324 pub fn vertex_count(&self) -> usize {
325 self.vertices.len()
326 }
327
328 /// Returns the number of edges in the schema.
329 #[must_use]
330 pub fn edge_count(&self) -> usize {
331 self.edges.len()
332 }
333
334 /// Return the declared entry vertices.
335 ///
336 /// See [`Schema::entries`] for semantics. Use [`primary_entry`] for
337 /// callers that need a single root and want a deterministic
338 /// fallback when no entries are declared.
339 #[must_use]
340 pub fn entry_vertices(&self) -> &[Name] {
341 &self.entries
342 }
343
344 /// Return every constraint attached to the given vertex.
345 ///
346 /// Tree-sitter-derived schemas attach byte ranges, interstitials,
347 /// formatting, and `field:<name>` entries here.
348 #[must_use]
349 pub fn constraints_for(&self, vertex_id: &str) -> &[Constraint] {
350 self.constraints
351 .get(&Name::from(vertex_id))
352 .map_or(&[], Vec::as_slice)
353 }
354
355 /// Return the text value of a tree-sitter `field('<name>', ...)`
356 /// anonymous-token child on the given vertex, if any.
357 ///
358 /// Tree-sitter rules of the form
359 /// `field('op', choice('+', '-', '*', '/'))` attach a field name to
360 /// an unnamed token alternative. The walker emits the token's text
361 /// as a `field:<name>` constraint on the parent vertex; this is the
362 /// supported accessor for that text.
363 ///
364 /// Returns `None` if no `field:<name>` constraint exists on
365 /// `vertex_id`. Named-node field children continue to surface as
366 /// edges (use [`outgoing_edges`](Self::outgoing_edges) and filter
367 /// by [`Edge::kind`](crate::Edge::kind) for those).
368 #[must_use]
369 pub fn field_text(&self, vertex_id: &str, field_name: &str) -> Option<&str> {
370 let sort = format!("field:{field_name}");
371 self.constraints
372 .get(&Name::from(vertex_id))?
373 .iter()
374 .find(|c| c.sort.as_ref() == sort.as_str())
375 .map(|c| c.value.as_str())
376 }
377}
378
379/// Choose a single entry vertex for a schema.
380///
381/// Returns the first declared entry if any. Otherwise falls back to a
382/// deterministic, protocol-agnostic choice: among vertex ids sorted
383/// lexicographically, the first vertex that is a source of at least
384/// one edge and a target of none (an edgeless "root" in the signature
385/// graph); failing that, the first vertex that has outgoing edges at
386/// all; failing that, the lexicographically first vertex; `None` only
387/// for an empty schema.
388///
389/// The fallback is explicitly *non-canonical*: it exists so that legacy
390/// schemas without declared entries remain usable, but new parsers
391/// should always supply at least one entry via
392/// [`SchemaBuilder::entry`](crate::SchemaBuilder::entry) so that the
393/// pointing is part of the schema's semantics rather than recovered by
394/// heuristic.
395#[must_use]
396pub fn primary_entry(schema: &Schema) -> Option<&Name> {
397 if let Some(first) = schema.entries.first() {
398 return Some(first);
399 }
400
401 let mut ids: Vec<&Name> = schema.vertices.keys().collect();
402 ids.sort();
403
404 let has_outgoing = |id: &Name| -> bool {
405 schema
406 .outgoing
407 .get(id)
408 .is_some_and(|edges| !edges.is_empty())
409 };
410 let has_incoming = |id: &Name| -> bool {
411 schema
412 .incoming
413 .get(id)
414 .is_some_and(|edges| !edges.is_empty())
415 };
416
417 if let Some(id) = ids
418 .iter()
419 .copied()
420 .find(|id| has_outgoing(id) && !has_incoming(id))
421 {
422 return Some(id);
423 }
424 if let Some(id) = ids.iter().copied().find(|id| has_outgoing(id)) {
425 return Some(id);
426 }
427 ids.into_iter().next()
428}