Skip to main content

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.