Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions crates/ogar-doc-ir/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u32>>,
/// Display labels of each member tuple (one label per axis dimension).
pub labels: Vec<Vec<String>>,
}

/// 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<String>,
/// 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<String>,
}

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 {
Expand All @@ -68,6 +127,14 @@ pub enum SlotOutcome {
/// The projected rows (view mask ∩ presence, nested via rails).
fields: Vec<ResolvedField>,
},
/// 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.
Expand Down Expand Up @@ -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<ViewId>;

/// 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: <Self::Graph as RailGraph>::Key, view: ViewId) -> Option<ResolvedGrid> {
let _ = (key, view);
None
}
}

/// Resolve a composed document into renderer-neutral blocks.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<K> {
self.inner.lookup(target, mode)
}
fn value_of(&self, key: K, position: u8) -> Option<String> {
self.inner.value_of(key, position)
}
fn view_by_name(&self, name: &str) -> Option<ViewId> {
if name == "wp.grid" {
Some(self.grid_view)
} else {
self.inner.view_by_name(name)
}
}
fn view_for_class(&self, class: ClassId) -> Option<ViewId> {
self.inner.view_for_class(class)
}
fn grid_of(&self, key: K, view: ViewId) -> Option<ResolvedGrid> {
(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, &registry, 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,
&registry,
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, &registry, 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, &registry, 4);
assert_eq!(r.outcome, SlotOutcome::Unresolvable);
}

fn slot(target_id: &str, view: &str, fallback: Option<SnapshotRef>) -> ObjectSlot {
ObjectSlot {
target: ObjectRef {
Expand Down
60 changes: 60 additions & 0 deletions crates/ogar-render-typst/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>]) -> 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.
Expand Down Expand Up @@ -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::<Vec<_>>();
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,
Expand Down
33 changes: 33 additions & 0 deletions crates/ogar-render-typst/tests/dual_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,20 @@ fn render_html(blocks: &[ResolvedBlock]) -> String {
SlotOutcome::Unresolvable => {
html.push_str(&format!("<p class=\"unresolvable\">{}</p>\n", rs.uri));
}
SlotOutcome::Grid { grid, .. } => {
html.push_str(&format!("<table data-view=\"{}\">", rs.class_view));
for (r, labels) in grid.rows.labels.iter().enumerate() {
html.push_str(&format!("<tr><th>{}</th>", labels.join("/")));
for c in 0..grid.columns.keys.len() {
html.push_str(&format!(
"<td>{}</td>",
grid.cell(r, c, 0).unwrap_or("")
));
}
html.push_str("</tr>");
}
html.push_str("</table>\n");
}
},
}
}
Expand All @@ -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<Vec<String>> = 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));
}
},
}
}
Expand Down
28 changes: 28 additions & 0 deletions docs/DOCIR-COMPOSITION-LAYER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading