diff --git a/crates/ogar-doc-ir/src/resolve.rs b/crates/ogar-doc-ir/src/resolve.rs index ecbf4c4b..86d65304 100644 --- a/crates/ogar-doc-ir/src/resolve.rs +++ b/crates/ogar-doc-ir/src/resolve.rs @@ -58,6 +58,65 @@ pub struct ResolvedField { pub value: String, } +/// One axis of a resolved grid: per member tuple, its CANONICAL coordinate +/// (fixed-width ordinals, kept so a rendered cell can be traced back to the +/// aggregate coordinate it came from) and its display labels (resolved at +/// this boundary, never earlier). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GridAxis { + /// Canonical coordinate of each member tuple, in presentation order. + pub keys: Vec>, + /// Display labels of each member tuple (one label per axis dimension). + pub labels: Vec>, +} + +/// A two-axis projection of a resolved object — the renderer-neutral table. +/// +/// This is the one shape the flat `(label, value)` rows cannot carry: an +/// object whose projection is addressed by TWO coordinates (a pivot, a +/// cross-tab, a matrix-shaped measurement). It is still a projection of ONE +/// addressed object through ONE named view — orientation is the view's +/// choice, so two slots naming the same target through two views present +/// the same cells rotated, and nothing about the object is copied into the +/// composed document. +/// +/// `cells` is row-major: `cells[(r * columns + c) * measures + m]`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResolvedGrid { + /// Row axis. + pub rows: GridAxis, + /// Column axis. + pub columns: GridAxis, + /// Measure (value) labels — one or more values per cell. + pub measures: Vec, + /// Formatted cell values, row-major, `measures.len()` per cell. An empty + /// string is an empty cell (the source's NULL), never a zero. + pub cells: Vec, +} + +impl ResolvedGrid { + /// Whether the shape is internally consistent (every axis carries one + /// label tuple per key, and there is exactly one value per + /// row × column × measure). + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.rows.keys.len() == self.rows.labels.len() + && self.columns.keys.len() == self.columns.labels.len() + && self.cells.len() + == self.rows.keys.len() * self.columns.keys.len() * self.measures.len() + } + + /// The value at row `r`, column `c`, measure `m`. + #[must_use] + pub fn cell(&self, r: usize, c: usize, m: usize) -> Option<&str> { + let (nc, nm) = (self.columns.keys.len(), self.measures.len()); + if r >= self.rows.keys.len() || c >= nc || m >= nm { + return None; + } + self.cells.get((r * nc + c) * nm + m).map(String::as_str) + } +} + /// How one slot resolved. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlotOutcome { @@ -68,6 +127,14 @@ pub enum SlotOutcome { /// The projected rows (view mask ∩ presence, nested via rails). fields: Vec, }, + /// The target resolved to a two-axis projection (see [`ResolvedGrid`]): + /// its source answered [`DocObjectSource::grid_of`] for the slot's view. + Grid { + /// The root object's class. + class: ClassId, + /// The projected table. + grid: ResolvedGrid, + }, /// The target was unresolvable and the slot carried a snapshot fallback — /// the explicit ActionText missing-object path, surfaced as the /// content-address (hex sha256) for the renderer to show. @@ -140,6 +207,18 @@ pub trait DocObjectSource { /// The view a NESTED hop's class projects through (the per-class binding /// the walk uses below the root). `None` prunes that subtree. fn view_for_class(&self, class: ClassId) -> Option; + + /// The two-axis projection of `key` through `view`, when that view is + /// grid-shaped for this source — `None` (the default) means "project + /// through the rail walk as usual", so every existing source is + /// unaffected. A source whose objects are addressed by two coordinates + /// (an aggregate result, a matrix-shaped measurement) answers here; the + /// orientation is the VIEW's, so rotating a table is naming another + /// view, never re-deriving the object. + fn grid_of(&self, key: ::Key, view: ViewId) -> Option { + let _ = (key, view); + None + } } /// Resolve a composed document into renderer-neutral blocks. @@ -206,6 +285,23 @@ where }; let outcome = match resolved { + // A grid-shaped view: the source projects the table itself. A + // malformed grid fails closed (never a half-rendered table). + Some((root, root_view, root_class)) if let Some(grid) = source.grid_of(root, root_view) => { + if grid.is_well_formed() { + SlotOutcome::Grid { + class: root_class, + grid, + } + } else { + match &slot.fallback { + Some(snap) => SlotOutcome::Fallback { + content_sha256_hex: hex32(&snap.content_sha256), + }, + None => SlotOutcome::Unresolvable, + } + } + } Some((root, root_view, root_class)) => { let graph = source.graph(); let mut fields = Vec::new(); @@ -419,6 +515,111 @@ mod tests { } } + /// A source that answers `grid_of` for ONE view on ONE object — the + /// minimal two-axis projection source (the "M" object below). + struct GridSrc { + inner: Src, + grid_view: ViewId, + malformed: bool, + } + impl DocObjectSource for GridSrc { + type Graph = G; + fn graph(&self) -> &G { + &self.inner.graph + } + fn lookup(&self, target: &ObjectRef, mode: &ResolutionMode) -> Option { + self.inner.lookup(target, mode) + } + fn value_of(&self, key: K, position: u8) -> Option { + self.inner.value_of(key, position) + } + fn view_by_name(&self, name: &str) -> Option { + if name == "wp.grid" { + Some(self.grid_view) + } else { + self.inner.view_by_name(name) + } + } + fn view_for_class(&self, class: ClassId) -> Option { + self.inner.view_for_class(class) + } + fn grid_of(&self, key: K, view: ViewId) -> Option { + (key == K::Wp1 && view == self.grid_view).then(|| ResolvedGrid { + rows: GridAxis { + keys: vec![vec![0], vec![1]], + labels: vec![vec!["r0".into()], vec!["r1".into()]], + }, + columns: GridAxis { + keys: vec![vec![0], vec![1], vec![2]], + labels: vec![vec!["c0".into()], vec!["c1".into()], vec!["c2".into()]], + }, + measures: vec!["m".into()], + cells: if self.malformed { + vec!["1".into()] + } else { + (0..6).map(|i| i.to_string()).collect() + }, + }) + } + } + + fn grid_setup(malformed: bool) -> (GridSrc, ViewRegistry) { + let (inner, mut registry) = setup(); + let grid_view = registry.register(NamedView::new( + WP, + WideFieldMask::from(0), + DisplayTemplate::Detail, + )); + ( + GridSrc { + inner, + grid_view, + malformed, + }, + registry, + ) + } + + #[test] + fn a_grid_view_resolves_to_a_grid_and_other_views_still_walk_rails() { + let (src, registry) = grid_setup(false); + let r = resolve_slot(&slot("wp1", "wp.grid", None), &src, &TestView, ®istry, 4); + let SlotOutcome::Grid { class, grid } = &r.outcome else { + panic!("expected Grid, got {:?}", r.outcome); + }; + assert_eq!(*class, WP); + assert_eq!(grid.cell(1, 2, 0), Some("5")); + assert_eq!(grid.cell(2, 0, 0), None); + // Can-stay-silent twin: the SAME object through a non-grid view is + // the ordinary rail walk — the default `grid_of` changes nothing. + let r = resolve_slot( + &slot("wp1", "work_package.summary", None), + &src, + &TestView, + ®istry, + 4, + ); + assert!(matches!(r.outcome, SlotOutcome::Resolved { .. })); + } + + #[test] + fn a_malformed_grid_fails_closed() { + let (src, registry) = grid_setup(true); + let r = resolve_slot(&slot("wp1", "wp.grid", None), &src, &TestView, ®istry, 4); + assert_eq!(r.outcome, SlotOutcome::Unresolvable); + } + + #[test] + fn a_grid_view_still_obeys_root_class_agreement() { + // The grid view projects WP; aimed at a USER object it must not + // resolve (the class gate runs before `grid_of` is consulted). + let (src, registry) = grid_setup(false); + let mut s = slot("alice", "wp.grid", None); + s.target.class = "user".into(); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!(r.outcome, SlotOutcome::Unresolvable); + } + fn slot(target_id: &str, view: &str, fallback: Option) -> ObjectSlot { ObjectSlot { target: ObjectRef { diff --git a/crates/ogar-render-typst/src/lib.rs b/crates/ogar-render-typst/src/lib.rs index bae510d8..d8c43f33 100644 --- a/crates/ogar-render-typst/src/lib.rs +++ b/crates/ogar-render-typst/src/lib.rs @@ -91,6 +91,43 @@ pub fn emit_field_view(class_view: &str, title: &str, fields: &[FieldView]) -> S out } +/// Emit one resolved two-axis projection (a grid-shaped view of one addressed +/// object) as a Typst table — the paged sibling of [`emit_field_view`] for +/// objects addressed by two coordinates. `header` is the column-header row +/// (its first entry labels the row-header column); each `rows` entry is one +/// presented row: its row label first, then its cells. Nothing here knows +/// what produced the grid; orientation was chosen by the slot's view before +/// this point, so a rotated table is simply other rows. +#[must_use] +pub fn emit_grid(class_view: &str, title: &str, header: &[String], rows: &[Vec]) -> String { + let mut out = String::new(); + out.push_str("#block[\n"); + out.push_str(&format!( + "*{}* #text(size: 0.8em)[({})]\n", + escape_typst(title), + escape_typst(class_view) + )); + out.push_str(&format!("#table(\n columns: {},\n", header.len().max(1))); + let line = |cells: &[String], bold: bool| { + let mut l = String::from(" "); + for c in cells { + if bold { + l.push_str(&format!("[*{}*], ", escape_typst(c))); + } else { + l.push_str(&format!("[{}], ", escape_typst(c))); + } + } + l.push('\n'); + l + }; + out.push_str(&line(header, true)); + for r in rows { + out.push_str(&line(r, false)); + } + out.push_str(")\n]\n"); + out +} + /// Emit an explicit unresolved-slot marker — the paged form of the ActionText /// missing-object fallback: the snapshot's content address is shown, never /// silently dropped. @@ -119,6 +156,29 @@ pub fn emit_text(text: &str) -> String { mod tests { use super::*; + #[test] + fn emit_grid_lays_out_header_and_rows_in_the_given_orientation() { + let h = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + let t = emit_grid( + "r.view", + "T", + &h(&["", "c0", "c1"]), + &[h(&["r0", "1", "2"]), h(&["r1", "3", ""])], + ); + assert!(t.contains("columns: 3")); + assert!(t.contains("[*c0*], [*c1*]")); + assert!(t.contains("[r1], [3], [], "), "{t}"); + // Rotation is other rows, not a flag: the transposed input yields the + // transposed table. + let r = emit_grid( + "r.view", + "T", + &h(&["", "r0", "r1"]), + &[h(&["c0", "1", "3"]), h(&["c1", "2", ""])], + ); + assert!(r.contains("[c1], [2], [], ")); + } + fn fv(position: u8, label: &str, value: &str) -> FieldView { FieldView { position, diff --git a/crates/ogar-render-typst/tests/dual_render.rs b/crates/ogar-render-typst/tests/dual_render.rs index 80171cd0..6a7537be 100644 --- a/crates/ogar-render-typst/tests/dual_render.rs +++ b/crates/ogar-render-typst/tests/dual_render.rs @@ -425,6 +425,20 @@ fn render_html(blocks: &[ResolvedBlock]) -> String { SlotOutcome::Unresolvable => { html.push_str(&format!("

{}

\n", rs.uri)); } + SlotOutcome::Grid { grid, .. } => { + html.push_str(&format!("", rs.class_view)); + for (r, labels) in grid.rows.labels.iter().enumerate() { + html.push_str(&format!("", labels.join("/"))); + for c in 0..grid.columns.keys.len() { + html.push_str(&format!( + "", + grid.cell(r, c, 0).unwrap_or("") + )); + } + html.push_str(""); + } + html.push_str("
{}{}
\n"); + } }, } } @@ -449,6 +463,25 @@ fn render_typst(blocks: &[ResolvedBlock]) -> String { SlotOutcome::Unresolvable => { out.push_str(&typst::emit_text(&format!("unresolvable: {}", rs.uri))); } + SlotOutcome::Grid { grid, .. } => { + let mut header = vec![String::new()]; + header.extend(grid.columns.labels.iter().map(|l| l.join("/"))); + let rows: Vec> = grid + .rows + .labels + .iter() + .enumerate() + .map(|(r, l)| { + let mut row = vec![l.join("/")]; + row.extend( + (0..grid.columns.keys.len()) + .map(|c| grid.cell(r, c, 0).unwrap_or("").to_string()), + ); + row + }) + .collect(); + out.push_str(&typst::emit_grid(&rs.class_view, "", &header, &rows)); + } }, } } diff --git a/docs/DOCIR-COMPOSITION-LAYER.md b/docs/DOCIR-COMPOSITION-LAYER.md index 76908fb9..a5b2eda6 100644 --- a/docs/DOCIR-COMPOSITION-LAYER.md +++ b/docs/DOCIR-COMPOSITION-LAYER.md @@ -358,6 +358,34 @@ parsing · composition `DocNode` · `DocOp` · `FieldView` enum + `ProjectionRenderer` trait · **named** ClassViews (`WorkPackage.inline` vs `.summary` — today's registry is one view per class) · `ogar-render-typst`. +> **2026-09-23: the `Table` arm is realized at the RESOLVE layer, not in +> `FieldView` and not in `DocNode`.** Status: VERIFIED-IN-CODE +> (`crates/ogar-doc-ir/src/resolve.rs`: `ResolvedGrid`, `GridAxis`, +> `SlotOutcome::Grid`, `DocObjectSource::grid_of`; +> `crates/ogar-render-typst/src/lib.rs`: `emit_grid`). +> +> The gap was that flat `(label, value)` rows cannot carry an object that is +> addressed by two coordinates. The first consumer is a lance-graph aggregate +> result embedded through an `ObjectSlot`. `u8` rail positions cannot address +> its coordinates, and encoding the second axis into labels would be lossy. +> +> The primitive is: +> - a two-axis projection of ONE addressed object through ONE named view; +> - an optional source answer, `grid_of`, which defaults to `None`, so every +> existing source is unchanged; +> - fail-closed on a malformed shape, and still gated by the root-class +> agreement check. +> +> Three consequences: +> - **Orientation belongs to the view.** Rotating a table means naming another +> view, never re-deriving the object. +> - **Axis keys keep the canonical coordinates.** A rendered cell stays +> traceable to its aggregate coordinate. +> - **Nothing enters `DocNode`.** A report or matrix is an object reached +> through a slot, per §3, so no `doc-compose.v2` bump is needed. The §2 +> `FieldView::Table(TableView)` widening remains the askama-side spelling. +> It is still unbuilt. + ## §9 Gates & sequencing - **IR gate:** `docs/OGAR-AS-IR.md` MUST be read before the composition