diff --git a/crates/ogar-doc-ir/Cargo.toml b/crates/ogar-doc-ir/Cargo.toml index 0d661f3..c5be6f7 100644 --- a/crates/ogar-doc-ir/Cargo.toml +++ b/crates/ogar-doc-ir/Cargo.toml @@ -11,21 +11,3 @@ description = "The source-agnostic perceptual IR for OGAR's document layer: a cl [dependencies] serde = { workspace = true } serde_json = "1.0" - -# The ClassView projection — OFF by default so the observation-IR core stays -# serde-only / no-canon (it lands ahead of the W4 classid ratification). Pulls -# ONLY the zero-dep masking contract (`ClassView` / `FieldMask` / `WideFieldMask`). -# The neutral IR hands out the mask + the values addressed through it; a CONSUMER -# (e.g. `lance-graph-arm-discovery`) builds its own `Dataset` from that. The -# neutral IR never depends on any one consumer — the arrow points OUT, never in. -# Wired via `git branch = "main"`, the same float-then-flip form the sibling -# `ogar-class-view` / `ogar-from-ruff` / `ogar-render-askama` crates use. -lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main", optional = true } - -[features] -default = [] -# The `project` module: `ClassView × WideFieldMask` masking over the in-memory -# `DocIr`. Same binary, zero serialization — presence is a mask bit, the values -# are addressed THROUGH the mask. A consumer turns the projection into its own -# arm `Dataset`; the neutral IR never names a consumer. (The Firewall, ADR-022/023.) -classview = ["dep:lance-graph-contract"] diff --git a/crates/ogar-doc-ir/src/compose.rs b/crates/ogar-doc-ir/src/compose.rs deleted file mode 100644 index 2401c7c..0000000 --- a/crates/ogar-doc-ir/src/compose.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! `compose` — the **composition layer** over the OGAR object graph -//! (W1 brick of `docs/DOCIR-COMPOSITION-LAYER.md`, grounded in -//! `docs/DOCIR-COMPOSITION-GROUNDING.md`; ledger `D-DOCIR-COMPOSITION`). -//! -//! # The one idea -//! -//! A composed document is a **lens through the object world, not a box where -//! copies of that world go to slowly decay**. The composition graph carries -//! ONLY document structure; every domain object enters through an -//! [`ObjectSlot`] — a typed projection portal (the `D-DOC-IR-SECOND-RETINA` -//! **A3 brick**, `ClassView × WideFieldMask`, plus an [`ObjectRef`] and a -//! [`ResolutionMode`]). The slot stores an address and a projection choice, -//! never the object's representation — the ActionText attachment doctrine, -//! transplanted (doctrine §1). -//! -//! # Placement (operator ruling A1) -//! -//! *"One `ogar-doc` crate; split axis is STATE not direction"* — so the -//! composition graph is a MODULE in this crate family, beside the observation -//! IR ([`crate::DocIr`]), never a sibling `ogar-doc-compose` crate. The -//! observation IR is untouched; a composed document references an observation -//! subtree through a slot (an imported scan appears as a portal, not as -//! pasted regions). -//! -//! # The load-bearing invariants (the observation IR's discipline, replayed) -//! -//! 1. **Closed node vocabulary + version marker.** [`from_json`] hard-fails on -//! any kind outside [`DocNode`]'s five variants or any version other than -//! [`DOC_COMPOSE_VERSION`]. The five kinds are the operator-specified slice -//! scope (doctrine §7: `Document / Section / Paragraph / Text / -//! ObjectSlot`); `Figure` / `Table` / `PageBreak` are a -//! `doc-compose.v2` bump, never a silent widening. -//! 2. **Canon-free.** serde-only, zero canon dependency — like the observation -//! IR, this lands ahead of mints. Masks travel in their WIRE form -//! (`u64` words, word `w` bit `b` ⇒ field position `w·64 + b` — the -//! `ogar-a2ui-frame` precedent), converting to typed -//! `FieldMask`/`WideFieldMask` at the contract membrane. -//! 3. **Explicit resolution.** Every slot names how it resolves — -//! [`ResolutionMode::Live`] (dashboards), [`ResolutionMode::Revision`] -//! (signed/GoBD artifacts), [`ResolutionMode::Snapshot`] (deleted objects -//! fall back). The printable form is the `ogar://` address; a URI without a -//! resolution suffix is a **refusal**, not a default. -//! 4. **RBAC is NOT this module's claim.** A slot's masks are a *request*; -//! the fail-closed `requested ∧ role` intersection happens server-side at -//! resolution time (grounding §A4 — the `ClassRbac` enforcement is -//! probe-gated, so the resolver carries the intersection itself). -//! -//! # Deferred (named, not hidden) -//! -//! The editor-authority operation log (`DocOp`, doctrine §4) is the NEXT -//! brick — this one is render-side only (build → validate → resolve → -//! render). CRDT backing is `DocOp`'s upgrade seam, not this module's. - -use serde::{Deserialize, Serialize}; - -/// Composition-IR version marker. [`from_json`] refuses any other value. -/// A `doc-compose.v2` is a NEW marker + (possibly) new [`DocNode`] kinds, -/// never a silent reshape. -pub const DOC_COMPOSE_VERSION: &str = "doc-compose.v1"; - -/// Index of a node in [`DocCompose::nodes`] (arena-style; the doctrine's -/// `NodeId`). A newtype so an id never silently mixes with a mask position -/// or an ordinal. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(transparent)] -pub struct NodeId(pub u32); - -/// A stable typed reference to an OGAR object — the address half of the -/// `ogar://{app}/{class}/{id}@{resolution}` printable form. Segments are the -/// codebook names (canon-free here); the classid resolves at the membrane -/// (`canonical_concept_id`), never in this module. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ObjectRef { - /// App/prefix segment (e.g. `"openproject"`, `"bim"`). - pub app: String, - /// Class segment (e.g. `"work-package"`, `"wall"`). - pub class: String, - /// Instance segment (the id as the app prints it). - pub id: String, -} - -/// How a slot resolves its target — the beyond-Rails move (doctrine §1): -/// a dashboard wants live content; a signed report wants a pinned revision; -/// a deleted object needs a snapshot fallback. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "mode", content = "value")] -pub enum ResolutionMode { - /// Resolve the object as it is now (`@live`). - Live, - /// Resolve a pinned revision (`@revision:N` — the Lance-versioning - /// shape, ADR-008/013). - Revision(u64), - /// Resolve a content-addressed snapshot (`@sha256:…` — the doc-KV - /// `DocumentId = sha256(raw)` discipline). - Snapshot([u8; 32]), -} - -/// Content-addressed fallback for a slot whose live target is gone. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SnapshotRef { - /// sha256 of the snapshotted projection. - pub content_sha256: [u8; 32], -} - -/// The typed projection portal — the A3 brick (`ClassView × WideFieldMask`) -/// made addressable. Stores the address and the projection CHOICE; never the -/// object's representation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ObjectSlot { - /// Which object. - pub target: ObjectRef, - /// Which NAMED semantic projection (e.g. `"work_package.inline"`, - /// `"user.card"`) — the `(class, mode)` registry key; renderer-independent - /// by definition (doctrine §2). - pub class_view: String, - /// Immediate-field selection, wire form (bit `i` ⇒ field position `i`). - pub field_mask: u64, - /// Traversal/nested selection, wire form (`u64` words, word `w` bit `b` - /// ⇒ position `w·64 + b` — positions ≥ 64 native, the #205 correction). - #[serde(default)] - pub wide_mask_words: Vec, - /// How the target resolves. - pub resolution: ResolutionMode, - /// Content-addressed fallback when the target is unresolvable. - #[serde(default)] - pub fallback: Option, -} - -/// The **closed** composition vocabulary — exactly the operator-specified -/// slice scope (doctrine §7). Exhaustive by design: a renderer gets a compile -/// error until it handles every kind. NO domain variants — a `Wall` or -/// `WorkPackage` enters only through [`DocNode::ObjectSlot`] (doctrine §3). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "kind")] -pub enum DocNode { - /// The document root — ordered children. - Document { - /// Child nodes in document order. - children: Vec, - }, - /// A section with an optional heading node. - Section { - /// Optional heading (a `Text` node, typically). - #[serde(default)] - heading: Option, - /// Child nodes in document order. - children: Vec, - }, - /// A paragraph of inline nodes. - Paragraph { - /// Inline children (`Text` / `ObjectSlot`) in order. - children: Vec, - }, - /// A run of plain text. - Text { - /// The text content. - text: String, - }, - /// A typed projection portal into the OGAR object graph. - ObjectSlot { - /// The slot. - slot: ObjectSlot, - }, -} - -/// A composed document: an arena of nodes + the root. The arena keeps ids -/// stable under edit (the future `DocOp` brick appends; it never re-indexes). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DocCompose { - /// Must equal [`DOC_COMPOSE_VERSION`]; [`from_json`] enforces it. - pub version: String, - /// The node arena. - pub nodes: Vec, - /// The root node (must be in range; see [`DocCompose::validate`]). - pub root: NodeId, -} - -/// Why the composition layer refused an input. -#[derive(Debug)] -pub enum ComposeError { - /// Malformed JSON or an off-vocabulary [`DocNode`] / [`ResolutionMode`] - /// (serde rejects unknown enum values — the closed-vocab gate). - Json(serde_json::Error), - /// The `version` field was not [`DOC_COMPOSE_VERSION`]. - WrongVersion { - /// The version the document carried. - found: String, - /// The version this module accepts. - expected: &'static str, - }, - /// An `ogar://` address that does not parse; the reason is spelled out. - BadUri(String), - /// A structurally invalid graph (out-of-range id, or a cycle reachable - /// from the root). - Invalid(String), -} - -impl core::fmt::Display for ComposeError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Json(e) => write!(f, "doc-compose JSON rejected (closed vocabulary): {e}"), - Self::WrongVersion { found, expected } => write!( - f, - "doc-compose version `{found}` unsupported (this module reads `{expected}`; \ - a new version is a deliberate migration, not a silent reshape)" - ), - Self::BadUri(reason) => write!(f, "ogar:// address rejected: {reason}"), - Self::Invalid(reason) => write!(f, "doc-compose graph rejected: {reason}"), - } - } -} - -impl std::error::Error for ComposeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Json(e) => Some(e), - _ => None, - } - } -} - -/// The **load gate** — closed-vocabulary + version enforcement + structural -/// validation at the boundary (the observation IR's `from_json` discipline). -/// -/// # Errors -/// -/// [`ComposeError::Json`] on malformed JSON or an off-vocab enum value; -/// [`ComposeError::WrongVersion`] when `version != `[`DOC_COMPOSE_VERSION`]; -/// [`ComposeError::Invalid`] when [`DocCompose::validate`] refuses the graph. -pub fn from_json(s: &str) -> Result { - let doc: DocCompose = serde_json::from_str(s).map_err(ComposeError::Json)?; - if doc.version != DOC_COMPOSE_VERSION { - return Err(ComposeError::WrongVersion { - found: doc.version, - expected: DOC_COMPOSE_VERSION, - }); - } - doc.validate()?; - Ok(doc) -} - -/// Serialize a [`DocCompose`] to JSON (the symmetric egress of [`from_json`]). -/// -/// # Errors -/// -/// Propagates any `serde_json` serialization error. -pub fn to_json(doc: &DocCompose) -> Result { - serde_json::to_string(doc) -} - -impl DocCompose { - /// Structural validation: every referenced id is in range, and the graph - /// reachable from [`Self::root`] is acyclic. Orphan nodes (arena slack - /// left by future edits) are legal; dangling REFERENCES are not. - /// - /// # Errors - /// - /// [`ComposeError::Invalid`] naming the offending id. - pub fn validate(&self) -> Result<(), ComposeError> { - let len = self.nodes.len() as u32; - let check = |id: NodeId| -> Result<(), ComposeError> { - if id.0 < len { - Ok(()) - } else { - Err(ComposeError::Invalid(format!( - "node id {} out of range (arena has {len} nodes)", - id.0 - ))) - } - }; - check(self.root)?; - for node in &self.nodes { - match node { - DocNode::Document { children } | DocNode::Paragraph { children } => { - for &c in children { - check(c)?; - } - } - DocNode::Section { heading, children } => { - if let Some(h) = heading { - check(*h)?; - } - for &c in children { - check(c)?; - } - } - DocNode::Text { .. } | DocNode::ObjectSlot { .. } => {} - } - } - // Cycle check on the subgraph reachable from root (iterative DFS with - // an explicit in-stack set — no recursion, no depth limit surprises). - let mut state = vec![0_u8; self.nodes.len()]; // 0 unvisited, 1 in-stack, 2 done - let mut stack: Vec<(u32, usize)> = vec![(self.root.0, 0)]; - state[self.root.0 as usize] = 1; - while let Some(&mut (id, ref mut next)) = stack.last_mut() { - let kids: Vec = match &self.nodes[id as usize] { - DocNode::Document { children } | DocNode::Paragraph { children } => { - children.iter().map(|c| c.0).collect() - } - DocNode::Section { heading, children } => heading - .iter() - .map(|h| h.0) - .chain(children.iter().map(|c| c.0)) - .collect(), - DocNode::Text { .. } | DocNode::ObjectSlot { .. } => Vec::new(), - }; - if *next < kids.len() { - let k = kids[*next]; - *next += 1; - match state[k as usize] { - 0 => { - state[k as usize] = 1; - stack.push((k, 0)); - } - 1 => { - return Err(ComposeError::Invalid(format!( - "cycle through node id {k} (a document is a tree, not a loop)" - ))); - } - _ => {} - } - } else { - state[id as usize] = 2; - stack.pop(); - } - } - Ok(()) - } -} - -// ───────────────────────────────────────────────────────────────────────── -// The `ogar://` printable address (doctrine §1 — the SGID equivalent) -// ───────────────────────────────────────────────────────────────────────── - -/// Parse an `ogar://{app}/{class}/{id}@{resolution}` address. Strict: every -/// malformed input is a refusal — there is no default resolution, no lenient -/// segment count, no silent hex truncation. -/// -/// # Errors -/// -/// [`ComposeError::BadUri`] naming the exact defect. -pub fn parse_ogar_uri(uri: &str) -> Result<(ObjectRef, ResolutionMode), ComposeError> { - let rest = uri - .strip_prefix("ogar://") - .ok_or_else(|| ComposeError::BadUri(format!("missing `ogar://` scheme in `{uri}`")))?; - let (path, res) = rest - .rsplit_once('@') - .ok_or_else(|| ComposeError::BadUri(format!("missing `@resolution` suffix in `{uri}`")))?; - let segs: Vec<&str> = path.split('/').collect(); - let [app, class, id] = segs.as_slice() else { - return Err(ComposeError::BadUri(format!( - "path must be app/class/id (got {} segment(s)) in `{uri}`", - segs.len() - ))); - }; - if app.is_empty() || class.is_empty() || id.is_empty() { - return Err(ComposeError::BadUri(format!( - "empty path segment in `{uri}`" - ))); - } - let resolution = if res == "live" { - ResolutionMode::Live - } else if let Some(n) = res.strip_prefix("revision:") { - ResolutionMode::Revision( - n.parse::().map_err(|_| { - ComposeError::BadUri(format!("revision `{n}` is not a u64 in `{uri}`")) - })?, - ) - } else if let Some(hex) = res.strip_prefix("sha256:") { - ResolutionMode::Snapshot( - parse_sha256_hex(hex) - .map_err(|reason| ComposeError::BadUri(format!("{reason} in `{uri}`")))?, - ) - } else { - return Err(ComposeError::BadUri(format!( - "unknown resolution `{res}` (expected `live` | `revision:N` | `sha256:<64 hex>`) in `{uri}`" - ))); - }; - Ok(( - ObjectRef { - app: (*app).to_string(), - class: (*class).to_string(), - id: (*id).to_string(), - }, - resolution, - )) -} - -/// Print the `ogar://` address (the symmetric egress of [`parse_ogar_uri`]). -#[must_use] -pub fn format_ogar_uri(target: &ObjectRef, resolution: &ResolutionMode) -> String { - let res = match resolution { - ResolutionMode::Live => "live".to_string(), - ResolutionMode::Revision(n) => format!("revision:{n}"), - ResolutionMode::Snapshot(h) => format!("sha256:{}", sha256_hex(h)), - }; - format!( - "ogar://{}/{}/{}@{}", - target.app, target.class, target.id, res - ) -} - -fn parse_sha256_hex(hex: &str) -> Result<[u8; 32], String> { - if hex.len() != 64 { - return Err(format!("sha256 must be 64 hex chars (got {})", hex.len())); - } - let mut out = [0_u8; 32]; - for (i, byte) in out.iter_mut().enumerate() { - let pair = &hex[i * 2..i * 2 + 2]; - *byte = u8::from_str_radix(pair, 16) - .map_err(|_| format!("non-hex byte `{pair}` at position {}", i * 2))?; - } - Ok(out) -} - -fn sha256_hex(h: &[u8; 32]) -> String { - let mut s = String::with_capacity(64); - for b in h { - use core::fmt::Write as _; - let _ = write!(s, "{b:02x}"); - } - s -} - -#[cfg(test)] -mod tests { - use super::*; - - fn slot(app: &str, class: &str, id: &str, view: &str) -> ObjectSlot { - ObjectSlot { - target: ObjectRef { - app: app.to_string(), - class: class.to_string(), - id: id.to_string(), - }, - class_view: view.to_string(), - field_mask: 0b1011, - wide_mask_words: vec![0, 1 << 5], // position 69 — wide is native - resolution: ResolutionMode::Live, - fallback: None, - } - } - - /// The §7 proof shape: a WorkPackage.description composing text + three - /// attachable classes, each through a slot — never as copied objects. - fn slice_doc() -> DocCompose { - DocCompose { - version: DOC_COMPOSE_VERSION.to_string(), - nodes: vec![ - // 0: root - DocNode::Document { - children: vec![NodeId(1)], - }, - // 1: one paragraph - DocNode::Paragraph { - children: vec![NodeId(2), NodeId(3), NodeId(4), NodeId(5)], - }, - // 2: text run - DocNode::Text { - text: "Install fire door — assigned to ".to_string(), - }, - // 3..5: the three attachables (doctrine §7) - DocNode::ObjectSlot { - slot: slot("openproject", "user", "42", "user.inline"), - }, - DocNode::ObjectSlot { - slot: slot("openproject", "work-package", "8f31", "work_package.inline"), - }, - DocNode::ObjectSlot { - slot: slot("openproject", "attachment", "7", "attachment.inline"), - }, - ], - root: NodeId(0), - } - } - - #[test] - fn slice_doc_round_trips_through_the_load_gate() { - let doc = slice_doc(); - let json = to_json(&doc).expect("serialize"); - let back = from_json(&json).expect("load gate accepts the slice shape"); - assert_eq!(doc, back); - } - - #[test] - fn wrong_version_is_a_refusal() { - let mut doc = slice_doc(); - doc.version = "doc-compose.v2".to_string(); - let json = to_json(&doc).expect("serialize"); - assert!(matches!( - from_json(&json), - Err(ComposeError::WrongVersion { .. }) - )); - } - - #[test] - fn unknown_node_kind_is_a_refusal() { - // A domain variant smuggled into the composition vocabulary — the §3 - // rule: `Wall` belongs to OGAR, never to DocNode. - let json = format!( - r#"{{"version":"{DOC_COMPOSE_VERSION}","nodes":[{{"kind":"wall","name":"W1"}}],"root":0}}"# - ); - assert!(matches!(from_json(&json), Err(ComposeError::Json(_)))); - } - - #[test] - fn out_of_range_reference_is_a_refusal() { - let doc = DocCompose { - version: DOC_COMPOSE_VERSION.to_string(), - nodes: vec![DocNode::Document { - children: vec![NodeId(7)], - }], - root: NodeId(0), - }; - assert!(matches!(doc.validate(), Err(ComposeError::Invalid(_)))); - } - - #[test] - fn cycle_is_a_refusal() { - let doc = DocCompose { - version: DOC_COMPOSE_VERSION.to_string(), - nodes: vec![ - DocNode::Document { - children: vec![NodeId(1)], - }, - DocNode::Paragraph { - children: vec![NodeId(0)], - }, - ], - root: NodeId(0), - }; - assert!(matches!(doc.validate(), Err(ComposeError::Invalid(_)))); - } - - #[test] - fn ogar_uri_round_trips_all_three_resolution_arms() { - let cases = [ - "ogar://openproject/work-package/8f31@live", - "ogar://bim/wall/a127@revision:42", - &format!("ogar://document/drawing/c920@sha256:{}", "ab".repeat(32)), - ]; - for uri in cases { - let (target, resolution) = parse_ogar_uri(uri).expect("parse"); - assert_eq!(format_ogar_uri(&target, &resolution), *uri); - } - } - - #[test] - fn ogar_uri_refusals_are_loud() { - for bad in [ - "http://openproject/work-package/8f31@live", // wrong scheme - "ogar://openproject/work-package/8f31", // no resolution — no default - "ogar://openproject/8f31@live", // two segments - "ogar://openproject//8f31@live", // empty segment - "ogar://a/b/c@revision:many", // non-numeric revision - "ogar://a/b/c@sha256:abcd", // short hex - "ogar://a/b/c@pinned", // unknown mode - ] { - assert!( - matches!(parse_ogar_uri(bad), Err(ComposeError::BadUri(_))), - "should refuse `{bad}`" - ); - } - } - - #[test] - fn snapshot_fallback_survives_serde() { - let mut doc = slice_doc(); - if let DocNode::ObjectSlot { slot } = &mut doc.nodes[3] { - slot.resolution = ResolutionMode::Revision(42); - slot.fallback = Some(SnapshotRef { - content_sha256: [0xAB; 32], - }); - } - let json = to_json(&doc).expect("serialize"); - let back = from_json(&json).expect("load"); - assert_eq!(doc, back); - } -} diff --git a/crates/ogar-doc-ir/src/lib.rs b/crates/ogar-doc-ir/src/lib.rs index 182395c..48b5da2 100644 --- a/crates/ogar-doc-ir/src/lib.rs +++ b/crates/ogar-doc-ir/src/lib.rs @@ -44,23 +44,6 @@ //! the doc-layer 5+3 council. This crate is pure perceptual structure with no //! canon dependency, so it can land ahead of that ratification. //! -//! # Observation IR vs. the composition layer (DocIr composition grounding) -//! -//! [`DocIr`] here is the **observation IR** — the perceptual retina (what a -//! scan / DOM faithfully *saw*). The 2026-07-20 ruling -//! (`docs/DOCIR-COMPOSITION-LAYER.md`, #217; grounded in -//! `docs/DOCIR-COMPOSITION-GROUNDING.md`, #218) promotes *DocIr* to also name a -//! **composition layer** over the OGAR object graph — a `DocNode` graph of -//! `Text` / `Section` / `ObjectSlot` (typed projection portals into OGAR -//! nodes). Per operator ruling **A1** (*"one `ogar-doc` crate; split axis is -//! STATE not direction"*), that composition `DocNode` is a **module in this -//! crate family, never a sibling `ogar-doc-compose`**. It **references this -//! observation IR through an `ObjectSlot`** (an imported scan appears in a -//! composed document as a portal, not as pasted regions) — this observation IR -//! is **untouched, referenced, never retyped in place**. An `ObjectSlot` is the -//! `D-DOC-IR-SECOND-RETINA` **A3 brick** (`ClassView × WideFieldMask`, "same -//! brick as Klickwege — no new DSL") plus an `ObjectRef` + a `ResolutionMode`. -//! //! # Refinements this crate makes to the plan's P6 sketch (building validated the spec) //! //! - **Enums are EXHAUSTIVE, not `#[non_exhaustive]`.** A closed vocabulary @@ -74,10 +57,6 @@ use serde::{Deserialize, Serialize}; -pub mod compose; -#[cfg(feature = "classview")] -pub mod project; - /// Perceptual-IR version marker. [`from_json`] refuses any other value. /// A `doc.v2` is a NEW marker + (possibly) new [`RegionKind`]s, never a /// silent reshape of `doc.v1`. diff --git a/crates/ogar-doc-ir/src/project.rs b/crates/ogar-doc-ir/src/project.rs deleted file mode 100644 index 35e284f..0000000 --- a/crates/ogar-doc-ir/src/project.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! `project` — the **ClassView × WideFieldMask projection** of the observation IR. -//! -//! # The arrow points OUT -//! -//! This module is how a [`DocIr`](crate::DocIr) becomes *addressable* by a -//! class's field basis. It depends on ONLY the zero-dep masking contract -//! (`lance_graph_contract::class_view`: [`ClassView`], [`WideFieldMask`]) and -//! hands out two things: -//! -//! - a **presence mask** ([`field_mask`]) — bit `i` set iff the document carries -//! the `i`-th field of the class; -//! - the **values addressed through that mask** ([`masked_values`]) — each -//! present field paired with the typed string read from the in-memory `DocIr`. -//! -//! A *consumer* (e.g. `lance-graph-arm-discovery`) turns those into its own -//! `Dataset` / `FeatureSpec`: **presence is the mask bit** (a binary feature, no -//! `∅` sentinel invented anywhere), and **categories are the addressed typed -//! values** (the consumer interns them). The neutral IR NEVER names a consumer — -//! it does not depend on the arm crate, does not build a `Dataset`, and does not -//! serialize. This is `ClassView` addressing as **masking over existing in-memory -//! data**, not a wire format (the Firewall, ADR-022/023). -//! -//! # Why a mask and not a re-typed struct -//! -//! The `DocIr` is untouched: `field_mask` reads `ir.fields` and -//! `view.fields(class)` and produces a [`WideFieldMask`] whose bit `i` is the -//! stable `ClassView` field position `i` (N3 append-only). Nothing is copied, -//! parsed, or reshaped — the projection is a *view*, addressed by the class. - -use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; - -use crate::DocIr; - -/// A class field the document carries, with its typed value read **through the -/// mask** — no copy, no serde. -/// -/// `position` is the stable [`WideFieldMask`] bit (= the field's index in -/// [`ClassView::fields`]); `value` borrows the `DocIr`'s -/// [`TypedField`](crate::TypedField) string in place. A consumer interns -/// `value` into its own category codebook. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MaskedField<'a> { - /// The class field position — the `WideFieldMask` bit this field occupies. - pub position: u8, - /// The typed value, borrowed from the in-memory `DocIr` (never serialized). - pub value: &'a str, -} - -/// The position of a `DocIr` field `key` within a class's ordered field set, or -/// `None` if the class does not declare it. -/// -/// Matches the `FieldRef` by `label` first, then `predicate_iri` — the two names -/// a `ClassView` field carries. A key the class does not know is **not present** -/// (its bit is never set) — the exact discipline of the doc-IR load gate: an -/// off-vocabulary field is dropped at the addressing boundary, never folded onto -/// a valid bit. Positions `>= 256` are unaddressable in a `u8` mask and return -/// `None`. -#[must_use] -pub fn position_of(view: &V, class: ClassId, key: &str) -> Option { - view.fields(class) - .iter() - .position(|f| f.label == key || f.predicate_iri == key) - .and_then(|p| u8::try_from(p).ok()) -} - -/// The **presence mask** — bit `i` set iff the document carries the `i`-th field -/// of `class`. -/// -/// This IS the `ClassView × WideFieldMask` masking over the in-memory `DocIr`: -/// it reads `ir.fields` and `view.fields(class)`, and nothing else. A consumer -/// reads bit `i` as the binary presence feature for field `i` — **presence is -/// the mask bit**, never a category value invented at a boundary. Zero -/// serialization. -#[must_use] -pub fn field_mask(ir: &DocIr, view: &V, class: ClassId) -> WideFieldMask { - let mut mask = WideFieldMask::EMPTY; - for tf in &ir.fields { - if let Some(pos) = position_of(view, class, &tf.key) { - mask = mask.with(pos); - } - } - mask -} - -/// The **values addressed through the mask** — one [`MaskedField`] per class -/// field the document carries, in class (bit) order. -/// -/// Presence is the mask; the value is the typed string read in place. A consumer -/// interns these into its own arm `Dataset` categories; this crate neither -/// interns nor serializes. When a document carries two typed fields that resolve -/// to the same class position, the **first wins** (document order) — the mask is -/// presence, not a multiset — so the returned rows are exactly the set bits of -/// [`field_mask`], addressed. -#[must_use] -pub fn masked_values<'a, V: ClassView>( - ir: &'a DocIr, - view: &V, - class: ClassId, -) -> Vec> { - let n = view.field_count(class); - let mut slots: Vec>> = vec![None; n]; - for tf in &ir.fields { - if let Some(pos) = position_of(view, class, &tf.key) { - let idx = pos as usize; - if idx < n && slots[idx].is_none() { - slots[idx] = Some(MaskedField { - position: pos, - value: tf.value.as_str(), - }); - } - } - } - slots.into_iter().flatten().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{BBoxRail, DOC_IR_VERSION, DocIr, Geometry, Provenance, Rail, TypedField}; - use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; - - /// A minimal in-crate `ClassView` — a fixed ordered field set per class. - /// Stands in for the W4 OGIT-backed `OgarClassView`; the projection is - /// generic over `ClassView`, so a test impl proves the masking without a - /// canon dependency. - struct TestView { - fields: Vec, - } - - impl TestView { - /// An "invoice" class: netto, ust, iban — the field basis (bit order). - fn invoice() -> Self { - Self { - fields: vec![ - FieldRef::new("inv:netto", "netto"), - FieldRef::new("inv:ust", "ust"), - FieldRef::new("inv:iban", "iban"), - ], - } - } - } - - impl ClassView for TestView { - fn fields(&self, _class: ClassId) -> &[FieldRef] { - &self.fields - } - fn template(&self, _class: ClassId) -> DisplayTemplate { - DisplayTemplate::Card - } - fn dolce_category_id(&self, _class: ClassId) -> u8 { - 0 - } - } - - fn bbox() -> BBoxRail { - BBoxRail { - tl: Rail { x: 0, y: 0 }, - br: Rail { x: 1, y: 1 }, - } - } - - fn field(key: &str, value: &str) -> TypedField { - TypedField { - key: key.to_string(), - value: value.to_string(), - bbox: bbox(), - confidence: 200, - } - } - - /// A doc carrying netto + iban but NOT ust, plus an off-vocabulary field. - fn doc(fields: Vec) -> DocIr { - DocIr { - version: DOC_IR_VERSION.to_string(), - source: Provenance::Ocr, - geometry: Geometry::Rendered, - content_sha256: [0u8; 32], - mime: "image/png".to_string(), - pages: vec![], - fields, - } - } - - #[test] - fn mask_sets_exactly_the_carried_field_bits() { - let view = TestView::invoice(); - let ir = doc(vec![field("netto", "100.00"), field("iban", "DE00")]); - let mask = field_mask(&ir, &view, 0); - assert!(mask.has(0), "netto (position 0) present"); - assert!( - !mask.has(1), - "ust (position 1) absent — bit clear, not a category" - ); - assert!(mask.has(2), "iban (position 2) present"); - assert_eq!(mask.count(), 2); - } - - #[test] - fn off_vocabulary_field_is_dropped_at_the_boundary() { - let view = TestView::invoice(); - // `skonto` is not a class field — it must not set any bit. - let ir = doc(vec![field("netto", "100.00"), field("skonto", "2%")]); - let mask = field_mask(&ir, &view, 0); - assert_eq!(mask.count(), 1, "only netto addressed; skonto dropped"); - assert!(mask.has(0)); - } - - #[test] - fn matches_by_predicate_iri_too() { - let view = TestView::invoice(); - let ir = doc(vec![field("inv:ust", "19")]); // key = predicate_iri, not label - let mask = field_mask(&ir, &view, 0); - assert!(mask.has(1), "ust addressed via predicate_iri"); - } - - #[test] - fn masked_values_are_the_typed_values_in_bit_order() { - let view = TestView::invoice(); - let ir = doc(vec![field("iban", "DE00"), field("netto", "100.00")]); - let rows = masked_values(&ir, &view, 0); - // Bit order (0=netto, 2=iban), regardless of document order. - assert_eq!(rows.len(), 2); - assert_eq!( - rows[0], - MaskedField { - position: 0, - value: "100.00" - } - ); - assert_eq!( - rows[1], - MaskedField { - position: 2, - value: "DE00" - } - ); - } - - #[test] - fn masked_values_agree_with_the_mask() { - let view = TestView::invoice(); - let ir = doc(vec![field("netto", "100.00"), field("iban", "DE00")]); - let mask = field_mask(&ir, &view, 0); - let rows = masked_values(&ir, &view, 0); - assert_eq!(rows.len(), mask.count() as usize); - for r in &rows { - assert!(mask.has(r.position), "every addressed value is a set bit"); - } - } - - #[test] - fn duplicate_field_first_wins() { - let view = TestView::invoice(); - let ir = doc(vec![field("netto", "100.00"), field("netto", "999.99")]); - let rows = masked_values(&ir, &view, 0); - assert_eq!(rows.len(), 1, "mask is presence, not a multiset"); - assert_eq!(rows[0].value, "100.00", "first document occurrence wins"); - } -} diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 8d934ea..cd67bb8 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1481,72 +1481,3 @@ isolation. The map's job is to keep them visible. BimObject). Named gaps: ObjectSlot/ResolutionMode/`ogar://` · composition DocNode · DocOp · FieldView + ProjectionRenderer · **named multi-views per class** (registry is single-view today) · ogar-render-typst. - **Grounding 2026-07-20 (`docs/DOCIR-COMPOSITION-GROUNDING.md`, this branch):** - the "new" names are **expansions of shipped/ruled bricks, not new - architecture** — `ObjectSlot` = the `D-DOC-IR-SECOND-RETINA` A3 brick - (*"document template = ClassView × WideFieldMask, same brick as Klickwege"*) - made addressable (+ `ObjectRef` + `ResolutionMode`); the doctrine's - renderer-neutral `FieldView` enum = the `E-ONE-MASK-THREE-PORTS` **fieldview - fold** formalized (the shipped `ogar-render-askama::field_view::FieldView` - struct, `field_view.rs:58`, is its **`Text`-leaf** reading — widen, rename the - struct to `FieldRow`; NOT a collision); `ProjectionRenderer` = the ruled - **`DocRenderer` trait** (A3, *"gains its fourth adapter"*) widened; - **named multi-view = mask-per-mode**, registry key `ClassId → (ClassId, mode)` - (`OgarClassView.by_id`, `ogar-class-view/src/lib.rs:311`); RBAC by projection - = the ruled `classview_mask ∧ role_mask` (#205) — **transport-side CODED - (`ogar-a2ui-frame` wide masks, projection-before-framing), but the - `ClassRbac`/`effective_mask` ENFORCEMENT is spec + probe-gated - (`PROBE-OGAR-RBAC-AUTHORIZE`, ISS-RBAC-AUTHORIZE-BY-CLASSID), NOT shipped — so - the slice carries the fail-closed intersection itself** (codex P2 on #218, - verified: no `impl ClassRbac`/`effective_mask` under `crates/`). **Crate - placement bound by - the A1 ruling** (*"one `ogar-doc` crate; split axis is STATE not - direction"*) → composition is a MODULE in the doc-ir family, not a sibling - `ogar-doc-compose`. First-slice landing zone grounded to exact files in - `openproject-nexgen-rs` (`op-work-packages/src/work_package.rs:74-76` - `description: Formattable`; render seam `op-server/src/board.rs:735/1446` - `CellData::RichText`; `@mention`/WP-link parsers are all-TODO stubs → clean). - Grounds against, not ahead of, the `OGAR-AS-IR.md` six-test gate. Docs-only. - **Status 2026-07-20 — W1 composition brick CODED (canon-free):** - `ogar-doc-ir::compose` landed per the A1 module placement — the closed - 5-kind `DocNode` vocabulary (`Document/Section/Paragraph/Text/ObjectSlot`, - exactly the §7 slice scope; `Figure/Table/PageBreak` = a `doc-compose.v2` - bump), `ObjectSlot{target, class_view (named view), field_mask u64, - wide_mask_words Vec (wire form, positions ≥ 64 native — the - `ogar-a2ui-frame` precedent), resolution, fallback}`, - `ResolutionMode{Live, Revision(u64), Snapshot([u8;32])}`, strict `ogar://` - parse/format (all three arms; missing resolution suffix = refusal, no - default), `DOC_COMPOSE_VERSION = "doc-compose.v1"` load gate + structural - validate (out-of-range refusal, cycle refusal). serde-only, ZERO canon - dependency — the observation IR untouched beside it. 9 new tests incl. the - §7 proof shape (a `WorkPackage.description` composing Text + User/WP/ - Attachment slots), version/kind/URI refusal gates, wide-position + fallback - round-trips; 16/16 crate tests green, clippy/fmt clean. Module doc pins the - §A4 RBAC posture (slot masks are a REQUEST; the fail-closed `∧ role` - intersection is the resolver's, enforcement probe-gated). Deferred, named: - `DocOp` (§4 editor authority — next brick), `FieldView` enum widening + - `(ClassId, mode)` registry (ogar-render-askama / ogar-class-view side), - `ogar-render-typst`. - -- **[D-STL-GEOMETRY-REDISCOVERY] the STL→mesh→surfel / `ogar-bim` geometry chunk - is a rediscovery of the "address is geometry" arc** — `[S]` (PROPOSAL- - GROUNDING, 2026-07-20; **council-pending, NOT adopted** — no mint, no crate, - no code) — home: `docs/DOCIR-COMPOSITION-GROUNDING.md` Part B — depends: - D-FMA-SKELETON, D-GUID-TIER (`HhtlMode::Located` CODED), D-BOUNDS, D-CESIUM- - PROBE, lance-graph `helix` crate + `jc::ewa_sandwich_3d` + the `3DGS-*.md` - plan family. Reuse-map (defer build): a spatial-LOD `HhtlNode` = the shipped - **`HhtlMode::Located`** reading (`ogar-fma-skeleton/src/guid.rs:328`, - `located_3d`/`morton3`, Cesium 3D-octree CRS) — HHTL stays key-path canon, a - spatial index is the `Located` reading, NOT a new hierarchy; `HelixAddress` = - the shipped **`helix::ResidueEdge`** sphere codec (L7 `hhtl++helix`) — surfel - normals ride it, never a new name; `Surfel` math = `jc` + `ndarray::splat3d` - (certified); `Aabb` = **derived from address (D-BOUNDS), never stored**; - `BufferRef` = the anatomy-mesh out-of-line-Lance pattern - (`canonical_node.rs:706-723`, the 4M-vert mesh named verbatim); `PartGraph` - extends `same_family` partonomy. The one genuinely-new sliver: an **STL-ingest - producer** (mirror of `ogar-from-ruff`) feeding the existing address+splat - substrate — **probe-gated** (D-FMA-SKELETON grades splat-fit convergence - CONJECTURE; the next deliverable is the round-trip probe, not synthesis). The - `ogar-bim` **semantic-object** half (a `Wall` node + `document-inline` - ClassView, addressable via `ObjectSlot`) can proceed independently of the mesh - pipeline. Naming discipline: widen the shipped carrier, never fork it. diff --git a/docs/DOCIR-COMPOSITION-GROUNDING.md b/docs/DOCIR-COMPOSITION-GROUNDING.md deleted file mode 100644 index 6855779..0000000 --- a/docs/DOCIR-COMPOSITION-GROUNDING.md +++ /dev/null @@ -1,271 +0,0 @@ -# Grounding: the DocIr Composition Layer (+ the STL/geometry chunk) - -> **Companion to `docs/DOCIR-COMPOSITION-LAYER.md`** (the operator ruling, -> #217, ADOPTED). This doc does not restate the doctrine — it **grounds it in -> shipped code and the append-only ledger**, and answers the "produce a repo -> map / gap analysis / crate boundary / smallest slice / exact files / -> terminology" ask that came with the 2026-07-20 feedback. -> -> **Two parts, two grades.** -> **Part A** grounds the DocIr composition layer — `[G]`, because it is the -> continuation of an operator-ruled arc, and every "new" name turns out to be -> an **expansion of a brick the canon already shipped or ruled**, not fresh -> architecture. -> **Part B** grounds the STL / mesh / surfel / `ogar-bim` chunk — `[S]`, -> **council-pending, not adopted**: it is largely a **rediscovery** of the -> "address is geometry" arc (`D-FMA-SKELETON` / `D-GUID-TIER` / `D-BOUNDS` / -> `helix` / `jc` + the lance-graph `3DGS-*` plans). Part B is a **reuse-map -> that defers the build** — recorded so a future session does not rebuild what -> exists; it mints nothing, adds no crate, writes no code. -> -> Verbatim-preservation rule (as in the doctrine): the operator's `[G]` -> formulations are canon text; this grounding regrades or appends, never -> rewrites. - ---- - -## Part A — DocIr composition is the expansion of the doc-layer arc `[G]` - -### A0. The arc it continues (not a greenfield proposal) - -The composition layer is the fourth beat of one arc, each beat the **same -positional-projection brick at a new altitude**: - -``` -D-OGAR-DOC-LAYER (#191) → persist/reconstruct: document node = {sha256, kv_key, - mime, counts}; awareness = GUID-keyed SoA subtree; - reconstruct_document ActionDef; DocRenderer trait -D-DOC-IR-SECOND-RETINA → doc.v1 promoted to the perceptual IR (ogar-doc-ir); - (#197 / #199) N retinas → one closed doc IR; A3: "document template - = ClassView × WideFieldMask, same brick as Klickwege - — no new DSL"; "a screen and a document are the SAME - positional projection; DocRenderer gains adapters" -D-A2UI-SCREEN-ADDRESSING → the live surface is DocRenderer's fourth adapter; - (#204–#207) RBAC by projection = WideFieldMask ∩ role-mask; - ogar-render-askama::field_view is the render half -D-DOCIR-COMPOSITION (#217)→ THIS: the document becomes a lens over the object - graph; ObjectSlots are typed projection portals -``` - -Reading the composition layer against this arc dissolves the "is this a new -subsystem?" question: it is the same brick, made **addressable to a foreign -node**. - -### A1. The expansion map (the "new" names are widenings, not collisions) - -Every element the doctrine frames as new is an expansion of a shipped/ruled -brick. The corrected framing (a naming *collision* would force an arbitrary -rename; an *expansion* keeps the concept and widens it — the general name goes -to the general concept): - -| Doctrine element | The canon it expands | Grounding | Verdict | -|---|---|---|---| -| **`ObjectSlot`** (typed projection portal) | `D-DOC-IR-SECOND-RETINA` ruling **A3**: *"document template = ClassView × WideFieldMask, same brick as Klickwege — no new DSL."* | `ObjectSlot{target, class_view, field_mask, wide_field_mask, resolution}` **is** that A3 brick + an `ObjectRef` + a `ResolutionMode`. The portal is the doc-template projection pointed at another node. | **Expansion** (A3 brick made addressable) | -| **`FieldView`** (renderer-neutral enum) | `E-ONE-MASK-THREE-PORTS` / `E-OGAR-CONVERGENCE-SHAPE`: render is *"classview fieldmask → **fieldview fold** → askama/jinja"*; the fold is *"one fieldview partial per column type × skin, ~22 of them"* resolved longest-prefix (app-specific → concept-canonical). | The shipped `ogar-render-askama::field_view::FieldView` (`field_view.rs:58` — `struct { position, label, predicate, value }`) is the **text-leaf reading** of that fold. The doctrine's `enum FieldView {Text, Badge, Table, ObjectSlot, Geometry, …}` is **the fold formalized as its variant set**. The general name (`FieldView`) belongs to the general concept (the enum); today's struct is specifically an addressed field *row* and becomes the enum's `Text` payload (rename to e.g. `FieldRow`). | **Expansion, not collision** — the struct is the narrow reading of the widened concept | -| **`ProjectionRenderer`** (trait) | `A3`: *"a screen and a document are the SAME positional projection; **`DocRenderer` gains its fourth adapter**."* The trait already exists as `DocRenderer` (one-leg rule; runtime-bound tesseract / Spire.Doc / askama adapters). | The doctrine's `ProjectionRenderer` **is** `DocRenderer`. Askama/Typst/Blitz/Vello are adapters on the existing trait; do not mint a second trait — widen `DocRenderer`'s adapter set (and rename `ProjectionRenderer → DocRenderer` in the composition types). | **Expansion** (existing trait, more adapters) | -| **Named multi-view per class** (`WorkPackage.inline` vs `.summary`) | `E-ONE-MASK-THREE-PORTS`: a classview bitmask is **per-mode** ("one mask, three projections … per mode"). | Today `OgarClassView.by_id: HashMap` (`ogar-class-view/src/lib.rs:311`) keys by bare `ClassId` — one view per class. The expansion is to key by **`(ClassId, mode)`**; a "named view" is a mode, a mode is a mask. This is the one structural registry change the slice forces. | **Expansion** of the registry key | -| **RBAC by projection** | `E-ONE-MASK-THREE-PORTS` + the `D-A2UI` #205 correction: `effective_mask = classview_mask ∧ role_mask`; `field_mask` retyped to `WideFieldMask`; fail-closed; sentinel ban. | **Ruled, but only the transport half is coded.** The wire carries wide masks (positions ≥ 64 decode on the `ogar-a2ui-frame` wire) and `a2ui-server` projects *before* framing (`render_stream.rs`: RBAC before frame). But the **enforcement** — an `impl ClassRbac` / `effective_mask` that computes `classview_mask ∧ role_mask` — does **not** exist under OGAR `crates/` (`ClassRbac` is spec-only, `docs/CLASSID-RBAC-KEYSTONE-SPEC.md`; enforcement is **probe-gated on `PROBE-OGAR-RBAC-AUTHORIZE`**, `ISSUES.md` ISS-RBAC-AUTHORIZE-BY-CLASSID). | **Transport CODED; enforcement spec + probe-gated — NOT shipped** | -| **`DocNode`** (composition-only) | `D-DOC-IR-SECOND-RETINA`: *"one closed doc IR"* — the observation IR (`DocIr{version, source, geometry, content_sha256, mime, pages, fields}`, `ogar-doc-ir/src/lib.rs:207`) is the perceptual retina and is **untouched**. | `DocNode` is a **new composition layer** whose leaves reference observations through `ObjectSlot`s (an imported scan appears in a report as a portal, not as pasted regions). No domain variants (`Wall`/`WorkPackage`) enter `DocNode` — those are OGAR nodes the slots point at. | **New module**, observation IR preserved | -| **`ResolutionMode{Live, Revision, Snapshot}`** + `ogar://` | `@sha256:` = the content-address discipline the doc KV already enforces (`DocumentId = sha256(raw)`, `D-OGAR-DOC-LAYER`); `@revision:` = Lance versioning (ADR-008/013 shape). | Clean, unclaimed names (confirmed absent from `crates/`). The beyond-Rails move; the storage semantics are reuse. | **New names, reused semantics** | - -### A2. Crate boundary — bound by the A1 ruling, not open - -The doctrine's §3 leaves crate placement "decided at implementation time -(`ogar-doc-ir` module vs sibling `ogar-doc-compose`)." The ledger already -narrowed it: `D-DOC-IR-SECOND-RETINA` operator ruling **A1** — *"one `ogar-doc` -crate; split axis is STATE not direction."* - -**Recommendation:** the composition graph is a **module in the doc-ir family**, -not a new `ogar-doc-compose` crate. The observation IR (`ogar-doc-ir`, serde-only, -zero canon dep) stays as-is; `DocNode`/`ObjectSlot`/`DocOp` land as a -composition module referencing observations by slot. This honors A1 and keeps -the "one closed doc IR" invariant — the composition layer is *state above* the -observation IR, not a *direction split* into a second crate. - -### A3. The genuine gaps (what the slice actually has to build) - -Filtered through A1: what is truly new vs. what is a widening. - -| Gap | Kind | Note | -|---|---|---| -| `ObjectSlot` / `ResolutionMode` / `ogar://` parse | **new** | clean names; `ObjectSlot` = A3 brick + `ObjectRef` + `ResolutionMode` | -| composition `DocNode` | **new module** | in the doc-ir family (A1), leaves reference observations by slot | -| `DocOp` (editor authority; Trix pattern — model is authoritative, not the DOM) | **new** | CRDT-compatible later; single-writer + content-addressed saves suffice for v1 | -| `FieldView` (enum) | **widen** | formalize the E-ONE-MASK-THREE-PORTS fold; the shipped struct becomes the `Text` leaf (rename struct → `FieldRow`) | -| `DocRenderer` named multi-view | **widen** | registry key `ClassId → (ClassId, mode)` | -| `ogar-render-typst` | **new adapter** | greenfield (no Typst anywhere in the workspace); the doctrine's §9 kill-criteria apply (demote to export-only if Typst can't be generated from render nodes without arbitrary-syntax leakage) | - -### A4. First vertical slice — grounded landing zone in `openproject-nexgen-rs` - -The doctrine's §7 slice (`WorkPackage.description` → 3 attachables × 2 modes → -render twice HTML + Typst) lands here, exact files: - -- **The field:** domain `WorkPackage.description: Formattable` - (`crates/op-work-packages/src/work_package.rs:74-76`) — **hand-written, safe - to touch.** **Never** the generated `op-generated` `description: String` - (`crates/op-generated/src/generated/work_package.rs:22` — `// @generated … - do not edit by hand`, regenerated by `op-codegen-pipeline`'s `emit_generated`). -- **Adapt-don't-rewrite is already the idiom:** `impl From<&WorkPackage> for - CanonicalWorkPackage` (`work_package.rs:253-277`) already projects the rich - domain type down to the generated `String`. The `DocNode` tree lives in the - hand-written layer; that same `From` projects it to/from the generated - `String` (markdown or DocIr-JSON) at the crate boundary — exactly the - doctrine's "adapt generated DTOs, don't hand-rewrite them." -- **The render seam:** `CellData::RichText` at `crates/op-server/src/board.rs:735` - and `:1446` — today `format!("

{}

", esc(...))` (a bare escaper; **no - markdown renderer is wired anywhere** — `Formattable::markdown` is a `TODO` - placeholder at `op-core/src/types.rs:36-44`). Replacing that one expression - with a `DocRenderer` pass over the resolved slots is the minimal-diff - insertion point; `render_wp_detail` (`board.rs:701-832`) is already a pure, - DB-decoupled function that resolves `status_name`/`author_name`/… before the - renderer — slot resolution extends that same pre-render step. -- **The attachables:** `User` (`crates/op-models/src/user/model.rs`), `Attachment` - (`crates/op-attachments/src/model.rs`). Both already have generated + - hand-written shapes. Crucially, the in-text machinery a slot would replace is - **all-TODO**: no `@mention` parser exists, and the WP-link macro is a - generated stub with every method `// TODO` - (`op-generated/.../work_package_exports_macros_work_packages_link_handler.rs`). - → **clean, unblocked landing zone; nothing to migrate away from.** -- **Classid discipline (fix as a forcing function):** `op-server` currently - bypasses the tested `op_canon::app::class_id_of` and calls - `ogar_vocab::canonical_concept_id` inline (`board.rs:25` + 7 call sites). - Slot-target classid resolution should route through `op_canon::app` - (the OGAR-consumer-best-practices pattern), fixing that inconsistency. -- **RBAC is the slice's own responsibility — do NOT assume it is shipped.** - Because `ClassRbac`/`effective_mask` enforcement is spec + probe-gated (§A1), - the slice's slot resolution MUST apply the fail-closed intersection - `resolved_mask = requested_wide_mask ∧ role_wide_mask` **server-side, before - rendering**, and treat a missing/narrow role mask as a **refusal**, never a - fall-through to the requested mask (the `full_for` sentinel is a render - convenience, never an RBAC fallback — the #205 rule). A slot that supplies an - unfiltered mask must not cause unauthorized fields to render. Until the OGAR - `ClassRbac` enforcement lands, the slice carries the intersection itself - (mirroring `a2ui-server`'s `project_surface` fail-closed gate); it does not - wait for, nor assume, the upstream primitive. - -**Slice = ** 5 `DocNode` kinds + 3 attachables × 2 modes each + render HTML -(askama, exists) + Typst PDF (greenfield adapter). The Typst leg is the only -part with no reuse floor. - -### A5. `ogar-bim` recommendation - -No BIM / mesh / `Wall` / CAD concept is minted anywhere in the codebase today -(confirmed across `crates/`), so the names are clean. Per doctrine §6, -`ogar-bim` is **semantic objects + ClassViews** (a `Wall` is an OGAR node with -`document-inline` / `property-table` / `viewport-selection` views), **not a -document format** — addressable from documents via `ObjectSlot` like any other -node. Recommendation: a new **producer + vocab family** (mint concepts in -`ogar-vocab` under a new domain byte, consumed via the classid membrane like -every other consumer) — but **deferred**, because BIM geometry is exactly the -Part B chunk that is unruled and probe-gated. The slice's "one `BimObject`" -step should be **one minimal semantic node with a `document-inline` view**, not -an IFC ontology. - -### A6. The OGAR-AS-IR gate (composition types are an IR-surface change) - -`DocNode` / `ObjectSlot` / `FieldView` / `DocOp` must pass the six -`docs/OGAR-AS-IR.md` tests before landing: (1) SSA / dataflow-explicit; (2) -effect annotations first-class; (3) typed signature not field-bag; (4) named -lowering passes (each renderer is a pass `IR → target`, adapters lower not -transform); (5) optimization passes declare a semantic-preservation guarantee; -(6) the IR is the canonical artifact (no renderer dialect creeps into -`DocNode`). A type failing a test is *rerouted* to the layer it belongs to, not -rejected. `SURREAL-AST-TRAP-PREFLIGHT` applies to any `ogar://` → storage -lowering. - ---- - -## Part B — `[S]` The STL / geometry chunk rediscovers the "address is geometry" arc - -> **Status: `[S]` PROPOSAL-GROUNDING. Council-pending; probe-gated; NOT -> adopted.** No mint, no crate, no code. This is a **reuse-map that defers the -> build** — recorded so a future session grounds on the existing arc instead of -> rebuilding it. Adoption requires the 5+3 council **and** a green probe. - -### B0. The gestalt it rediscovers - -DISCOVERY-MAP §1, the spine's one sentence: - -> *"the substrate is an immaterialized Morton cascade with templated payloads — -> **address is geometry (helix + HHTL)**, error is closed-form (jc), -> materialization is lazy and columnar; the only forbidden cost is -> non-amortized per-query."* - -The STL → mesh → surfel stack proposed in the feedback maps almost one-to-one -onto this already-`[G]`/CODED arc. - -### B1. Reuse map (proposed type → existing anchor) - -| Proposed type | Existing anchor | Grounding | -|---|---|---| -| `HhtlNode` / spatial-LOD octree | **`HhtlMode::Located`** — `D-GUID-TIER` **CODED** | `ogar-fma-skeleton/src/guid.rs:328` (`located_3d(x,y,z)` / `position_3d` / `morton3`) is a shipped Cesium 3D-octree CRS *on the GUID key*. The dual `HhtlMode::{Located, Cascade}` is the exact "don't overload HHTL silently" discipline — HHTL stays the key-path canon; a spatial index is the **`Located` reading**, not a new hierarchy. | -| `HelixAddress` | **`helix`** crate (lance-graph) | `crates/helix` is a shipped golden-spiral hemisphere Place/Residue codec (`ResidueEdge`, 3-byte endpoint pair, O(1) 256×256 metric-safe LUT). `hhtl ++ helix` is the sanctioned **L7 "absolute location, two hemispheres"** facet layout (`le-contract.md:62`). **Surfel normals ride `helix::ResidueEdge`; do not mint `HelixAddress`.** | -| `Surfel` / splat math | **`jc::ewa_sandwich_3d`** (certified 3×3 SPD push-forward) + `ndarray::hpc::splat3d` (renderer) + **`D-FMA-SKELETON`** | The splat math is shipped and certified (ρ ≥ 0.999); the FMA arc is the splat-native mesh precedent. A `Surfel` is a splat carrier over this math, not new math. | -| `Aabb` / bounds | **`D-BOUNDS`** | Tile bounding volume is **derived from the address** (address arithmetic), *not stored*, by design. No `Aabb` struct is the intended state. | -| LOD / geometric-error | **`D-CESIUM-PROBE`** + `jc` closed-form + lance-graph `3DGS-*.md` (20-file plan family) | Cesium SSE refine/collapse is the trial-and-error probe `ADR-025` **removes** (closed-form level). LOD is address arithmetic, not a stored scene graph. | -| `BufferRef` / content-addressed mesh payload | `canonical_node.rs:706-723` | The anatomy mesh (**4M vertices / 6M triangles**) is *named as the worked example* of an out-of-line Lance table referenced by key/classid. That IS the `BufferRef` pattern — unnamed, already canon. | -| `PartGraph` | `ogar-fma-skeleton` `same_family` partonomy (`guid.rs`) | "relations ARE the addressing: local relation ⇔ shared family prefix; prefix = partonomy = spatial containment" (`D-BOTHCASC`). An STL `PartGraph` should extend this, not duplicate it. | -| `FeatureIr` | — | Genuinely new, but **name-overload risk**: "Feature" is already used for `Fragment → Feature/VectorCentroid` in the 3DGS datalake plan, and `Field*` is the classview vocabulary. Pick a name that doesn't overload `Field`/`Feature`. | -| `SourceMesh` / `MeshIr` | — (3DGS plans are design-only) | The one genuinely-new tissue (see B3). | - -### B2. Naming discipline (same principle as Part A: widen, don't fork) - -- **Do NOT mint a new "HHTL"** for a spatial octree. HHTL is the key-path / - routing-cascade canon (`hhtl.rs`, `canonical_node.rs`, `CLAUDE.md` P0). A - spatial index is the **`HhtlMode::Located` reading of the same key**, with the - explicit-mode guard already shipped in `ogar-fma-skeleton`. -- **Do NOT mint `HelixAddress`.** Ride `helix::ResidueEdge` (and the L7 - `hhtl++helix` layout). -- **Bounds are derived** (`D-BOUNDS`), not a stored `Aabb`. -- A `Surfel`'s math is `jc` / `splat3d`, not new. - -### B3. The one genuinely-new sliver - -Everything downstream of ingestion is reuse. The new tissue is narrow: - -> **STL ingest → mesh normalize → feature/part segmentation**, as a **new -> producer** that feeds the existing address + splat substrate — structurally -> the mirror of `ogar-from-ruff` (which feeds the class substrate). `SourceMesh` -> (immutable evidence) + `MeshIr` (normalized topology) + the segmentation into -> a `PartGraph` (extending `same_family`) is the producer; addressing, LOD, -> surfel encoding, and storage are all reuse. - -### B4. Probe-first gate (why this is `[S]`, not `[H]`) - -`D-FMA-SKELETON` already grades **splat-fit convergence a CONJECTURE** -(`address structure CODED; splat-fit convergence CONJECTURE`). The proposed -producer inherits that gate: it is `[S]` until a probe runs — e.g. **one STL → -`PartGraph` → surfel → re-render round-trip**, fidelity-checked against the -source mesh, with selection round-tripping from a rendered pixel back to the -source triangle and the OGAR node. Per workspace probe-first discipline, the -next deliverable for this chunk is **that probe, not more synthesis**. It -belongs in the SPLAT-NATIVE / FMA arc (`docs/SPLAT-NATIVE-CUSTOMER.md`, -`docs/FMA-SKELETON-CONVERGENCE-ANCHOR.md`) + the lance-graph `3DGS-*.md` plans. - -### B5. Explicitly not adopted - -No mint, no crate, no code, no ADR. This section is a reuse-map so the arc is -not rebuilt; adoption needs the 5+3 council **and** a green probe. The -`ogar-bim` semantic-object family (Part A §A5) is the *document-facing* half and -can proceed independently of this geometry chunk — a `Wall` node with a -`document-inline` ClassView needs no mesh pipeline; the mesh pipeline is what B0–B4 -defer. - ---- - -## Appendix — one-line index of what this doc grounds - -- **Part A** — the composition layer is `D-OGAR-DOC-LAYER → D-DOC-IR-SECOND-RETINA - → D-A2UI → D-DOCIR-COMPOSITION`, and its "new" names are widenings: - `ObjectSlot` = the A3 Klickwege brick made addressable; `FieldView` (enum) = - the `E-ONE-MASK-THREE-PORTS` fold formalized (the shipped struct is its `Text` - leaf); `ProjectionRenderer` = `DocRenderer`; named multi-view = mask-per-mode; - RBAC = the ruled `classview_mask ∧ role_mask` (transport CODED; the - `ClassRbac`/`effective_mask` enforcement is spec + probe-gated, NOT shipped). - Crate placement is bound by - the A1 "one crate" ruling. Slice landing zone is grounded to exact files in - `openproject-nexgen-rs`. -- **Part B** — the STL/geometry chunk is a `[S]` rediscovery of the "address is - geometry" arc (`HhtlMode::Located` / `helix` / `jc` / `D-FMA-SKELETON` / - `D-BOUNDS` / `3DGS-*` plans); reuse, don't fork; the one new sliver is an - STL-ingest producer, probe-gated; not adopted.