blockworx/doc_ng/model.rs
1//! Document model — collaborative log architecture.
2//!
3//! Relation taxonomy (final form — see design doc):
4//! 1. LEAF VALUES — `Register<T>`: value + stamp, LWW. Atomicity by the
5//! Frankenstein test: if concurrent edits to different sub-fields were
6//! both kept, is the result coherent? No → one Register over the
7//! composite (`GridRect`, `Vec<Waypoint>`). Yes → split into namespaces.
8//! 2. NAMESPACES — fixed composite structure inlined into its owner
9//! (Label): a plain struct whose fields are Registers (or nested
10//! namespaces). Not entities: no ID, no lifecycle. Addressed by
11//! hierarchical target paths (`BlockTargets::Title(LabelTargets::Name)`).
12//! 3. SET MEMBERSHIP — variable-cardinality containment: authored
13//! child-side (`owner` Register on the entity); parent-side sets are
14//! DERIVED (`DocumentCache`).
15//! 4. VALUE-KEYED REGISTER FAMILIES — available pattern; no current
16//! instances (route labels graduated to entities).
17//!
18//! Register placement IS the atomicity decision: `Register<GridRect>` declares
19//! an atomic composite; `title: Label` (a struct of Registers) declares a
20//! namespace. The Targets enums must mirror this structure exactly — one
21//! enum leaf path per Register.
22//!
23//! Cardinality is enforced by STRUCTURE, not by repair: "at most one" → a
24//! field on the owner (Block.icon); "zero or more" → entities with owners.
25//!
26//! Stamps are FIRST-CLASS MERGE STATE, inline in every Register: snapshots
27//! must contain them (replay resolves tail LWW against snapshot-point stamps)
28//! and they must converge (snapshot==replay asserts them). The "no notion of
29//! time" property belongs to the user-facing PROJECTION (KDL export, review
30//! rendering), which strips stamps mechanically — not to the storage layout.
31//!
32//! Zero-value principle: every type's default is meaningful (`Role::Accent0`,
33//! null `AssetHash`, empty rect, `Stamp::BOTTOM`, `Liveness::Deleted`). The model
34//! is Option-free: absence is map-absence (entities) or stamped Liveness
35//! (slots); "no value" is a meaningful zero.
36
37use crate::doc_ng::{
38 hash::AssetHash,
39 id::{BlockId, CommentId, ImageId, PinId, RouteId, RouteLabelId, TextId},
40 stamp::Stamp,
41};
42use ahash::{HashMap, HashSet};
43use serde::{Deserialize, Serialize};
44use std::sync::Arc;
45
46/// The universal authored cell: current value + stamp of the write that set
47/// it. `apply` is the ENTIRE merge rule and the only write path — no code can
48/// update a value while forgetting its stamp, and the LWW comparison lives in
49/// exactly one place. Default = (`T::default()`, BOTTOM): every register exists
50/// from time 0 holding its meaningful zero, so first-write needs no special
51/// case anywhere in the fold.
52#[derive(Default)]
53pub struct Register<T> {
54 value: T,
55 stamp: Stamp,
56}
57
58pub enum Applied {
59 Won,
60 LostToNewer,
61}
62
63impl<T> Register<T> {
64 pub fn get(&self) -> &T {
65 &self.value
66 }
67 pub fn stamp(&self) -> Stamp {
68 self.stamp
69 }
70 /// LWW: the later (stamp, actor) wins; a losing write is reported so the
71 /// fold can record `Collision::RegisterOverwrite`.
72 pub fn apply(&mut self, value: T, stamp: Stamp) -> Applied {
73 if stamp > self.stamp {
74 self.value = value;
75 self.stamp = stamp;
76 Applied::Won
77 } else {
78 Applied::LostToNewer
79 }
80 }
81}
82
83// ---------------------------------------------------------------------------
84// Liveness (entities and slots)
85// ---------------------------------------------------------------------------
86
87#[derive(Clone, Copy, PartialEq, Eq, Default)]
88pub enum Liveness {
89 Alive,
90 #[default]
91 Deleted,
92}
93
94/// Stamped presence + retained inner — ENTITIES ONLY (value slots like
95/// Block.icon are atomic Registers whose zero value means absent).
96/// `presence` is an ordinary
97/// Register — delete-vs-restore is plain LWW on it. Delete-wins over edits is
98/// STRUCTURAL: edit commands cannot target presence, so no edit (any stamp)
99/// resurrects; losing edits apply to the retained inner, are flagged
100/// (`Collision::EditOfDeleted`), and surface on Restore.
101/// Default = (Deleted @ BOTTOM, `T::default()`).
102#[derive(Default)]
103pub struct Live<T> {
104 pub presence: Register<Liveness>,
105 pub inner: T,
106}
107
108// ---------------------------------------------------------------------------
109// Document root (authored state only)
110// ---------------------------------------------------------------------------
111
112/// The Document is a singleton entity with one register (Name). AUTHORED
113/// STATE ONLY — all derived indexes live in `DocumentCache`. This struct (with
114/// its stamps) is what snapshot==replay asserts over; the KDL export is a
115/// stamp-stripping projection of it.
116///
117/// The document is the IMPLICIT ROOT of the containment tree: not an element,
118/// cannot be deleted/moved/raced. Blocks whose parent is `Id::NULL` live at
119/// document level. Single-canvas presentation, if wanted, is a UI convention
120/// — not a merge invariant.
121pub struct Document {
122 pub name: Register<String>, // DocumentTargets::Name
123 pub blocks: HashMap<BlockId, Live<Block>>,
124 pub pins: HashMap<PinId, Live<Pin>>,
125 pub routes: HashMap<RouteId, Live<Route>>,
126 pub route_labels: HashMap<RouteLabelId, Live<RouteLabel>>,
127 pub texts: HashMap<TextId, Live<Text>>,
128 pub comments: HashMap<CommentId, Live<Comment>>,
129 pub images: HashMap<ImageId, Live<Image>>,
130}
131
132/// DERIVED — all reverse indexes, maintained incrementally by the fold and
133/// fully rebuildable: `rebuild(&Document) -> DocumentCache` (the ground truth
134/// the incremental maintenance is property-tested against). Never serialized
135/// as authoritative state; never targeted by commands. No entries for
136/// tombstoned elements. Future indexes (spatial, by-scope) belong here.
137pub struct DocumentCache {
138 pub top_level: HashSet<BlockId>, // blocks with parent == Id::NULL
139 pub blocks: HashMap<BlockId, BlockIndex>,
140 pub routes: HashMap<RouteId, RouteIndex>,
141 /// Routes whose authored presence is Alive but whose EFFECTIVE liveness is
142 /// dead (an endpoint pin is tombstoned) — derived, flagged
143 /// (`Collision::RouteOrphanedByDelete`), auto-revived when the endpoint is
144 /// Restored. Rendered as deleted. See "no dangling routes" below.
145 pub suppressed: HashSet<RouteId>,
146}
147
148// ---------------------------------------------------------------------------
149// Geometry (fixed-point; deterministic bytes for hashing)
150// ---------------------------------------------------------------------------
151
152#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
153pub struct GridPoint {
154 pub x: i32,
155 pub y: i32,
156}
157
158#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
159pub struct GridSize {
160 pub w: u32,
161 pub h: u32,
162}
163/// Atomic wherever it appears — always behind a single Register (fails the
164/// Frankenstein test: one author's position + another's size is a rect
165/// neither authored).
166#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
167pub struct GridRect {
168 pub top_left: GridPoint,
169 pub size: GridSize,
170}
171
172/// An f32 stored as floor(val * 2^24).
173/// NOTE: `f32 -> FracVal` on NaN saturates to 0 (Rust `as` cast). Reject NaN
174/// at command construction (`debug_assert`) rather than relying on saturation.
175#[derive(
176 Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
177)]
178pub struct FracVal(i64);
179
180impl From<f32> for FracVal {
181 fn from(val: f32) -> FracVal {
182 debug_assert!(!val.is_nan(), "NaN must not enter the document");
183 Self((val as f64 * 2.0f64.powi(24)) as i64)
184 }
185}
186impl From<FracVal> for f32 {
187 fn from(val: FracVal) -> f32 {
188 ((val.0 as f64) / 2.0f64.powi(24)) as f32
189 }
190}
191
192#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
193pub struct ScreenPoint {
194 pub x: FracVal,
195 pub y: FracVal,
196}
197
198#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
199pub struct ScreenSize {
200 pub w: FracVal,
201 pub h: FracVal,
202}
203
204/// Atomic (same reasoning as `GridRect`). Default = empty rect (meaningful zero).
205#[derive(Serialize, Deserialize, Debug, Clone, Default)]
206pub struct ScreenRect {
207 pub top_left: ScreenPoint,
208 pub size: ScreenSize,
209}
210
211// ---------------------------------------------------------------------------
212// Shared value enums
213// ---------------------------------------------------------------------------
214
215#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
216pub enum LabelSide {
217 #[default]
218 Top,
219 Center,
220 Bottom,
221}
222
223/// Zero-value principle: no `Option<u8>` roles/accents. The domain is exactly
224/// the 9-accent palette; Accent0 is the meaningful default ("plain").
225#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
226pub enum Role {
227 #[default]
228 Accent0,
229 Accent1,
230 Accent2,
231 Accent3,
232 Accent4,
233 Accent5,
234 Accent6,
235 Accent7,
236 Accent8,
237}
238
239#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
240pub enum PinDir {
241 #[default]
242 Input,
243 InOut,
244 Output,
245}
246
247// ---------------------------------------------------------------------------
248// Namespaces — inlined register groups, NOT entities
249// ---------------------------------------------------------------------------
250
251/// A namespace of four independent registers: concurrent rename + side-flip
252/// are both kept, coherently. No ID — addressed through the owner's path.
253#[derive(Default)]
254pub struct Label {
255 pub name: Register<String>, // ...(LabelTargets::Name)
256 pub side: Register<LabelSide>, // ...(LabelTargets::Side)
257 pub offset: Register<FracVal>, // ...(LabelTargets::Offset)
258 pub hidden: Register<bool>, // ...(LabelTargets::Hidden)
259}
260
261/// Icon value: asset + placement, held whole in a single Register on Block.
262/// Plain fields (no inner Registers) — the register boundary is the icon
263/// itself. Default is the ZERO icon: null `AssetHash`, empty rect, renders as
264/// nothing, means "no image".
265#[derive(Serialize, Deserialize, Debug, Clone, Default)]
266pub struct Icon {
267 pub asset: AssetHash,
268 pub rect: ScreenRect,
269}
270
271// ---------------------------------------------------------------------------
272// Elements (entities)
273// ---------------------------------------------------------------------------
274
275pub struct Block {
276 /// Zero-value principle: `Id::NULL` = the document itself (implicit root).
277 /// Cycle repair treats NULL as the tree root; unknown non-null IDs are the
278 /// dangling-ref repair case.
279 pub parent: Register<BlockId>, // BlockTargets::Parent
280 pub rect: Register<GridRect>, // BlockTargets::Rect (atomic)
281 pub locked: Register<bool>, // BlockTargets::Locked
282 pub title: Label, // BlockTargets::Title(..)
283 pub type_label: Label, // BlockTargets::TypeLabel(..)
284 /// Atomic value slot: the WHOLE icon (asset + placement) is one register.
285 /// Default = zero icon (null `AssetHash`, empty rect) = "no image", so
286 /// absence is just a value: clearing = writing the zero icon, an ordinary
287 /// stamped LWW write — the stamp records when it was cleared, and
288 /// clear-vs-edit is plain LWW. Atomicity is required by zero-as-absence:
289 /// a namespace here could resurrect half an icon.
290 pub icon: Register<Icon>, // BlockTargets::Icon
291}
292
293/// DERIVED — lives in `DocumentCache`, rebuilt from child-side owner registers.
294pub struct BlockIndex {
295 pub pins: HashSet<PinId>, // from Pin.owner
296 pub routes: HashSet<RouteId>, // from Route.owner
297 pub texts: HashSet<TextId>, // from Text.owner
298 pub comments: HashSet<CommentId>, // from Comment.owner
299 pub images: HashSet<ImageId>, // from Image.owner
300 pub children: HashSet<BlockId>, // from Block.parent
301}
302
303/// Entity: variable cardinality per block, externally referenced (Route.from/to).
304pub struct Pin {
305 pub owner: Register<BlockId>, // PinTargets::Owner (membership)
306 pub name: Register<String>,
307 pub type_name: Register<String>, // renamed from type_label: it's a bare
308 pub tag: Register<String>, // string, not a Label namespace
309 pub tag_hidden: Register<bool>,
310 pub rect: Register<GridRect>, // atomic
311 pub dir: Register<PinDir>,
312 pub pin_accent: Register<Role>,
313 pub port_accent: Register<Role>,
314 pub port_pin_accent: Register<Role>,
315 pub flip_lr: Register<bool>,
316}
317
318/// Waypoint lock lives inside the atomic waypoints value: a polyline (with
319/// its locks) is one author's coherent intent — one register, whole-value LWW.
320#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
321pub struct Waypoint {
322 pub pos: GridPoint,
323 pub locked: bool,
324}
325
326pub struct Route {
327 pub owner: Register<BlockId>, // RouteTargets::Owner (membership)
328 pub name: Register<String>, // RouteTargets::Name
329 pub from: PinId, // RouteTargets::From
330 pub to: PinId, // RouteTargets::To
331 pub role: Register<Role>, // RouteTargets::Role
332 pub waypoints: Register<Vec<Waypoint>>, // RouteTargets::Waypoints (atomic)
333}
334
335/// DERIVED — lives in `DocumentCache`, rebuilt from `RouteLabel.owner`.
336pub struct RouteIndex {
337 pub labels: HashSet<RouteLabelId>,
338}
339
340/// Entity: a label placed along a route. Graduated from a value-keyed register
341/// family because entries are expected to grow properties.
342pub struct RouteLabel {
343 pub owner: Register<RouteId>, // RouteLabelTargets::Owner (membership)
344 pub pos: Register<FracVal>, // RouteLabelTargets::Pos (offset along route)
345}
346
347pub struct Text {
348 pub owner: Register<BlockId>, // TextTargets::Owner (membership)
349 pub text: Register<String>, // TextTargets::Text (atomic string)
350 pub pos: Register<GridPoint>, // TextTargets::Pos
351 pub role: Register<Role>, // TextTargets::Role
352}
353
354pub struct Comment {
355 pub owner: Register<BlockId>, // CommentTargets::Owner (membership)
356 pub rect: Register<GridRect>, // CommentTargets::Rect (atomic)
357 pub role: Register<Role>, // CommentTargets::Role
358 pub title: Label, // CommentTargets::Title(..)
359}
360
361/// Entity: free-floating placed image (block icons are the atomic Icon value).
362pub struct Image {
363 pub owner: Register<BlockId>, // ImageTargets::Owner (membership)
364 pub asset: Register<AssetHash>, // ImageTargets::Asset
365 pub rect: Register<ScreenRect>, // ImageTargets::Rect (atomic)
366}
367
368pub enum Asset {
369 Svg(Arc<[u8]>), // Arc: crosses the sync thread boundary
370 Png(Arc<[u8]>),
371}
372
373// ---------------------------------------------------------------------------
374// Design decisions (all resolved)
375// ---------------------------------------------------------------------------
376// STAMPS INLINE, NOT SIDECAR: a (ElementId, TargetPath)-keyed sidecar is a
377// parallel structure whose keys must mirror every struct field — the
378// two-homes pattern in infrastructure form. Stamps are first-class merge
379// state (snapshots need them; they must converge), so they live in the
380// Register. "Clock-free" is a property of the export projection, not storage.
381//
382// EDIT-VS-DELETE: delete wins, structurally — edits cannot target presence.
383// Losing edits land in retained inner, flagged, surface on Restore. The only
384// presence arbitration is delete-vs-restore: plain LWW on Live.presence.
385//
386// NO DANGLING ROUTES (a route exists only between live endpoints):
387// - SEQUENTIAL (bundled cascade): deleting a block records concrete Delete
388// commands in the same Change for its pins, every route terminating on
389// those pins (including routes owned by OTHER blocks/scopes — deletion is
390// inherently boundary-crossing; see ECO/lease design), those routes'
391// labels, and the owned subtree recursively. The Change is the undo unit.
392// Commit-time closure check (debug): no command in a delete batch leaves
393// a reference dangling within the batch's own view.
394// - CONCURRENT (repair by suppression): merges can compose a live route
395// with a tombstoned endpoint; the fold derives effective_liveness =
396// authored_presence && endpoints_live (DocumentCache::suppressed), flags
397// it, and never authors state (no synthesized stamps). Restore of the
398// endpoint automatically revives suppressed routes.
399//
400// ABSENCE: entities = map-absence (structural; unforgeable IDs mean nothing
401// races a nonexistent entity). Value slots = atomic Register<T> whose ZERO
402// value means absent (Block.icon): clearing is a stamped write of the zero,
403// so "when was it cleared" is the register stamp and clear-vs-edit is plain
404// LWW. The model is Option-free; every zero is meaningful.
405//
406// MERGE-COHERENCE OF BATCHED RESTRUCTURES: all commands in a Change share its
407// stamp, so competing bulk operations (e.g. two users each wrapping top-level
408// content in a new scope) resolve UNIFORMLY — one side wins every contested
409// register; no torn trees. Divergent wrapper scopes do not coalesce
410// (independent creations are independent intents); the loser's empty scope is
411// a post-merge lint entry alongside spatial overlap.