@toclocoinc/lattice-grid 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/API.html CHANGED
@@ -850,6 +850,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
850
850
  <tr><td class="name">maximise</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc"><code>false</code> removes the rail button and <code>grid.maximise</code>, for an application with its own full-screen mode.</td></tr>
851
851
  <tr><td class="name">toolPanel</td><td class="type">boolean | object</td><td class="desc">Side dock. <code>panels</code>: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>. <code>side: 'left'</code> makes it the icon rail, which also turns on <code>actions</code> (<code>undo</code>, <code>redo</code>, <code>pause</code>, <code>restore</code>, <code>maximise</code>, then the export group: <code>export</code>, <code>excel</code>, <code>clipboard</code>, <code>print</code>: nine in all, and an array takes these names rather than the button labels) and <code>icons</code>. An explicit array <em>replaces</em> that list rather than extending it; a bare <code>'-'</code> in it renders a divider between groups. <code>exportName</code> names the CSV.</td></tr>
852
852
  <tr><td class="name">groupPanel</td><td class="type">boolean | object</td><td class="desc">A drag-and-drop group-by strip above the column header — the row-group panel. Drag a heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable — arrows move between chips, <code>Shift</code> with an arrow reorders, <code>Delete</code> ungroups, and an add control groups any column — and every change is announced through the live region. Off by default and non-breaking; it drives the same model as <code>grid.columns.group()</code> and reimplements nothing. The object form takes <code>hint</code>, the placeholder shown while nothing is grouped.</td></tr>
853
+ <tr><td class="name">kpis</td><td class="type">StatConfig[]</td><td class="desc">A built-in KPI/stat strip: a labelled band of stat tiles the grid places for you above the column header. Each entry is a <code>createStat</code> spec — <code>of</code>, <code>fn</code>, <code>title</code>, <code>interval</code>, <code>footer</code>, <code>format</code> and the rest, minus <code>grid</code> and <code>container</code>, which the grid supplies — so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change like a stand-alone stat does. Off by default and non-breaking; it reuses <code>createStat</code> and reimplements no compute.</td></tr>
853
854
  <tr><td class="name">timeZone</td><td class="type">string</td><td class="desc">An IANA zone every date column formats and parses in, so a grid shows one zone whatever the viewer's machine says. Individual columns may override it.</td></tr>
854
855
  <tr><td class="name">formulaFunctions</td><td class="type">object</td><td class="desc">Your own functions, added to the formula language by name. The built-in list is closed on purpose; this is the one way in, and a function you add is called exactly as a built-in is.</td></tr>
855
856
  <tr><td class="name">formatting</td><td class="type">object</td><td class="desc">Conditional formatting rules to seed, keyed by column id or <code>'*'</code>. The same shape <code>grid.formatting.all()</code> returns, so a saved view can be handed straight back.</td></tr>
@@ -1151,10 +1152,70 @@ grid.overlay.hide();</code></pre>
1151
1152
  <tr><td class="sig">settle(id, ok, reason?)</td><td class="type">boolean</td><td class="desc">Report the outcome of an optimistic write. Only needed with <code>edit.confirm: 'manual'</code>; the id arrives on <code>cell:pending</code>.</td></tr>
1152
1153
  <tr><td class="sig">pending()</td><td class="type">OpenWrite[]</td><td class="desc">Writes still awaiting an outcome. Empty unless <code>edit.commit</code> is set.</td></tr>
1153
1154
  <tr><td class="sig">status(key, colId)</td><td class="type">'pending' | null</td><td class="desc">Whether a cell has a write in flight.</td></tr>
1155
+ <tr><td class="sig">addRow(row)</td><td class="type">string | null</td><td class="desc">Append a row optimistically and persist it (over a source declaring <code>mutate.append</code>). Returns the client temp key; on the server key it fires <code>row:confirmed</code> after rekeying selection, expansion, focus and in-flight cell edits. <code>null</code> when append is unavailable.</td></tr>
1156
+ <tr><td class="sig">deleteRow(key)</td><td class="type">string | null</td><td class="desc">Delete a row optimistically and persist it (over a source declaring <code>mutate.delete</code>). Tombstones then confirms, or restores on refusal. <code>null</code> when delete is unavailable.</td></tr>
1157
+ <tr><td class="sig">settleRow(id, ok, reason?, reconcile?)</td><td class="type">boolean</td><td class="desc">Report the outcome of a structural op. Only needed with <code>edit.confirm: 'manual'</code>; the id arrives on <code>row:pending</code>.</td></tr>
1158
+ <tr><td class="sig">rowStatus(key)</td><td class="type">'pending' | null</td><td class="desc">Whether a row has an append/delete in flight.</td></tr>
1159
+ <tr><td class="sig">pendingRows()</td><td class="type">OpenRowOp[]</td><td class="desc">Structural ops still awaiting an outcome. Empty unless the source can append or delete.</td></tr>
1154
1160
  </tbody>
1155
1161
  </table>
1156
1162
  </div>
1157
1163
 
1164
+ <p class="section-note">
1165
+ <strong>Appending and deleting rows.</strong> Over a remote source whose adapter declares
1166
+ <code>mutate.append</code>/<code>mutate.delete</code>, <code>grid.edit.addRow</code> and
1167
+ <code>grid.edit.deleteRow</code> are the structural counterparts of the cell edit path. An
1168
+ appended row shows at once under a client temp key; when the server hands back the real key the
1169
+ row is rekeyed everywhere the grid tracks it &mdash; the source row, selection, expansion,
1170
+ focus and any in-flight cell edits all follow &mdash; and <code>row:confirmed</code> fires. A
1171
+ delete tombstones the row immediately and either purges it on confirmation or restores it on
1172
+ refusal. The example drives both against a mock adapter, and shows the rekey moving a selection:
1173
+ </p>
1174
+ <pre data-run="js" data-expect="srv-1 selected; deleted" data-covers="event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1175
+ <span class="kw">const</span> { createPushdownSource } = <span class="kw">await</span> import('../packages/core/src/source/pushdown.js');
1176
+
1177
+ <span class="cmt">// A mock adapter that persists append and delete. append returns the server key.</span>
1178
+ <span class="kw">let</span> nextId = 1;
1179
+ <span class="kw">const</span> adapter = {
1180
+ name: 'mock',
1181
+ capabilities: { sort: <span class="kw">true</span>, mutate: { append: <span class="kw">true</span>, delete: <span class="kw">true</span>, returning: 'key' } },
1182
+ <span class="kw">async</span> execute() { <span class="kw">return</span> { rows: [], total: 0 }; },
1183
+ <span class="kw">async</span> mutate(op) {
1184
+ <span class="kw">if</span> (op.kind === 'append') <span class="kw">return</span> { ok: <span class="kw">true</span>, keys: [`srv-${nextId++}`] };
1185
+ <span class="kw">return</span> { ok: <span class="kw">true</span> }; <span class="cmt">// delete confirmed</span>
1186
+ },
1187
+ };
1188
+
1189
+ <span class="kw">const</span> grid = createHeadlessGrid({
1190
+ rowKey: 'id',
1191
+ columns: [{ field: 'id' }, { field: 'name' }],
1192
+ source: createPushdownSource({ adapter, edit: <span class="kw">true</span> }),
1193
+ selection: 'multiple',
1194
+ edit: <span class="kw">true</span>,
1195
+ });
1196
+
1197
+ <span class="cmt">// The structural lifecycle events, bridged onto the grid's own bus.</span>
1198
+ <span class="kw">const</span> fired = [];
1199
+ grid.on('row:pending', (e) =&gt; fired.push(`pending:${e.kind}`));
1200
+ grid.on('row:confirmed', (e) =&gt; fired.push(`confirmed:${e.kind}`));
1201
+ grid.on('row:reverted', (e) =&gt; fired.push(`reverted:${e.kind}`));
1202
+ grid.on('row:conflict', () =&gt; fired.push('conflict'));
1203
+
1204
+ <span class="cmt">// Append: shows immediately under a temp key, then rekeys to the server key.</span>
1205
+ <span class="kw">const</span> temp = grid.edit.addRow({ name: 'Ada' });
1206
+ grid.selection.set([temp]); <span class="cmt">// select the optimistic row</span>
1207
+ <span class="kw">await</span> <span class="kw">new</span> Promise((r) =&gt; setTimeout(r, 0)); <span class="cmt">// let mutate resolve; row:confirmed fires</span>
1208
+ <span class="kw">const</span> movedTo = grid.selection.keys()[0]; <span class="cmt">// selection followed the rekey</span>
1209
+
1210
+ <span class="cmt">// Delete: tombstones then confirms.</span>
1211
+ grid.edit.deleteRow(movedTo);
1212
+ <span class="kw">await</span> <span class="kw">new</span> Promise((r) =&gt; setTimeout(r, 0));
1213
+ <span class="kw">const</span> gone = grid.rows.byKey(movedTo) === <span class="kw">undefined</span>;
1214
+
1215
+ grid.destroy();
1216
+ <span class="cmt">// fired: pending:append, confirmed:append, pending:delete, confirmed:delete</span>
1217
+ <span class="kw">return</span> `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;</code></pre>
1218
+
1158
1219
  <p class="section-note">
1159
1220
  <strong>Previewing a bulk paste.</strong> A paste can rewrite dozens of cells at once, and one
1160
1221
  that lands somewhere unexpected looks exactly like one that worked. Set
@@ -1765,7 +1826,19 @@ grid.statistics.tracking(); // { columns, rows, forgotten
1765
1826
  const stats = mountPanel({ grid, panel: 'statistics', container: sidebar });
1766
1827
  stats.refresh(); // for a change the grid does not announce
1767
1828
  stats.destroy(); // yours to call: the element belongs to your page</code></pre>
1768
- <p>Any built-in panel works: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>, as does a constructor of your own.</p>
1829
+ <p>Any built-in panel works: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>, <code>compare</code>, <code>insights</code>, as does a constructor of your own.</p>
1830
+
1831
+ <h3>The insights panel</h3>
1832
+ <p>The <code>insights</code> tool panel is the on-screen half of the comparison analytics (§9.9): the API-only <code>subsetVsPopulation()</code>, <code>datasetVsDataset()</code>, <code>capability()</code> and <code>compareGroups()</code> rendered without you building any UI. It is opt-in — off unless you name it. Add it with <code>toolPanel: { panels: ['columns', 'insights'] }</code>.</p>
1833
+ <p>It shows four things, all over the filtered rows: the columns of the current filtered subset <strong>ranked by effect size</strong> against the whole; the same ranking against a second grid when you pass one as <code>toolPanel: { panels: ['insights'], /* config */ } </code> with <code>insights: { compareWith: otherGrid }</code>; process <strong>capability</strong> for the chosen column where it declares a <code>spec</code>; and a two-group comparison — pick a column and a column to group by, and the panel shows the <strong>named test, its confidence interval and its effect size together</strong>.</p>
1834
+ <p><strong>The stance is enforced on screen.</strong> The effect size is never shown without its interval beside it; the test or method that produced a figure is always named, from the API's own <code>method</code> string; and nothing renders a <code>significant</code> flag, a verdict, a badge or a star — the p-value is shown as the plain datum it is, when it is shown at all. The panel adds no statistic of its own: every number it shows is the one <code>grid.statistics</code> returns.</p>
1835
+ <pre><code>createGrid(el, { toolPanel: { panels: ['columns', 'insights'] } });
1836
+
1837
+ <span class="cmt">// with a second dataset to rank this grid against:</span>
1838
+ createGrid(el, {
1839
+ toolPanel: { panels: ['insights'] },
1840
+ insights: { compareWith: otherGrid },
1841
+ });</code></pre>
1769
1842
 
1770
1843
 
1771
1844
  <h3>The reductions</h3>
@@ -2567,6 +2640,7 @@ createGrid(host, { source, columns: [...] });</code></pre>
2567
2640
  <tr><td class="name">restAdapter</td><td class="desc">The API you already have</td><td class="desc">Parameter names are yours to choose. Paging and sorting are assumed; filtering is assumed absent until you declare <code>operators</code>, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently.</td></tr>
2568
2641
  <tr><td class="name">duckdbAdapter</td><td class="desc">A DuckDB connection</td><td class="desc">Writes SQL and takes the whole query: filter tree, multi-column sort and paging. <code>from</code> is any FROM expression, so <code>read_parquet('s3://bucket/*.parquet')</code> is as valid as a table name. The engine is yours to create and install; this imports nothing, so the bundle is unchanged whether you use it or not.</td></tr>
2569
2642
  <tr><td class="name">dfqlAdapter</td><td class="desc">DemandFlow entities</td><td class="desc">Speaks <code>POST /v1/query</code>. Sends the entity, the key attribute and the prefix to match, a field projection and one field-and-term filter, matched as a case-insensitive substring. It cannot sort or page, so the grid does both, and every request carries a <code>countOnly</code> line because <code>limit</code> caps rows <em>scanned</em> rather than matched: a filtered query returns an arbitrary subset, and the count is the only thing that reveals it.</td></tr>
2643
+ <tr><td class="name">graphqlAdapter</td><td class="desc">Any GraphQL endpoint</td><td class="desc">Configured, not zero-config: GraphQL has no fixed query semantics, so you pass <code>buildQuery</code> to turn the plan into a <code>{ query, variables }</code> operation and <code>parseResponse</code> to read <code>data</code> back into rows and total. Defaults cover an offset/limit list with <code>totalCount</code> and a Relay cursor connection (<code>first</code>/<code>after</code> with <code>pageInfo</code>). The default pushes only the window and the total; declare <code>operators</code> or <code>capabilities</code> for filter/sort only alongside a <code>buildQuery</code> that emits them. A cursor connection is forward-only, so a deep window costs round trips proportional to its offset.</td></tr>
2570
2644
  </tbody>
2571
2645
  </table>
2572
2646
  </div>
@@ -2589,6 +2663,8 @@ createGrid(host, { source, columns: [...] });</code></pre>
2589
2663
  <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for a token that expires, a proxy, or a non-browser runtime. The adapter bundles no HTTP client. See <a href="#adapter-auth">authenticating</a>.</td></tr>
2590
2664
  <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether to ask for <code>$count=true</code> and read <code>@odata.count</code>. On by default because the grid sizes its scrollbar from the total; set <code>false</code> for a server that does not support it.</td></tr>
2591
2665
  <tr><td class="name">search</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Whether the server implements <code>$search</code>. Off by default, so quick-filter text stays with the grid until you confirm the endpoint honours it; <code>true</code> pushes it as <code>$search</code>.</td></tr>
2666
+ <tr><td class="name">edit</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Opt into write-back. Off keeps the source read-only; <code>true</code> advertises <code>mutate: { update: true, delete: true, append: true, returning: 'row' }</code>, so a committed cell edit is persisted with <code>PATCH</code>, a row delete with <code>DELETE /EntitySet(key)</code>, and an add-row with <code>POST /EntitySet</code> reading the created entity back for its server key.</td></tr>
2667
+ <tr><td class="name">key</td><td class="type">string</td><td class="type">the row key</td><td class="desc">The key property every write addresses a row by in its entity-key URL segment, e.g. <code>/Orders(&lt;key&gt;)</code>, and that an add-row is rekeyed to from the created entity. Write-back only.</td></tr>
2592
2668
  </tbody>
2593
2669
  </table>
2594
2670
  </div>
@@ -2615,10 +2691,52 @@ createGrid(host, { source, columns: [...] });</code></pre>
2615
2691
  <tr><td class="name">encodeFilter</td><td class="type">(filters: object) =&gt; string</td><td class="type"><code>JSON.stringify</code></td><td class="desc">How the pushed condition tree becomes the <code>filter</code> parameter's value. Override it to emit whatever query language your service parses instead of JSON.</td></tr>
2616
2692
  <tr><td class="name">rows</td><td class="type">(body: unknown) =&gt; unknown[]</td><td class="type">body itself if an array, else <code>body.rows</code> then <code>body.data</code></td><td class="desc">Pulls the row array out of the response body, for an envelope that nests it somewhere else.</td></tr>
2617
2693
  <tr><td class="name">total</td><td class="type">(body: unknown, rows: unknown[]) =&gt; number</td><td class="type"><code>body.total</code> then <code>body.count</code>, else the page length</td><td class="desc">Reads the count of <em>all</em> matching rows, not the page. The grid sizes its scrollbar from it, so a page-sized total makes a large result look like one page.</td></tr>
2694
+ <tr><td class="name">edit</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Opt into write-back. Off keeps the source read-only; <code>true</code> advertises <code>mutate: { update: true, delete: true, append: true, returning }</code>, so a committed cell edit is persisted with <code>PATCH</code>, a row delete with <code>DELETE</code>, and an add-row with <code>POST</code> to the collection URL.</td></tr>
2695
+ <tr><td class="name">returning</td><td class="type">'row' | 'key' | 'none'</td><td class="type"><code>none</code></td><td class="desc">The reconcile contract for a successful write. <code>none</code> is last-write-wins — the optimistic value stands; <code>row</code> reads the server's authoritative row (via <code>writeRow</code>) back before confirm; <code>key</code> reads only the server-assigned key. An add-row needs <code>row</code> or <code>key</code> so the temp row can be rekeyed.</td></tr>
2696
+ <tr><td class="name">keyField</td><td class="type">string</td><td class="type"><code>id</code></td><td class="desc">The property an add-row response carries the server-assigned key in, read back (through <code>writeRow</code>) to rekey the optimistic row. Write-back only.</td></tr>
2697
+ <tr><td class="name">encodeMutation</td><td class="type">(op: MutationOp) =&gt; { method: string, url: string, headers?: object, body?: unknown }</td><td class="type">the default verb map</td><td class="desc">Full control of a mutation's HTTP shape, overriding the default method, URL and body. Supersedes <code>writeUrlFor</code>.</td></tr>
2698
+ <tr><td class="name">writeUrlFor</td><td class="type">(op: MutationOp) =&gt; string</td><td class="type"><code>${url}/${key}</code></td><td class="desc">The endpoint a single mutation targets, when the default per-row URL is not what the service uses. Addresses an existing row; an add-row POSTs to the collection <code>url</code> instead. Ignored when <code>encodeMutation</code> is supplied.</td></tr>
2699
+ <tr><td class="name">writeRow</td><td class="type">(body: unknown) =&gt; unknown</td><td class="type">the entity, or <code>body.row</code>/<code>body.data</code></td><td class="desc">Pulls the authoritative row out of a write response when <code>returning: 'row'</code>, and the created row an add-row reads its key from.</td></tr>
2618
2700
  </tbody>
2619
2701
  </table>
2620
2702
  </div>
2621
2703
 
2704
+ <p class="section-note">
2705
+ <strong>Persisting a cell edit.</strong> With <code>edit: true</code> the adapter advertises
2706
+ <code>mutate</code>, so a committed cell edit is sent as an HTTP request. REST has no universal
2707
+ write convention, so the request is yours to shape: <code>writeUrlFor</code> names the per-row
2708
+ endpoint, <code>encodeMutation</code> takes full control of method and body, and
2709
+ <code>writeRow</code> reads the authoritative row back when <code>returning: 'row'</code>.
2710
+ </p>
2711
+ <pre data-run="js" data-expect="shipped" data-covers="export:restAdapter"><code><span class="kw">const</span> { restAdapter } = <span class="kw">await</span> import('../packages/core/src/index.js');
2712
+
2713
+ <span class="cmt">// The per-row endpoint a mutation targets.</span>
2714
+ <span class="kw">const</span> writeUrlFor = (op) =&gt; `/api/orders/${op.key}`;
2715
+
2716
+ <span class="kw">let</span> sentMethod;
2717
+ <span class="kw">const</span> adapter = restAdapter({
2718
+ url: '/api/orders',
2719
+ edit: <span class="kw">true</span>, <span class="cmt">// opt into write-back (advertises mutate.update / delete)</span>
2720
+ returning: 'row', <span class="cmt">// reconcile to the server's authoritative row</span>
2721
+ writeUrlFor,
2722
+ <span class="cmt">// Full control of the request; supersedes the default envelope.</span>
2723
+ encodeMutation: (op) =&gt; ({
2724
+ method: 'PATCH',
2725
+ url: writeUrlFor(op),
2726
+ headers: { 'Content-Type': 'application/json' },
2727
+ body: JSON.stringify(op.patch),
2728
+ }),
2729
+ <span class="cmt">// Pull the authoritative row out of this service's envelope.</span>
2730
+ writeRow: (body) =&gt; body.record,
2731
+ fetch: <span class="kw">async</span> (url, init) =&gt; {
2732
+ sentMethod = init.method;
2733
+ <span class="kw">return</span> { ok: <span class="kw">true</span>, status: 200, json: <span class="kw">async</span> () =&gt; ({ record: { id: '42', status: 'shipped' } }) };
2734
+ },
2735
+ });
2736
+
2737
+ <span class="kw">const</span> result = <span class="kw">await</span> adapter.mutate({ kind: 'update', key: '42', patch: { status: 'shipped' } });
2738
+ <span class="kw">return</span> result.rows[0].status; <span class="cmt">// 'shipped', read back from the server</span></code></pre>
2739
+
2622
2740
  <h5 id="duckdb-options"><code>duckdbAdapter</code></h5>
2623
2741
  <div class="table-wrap">
2624
2742
  <table>
@@ -2627,6 +2745,9 @@ createGrid(host, { source, columns: [...] });</code></pre>
2627
2745
  <tr><td class="name">connection</td><td class="type">object</td><td class="type">—</td><td class="desc">A live connection exposing <code>query</code>, and ideally <code>prepare</code>. Required. A connection without <code>prepare</code> is used only for unfiltered queries, because interpolating a user's filter into SQL is worse than not filtering.</td></tr>
2628
2746
  <tr><td class="name">from</td><td class="type">string</td><td class="type">—</td><td class="desc">A table, a view, or any FROM expression. Required. <code>read_parquet('s3://bucket/*.parquet')</code> is as valid as a table name.</td></tr>
2629
2747
  <tr><td class="name">fields</td><td class="type">string[]</td><td class="type">everything (<code>SELECT *</code>)</td><td class="desc">The columns to select. Name them to narrow the projection when the grid shows a subset of a wide table.</td></tr>
2748
+ <tr><td class="name">writable</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Allow write-back against a plain writable table. Off keeps the source read-only, so a <code>from</code> that is a view or an expression can never be mutated by accident. Enables <code>update</code>, <code>delete</code> and <code>append</code>.</td></tr>
2749
+ <tr><td class="name">keyField</td><td class="type">string</td><td class="type"><code>id</code></td><td class="desc">The key column an update and a delete target in their <code>WHERE</code>, and that an add-row is rekeyed by. Write-back is refused unless this names a real column, because an <code>UPDATE</code>/<code>DELETE</code> without a unique key could touch more than one row.</td></tr>
2750
+ <tr><td class="name">returning</td><td class="type">'row' | 'none'</td><td class="type"><code>row</code></td><td class="desc">The reconcile contract for a successful write. <code>row</code> appends <code>RETURNING *</code> and reconciles server truth (computed columns, triggers); <code>none</code> keeps the optimistic value. An add-row always <code>RETURNING</code>s at least the key column regardless, since it needs that key to rekey the temp row.</td></tr>
2630
2751
  </tbody>
2631
2752
  </table>
2632
2753
  </div>
@@ -2645,6 +2766,43 @@ createGrid(host, { source, columns: [...] });</code></pre>
2645
2766
  <tr><td class="name">limit</td><td class="type">number</td><td class="type">server default</td><td class="desc">Caps rows <em>scanned</em>, not matched — which is why every request also sends <code>countOnly</code> to reveal the true match count.</td></tr>
2646
2767
  <tr><td class="name">headers</td><td class="type">Record&lt;string, string&gt;</td><td class="type">{}</td><td class="desc">Extra headers merged over the bearer token, for a gateway that needs its own.</td></tr>
2647
2768
  <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for a proxy or a non-browser runtime.</td></tr>
2769
+ <tr><td class="name">writeUrl</td><td class="type">string</td><td class="type">the default write endpoint</td><td class="desc">Where record mutations are POSTed, when the deployment's write endpoint differs from the default. Write-back persists update, delete and add-row.</td></tr>
2770
+ <tr><td class="name">encodeCreate</td><td class="type">(row: unknown) =&gt; Record&lt;string, unknown&gt;</td><td class="type">the row's own fields</td><td class="desc">Maps a new grid row to the DemandFlow <code>fields</code> an append needs — its required <code>entity</code>/<code>level</code>/<code>comboKey</code> — since the structural append only knows the row's own fields.</td></tr>
2771
+ </tbody>
2772
+ </table>
2773
+ </div>
2774
+
2775
+ <h5 id="graphql-options"><code>graphqlAdapter</code></h5>
2776
+ <p class="section-note">
2777
+ <strong>GraphQL has no fixed query semantics, so this adapter is configured.</strong> A filter,
2778
+ a sort and pagination are whatever the schema defines, so the two hooks are yours to write:
2779
+ <code>buildQuery</code> turns the pushed plan into the <code>{ query, variables }</code> body the
2780
+ endpoint is POSTed, and <code>parseResponse</code> reads its <code>data</code> back into
2781
+ <code>{ rows, total }</code>. The defaults cover an offset/limit list with a
2782
+ <code>totalCount</code> and a Relay cursor connection; either is replaced whole by passing the
2783
+ hook. The default query pushes only the window and asks for the total, which is why the default
2784
+ capabilities are <code>range</code> and <code>total</code> and nothing more — declare
2785
+ <code>operators</code> or <code>capabilities</code> for filter or sort only alongside a
2786
+ <code>buildQuery</code> that genuinely emits them, or the grid returns the wrong rows silently.
2787
+ </p>
2788
+ <div class="table-wrap">
2789
+ <table>
2790
+ <thead><tr><th>Option</th><th>Type</th><th>Default</th><th>Meaning</th></tr></thead>
2791
+ <tbody>
2792
+ <tr><td class="name">url</td><td class="type">string</td><td class="type">—</td><td class="desc">The GraphQL endpoint, POSTed a <code>{ query, variables }</code> body. Required.</td></tr>
2793
+ <tr><td class="name">headers</td><td class="type">Record&lt;string, string&gt;</td><td class="type">{}</td><td class="desc">Sent on every request, merged over <code>Accept</code> and <code>Content-Type: application/json</code>. Where a fixed bearer token or API key goes. See <a href="#adapter-auth">authenticating</a>.</td></tr>
2794
+ <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for an expiring token, a proxy or a non-browser runtime. The adapter bundles no HTTP client. See <a href="#adapter-auth">authenticating</a>.</td></tr>
2795
+ <tr><td class="name">field</td><td class="type">string</td><td class="type"><code>items</code></td><td class="desc">The root query field the <em>default</em> query selects from, e.g. <code>orders</code>. Ignored when you pass <code>buildQuery</code>.</td></tr>
2796
+ <tr><td class="name">fields</td><td class="type">string[]</td><td class="type"><code>['id']</code> (with a warning)</td><td class="desc">The field names the default query's selection set requests. Name what your grid shows; a default query that selects nothing useful is surfaced rather than left an empty grid.</td></tr>
2797
+ <tr><td class="name">selection</td><td class="type">string</td><td class="type">— (uses <code>fields</code>)</td><td class="desc">A raw selection set for nested fields, e.g. <code>'id name address { city }'</code>, overriding <code>fields</code>.</td></tr>
2798
+ <tr><td class="name">pagination</td><td class="type">'offset' | 'cursor'</td><td class="type"><code>offset</code></td><td class="desc">The default convention: an <code>offset</code>/<code>limit</code> list, or a Relay <code>cursor</code> connection (<code>first</code>/<code>after</code> with <code>pageInfo</code>). A cursor connection is forward-only, so a deep window is paged forward to and costs round trips proportional to its offset.</td></tr>
2799
+ <tr><td class="name">pageSize</td><td class="type">number</td><td class="type">1000</td><td class="desc">The page size for the two forward walks: pulling the whole result (when residual work forces it) and walking a cursor connection to a window.</td></tr>
2800
+ <tr><td class="name">vars</td><td class="type">Partial&lt;Record&lt;'offset'|'limit'|'first'|'after', string&gt;&gt;</td><td class="type">{ offset:'offset', limit:'limit', first:'first', after:'after' }</td><td class="desc">Renames the pagination variables the adapter drives per page, to match the names your schema's arguments use.</td></tr>
2801
+ <tr><td class="name">capabilities</td><td class="type">PushdownCapabilities</td><td class="type">{ range:true, total:true, filter:false, sort:false, quick:false }</td><td class="desc">What your <code>buildQuery</code> actually pushes, merged over the defaults. Declaring a capability the hook does not honour returns the wrong rows silently, so the default declares only the window and the total.</td></tr>
2802
+ <tr><td class="name">operators</td><td class="type">string[]</td><td class="type">— (filtering off)</td><td class="desc">The comparisons your <code>buildQuery</code> emits, e.g. <code>['eq','gt','contains']</code>. Setting it turns filtering on as a <code>tree</code>; pair it with a <code>buildQuery</code> that translates the condition tree, or the filter is declared but not applied.</td></tr>
2803
+ <tr><td class="name">buildQuery</td><td class="type">(request: RemoteRequest) =&gt; { query, variables }</td><td class="type">the offset or cursor default</td><td class="desc">Turns the pushed plan — the window, and whatever filter/sort/quick you declared pushable — into the GraphQL operation to POST. This is where your schema's argument names live.</td></tr>
2804
+ <tr><td class="name">parseResponse</td><td class="type">(data: object) =&gt; { rows, total, pageInfo? }</td><td class="type">the offset or cursor default</td><td class="desc">Reads the operation's <code>data</code> into the row array and the count of <em>all</em> matching rows. For a cursor connection, return <code>pageInfo</code> (<code>hasNextPage</code>, <code>endCursor</code>) so the adapter can walk forward.</td></tr>
2805
+ <tr><td class="name">buildMutation</td><td class="type">(op: object) =&gt; { query, variables }</td><td class="type">— (write-back off)</td><td class="desc">Turns a mutation into a GraphQL operation. A declared follow-up wired by the write-back wave; <code>capabilities.mutate</code> stays <code>false</code> by declaration until then.</td></tr>
2648
2806
  </tbody>
2649
2807
  </table>
2650
2808
  </div>
@@ -3198,6 +3356,10 @@ off(); <span class="cmt">// on() returns i
3198
3356
  <tr><td class="name">row:dblclicked</td><td class="type">{ row, key, index, event }</td><td class="desc"></td></tr>
3199
3357
  <tr><td class="name">row:edit:start</td><td class="type">{ row, key, colId, column }</td><td class="desc">Replaces the cell pair when <code>edit.mode</code> is <code>'row'</code>.</td></tr>
3200
3358
  <tr><td class="name">row:edit:end</td><td class="type">{ row, key, colId, valid, errors }</td><td class="desc">In row mode an invalid cell blocks the whole commit and the session stays open.</td></tr>
3359
+ <tr><td class="name">row:pending</td><td class="type">{ id, kind, key, temp, row }</td><td class="desc">A row was appended or deleted optimistically, not yet durable. <code>kind</code> is <code>'append'</code> or <code>'delete'</code>; <code>temp: true</code> means an appended row under a client temp key. Only over a source that declares <code>mutate.append</code>/<code>delete</code>.</td></tr>
3360
+ <tr><td class="name">row:confirmed</td><td class="type">{ id, kind, key, tempKey?, row, superseded }</td><td class="desc">The append or delete reached the server. For an append the row has already been rekeyed from <code>tempKey</code> to the server <code>key</code> — selection, expansion, focus and in-flight cell edits followed.</td></tr>
3361
+ <tr><td class="name">row:reverted</td><td class="type">{ id, kind, key, tempKey?, reason, row, superseded, applied }</td><td class="desc">The append or delete failed: an appended row is removed, a deleted row restored. <code>applied: false</code> means a newer op owned the key, so nothing was undone.</td></tr>
3362
+ <tr><td class="name">row:conflict</td><td class="type">{ id, kind, key, serverRow, row }</td><td class="desc">The op succeeded but the server row had moved underneath it. Last-write-wins: <code>serverRow</code> carries the server's truth so the divergence is surfaced, never swallowed.</td></tr>
3201
3363
  <tr><td class="name">cell:contextmenu</td><td class="type">{ ...cellParams, row }</td><td class="desc">Right-click on a cell.</td></tr>
3202
3364
  <tr><td class="name">sort:changed</td><td class="type">{ sort }</td><td class="desc">The full sort entry list.</td></tr>
3203
3365
  <tr><td class="name">filter:changed</td><td class="type">{ filters } | { quick }</td><td class="desc">The condition tree or the quick filter changed.</td></tr>
@@ -4118,7 +4280,7 @@ grid.destroy();
4118
4280
  <h3 id="core-exports-example">Every core export, executed</h3>
4119
4281
  <p class="section-note">Named and resolved against the barrel on every build. A rename or a removal fails
4120
4282
  here rather than in a consumer&#39;s build.</p>
4121
- <pre data-run="js" data-expect="61" data-covers="export:AR export:AR_SA export:CS_CZ export:DA_DK export:DEFAULT_LOCALE export:DE_DE export:EL_GR export:EN_GB export:EN_US export:ES_ES export:FI_FI export:FR_CA export:FR_FR export:HU_HU export:IT_IT export:JA_JP export:LOCALES export:MESSAGE_KEYS export:NB_NO export:NL_NL export:NO_CAPABILITIES export:PL_PL export:PT_BR export:RO_RO export:SV_SE export:UK_UA export:UNIT_SYSTEMS export:applyResidual export:auditCatalogue export:compileRules export:createPushdownSource export:createRadixType export:createUnitType export:defineUnit export:dfqlAdapter export:duckdbAdapter export:evaluateFormula export:formatList export:formatUnit export:getVersion export:ingest export:ingestSync export:licenceInfo export:licenceState export:licenseInfo export:licenseState export:looksLikeFormula export:odataAdapter export:parseUnit export:planQuery export:referencesOf export:registerModules export:registerUnitSystem export:resolveCatalogue export:resolveLocale export:restAdapter export:restoreState export:serialiseState export:setLicence export:setLicense export:version"><code><span class="kw">const</span> core = <span class="kw">await</span> import('../packages/core/src/index.js');
4283
+ <pre data-run="js" data-expect="62" data-covers="export:AR export:AR_SA export:CS_CZ export:DA_DK export:DEFAULT_LOCALE export:DE_DE export:EL_GR export:EN_GB export:EN_US export:ES_ES export:FI_FI export:FR_CA export:FR_FR export:HU_HU export:IT_IT export:JA_JP export:LOCALES export:MESSAGE_KEYS export:NB_NO export:NL_NL export:NO_CAPABILITIES export:PL_PL export:PT_BR export:RO_RO export:SV_SE export:UK_UA export:UNIT_SYSTEMS export:applyResidual export:auditCatalogue export:compileRules export:createPushdownSource export:createRadixType export:createUnitType export:defineUnit export:dfqlAdapter export:duckdbAdapter export:evaluateFormula export:formatList export:formatUnit export:getVersion export:graphqlAdapter export:ingest export:ingestSync export:licenceInfo export:licenceState export:licenseInfo export:licenseState export:looksLikeFormula export:odataAdapter export:parseUnit export:planQuery export:referencesOf export:registerModules export:registerUnitSystem export:resolveCatalogue export:resolveLocale export:restAdapter export:restoreState export:serialiseState export:setLicence export:setLicense export:version"><code><span class="kw">const</span> core = <span class="kw">await</span> import('../packages/core/src/index.js');
4122
4284
 
4123
4285
  <span class="cmt">// Every declared export of the core package, named and checked. A symbol</span>
4124
4286
  <span class="cmt">// that was renamed or dropped fails here, not in a consumer's build.</span>
@@ -4133,8 +4295,8 @@ grid.destroy();
4133
4295
  'auditCatalogue', 'compileRules', 'createPushdownSource', 'createRadixType',
4134
4296
  'createUnitType', 'defineUnit', 'dfqlAdapter', 'duckdbAdapter',
4135
4297
  'evaluateFormula', 'formatList', 'formatUnit', 'getVersion',
4136
- 'ingest', 'ingestSync', 'licenceInfo', 'licenceState',
4137
- 'licenseInfo', 'licenseState', 'looksLikeFormula', 'odataAdapter',
4298
+ 'graphqlAdapter', 'ingest', 'ingestSync', 'licenceInfo',
4299
+ 'licenceState', 'licenseInfo', 'licenseState', 'looksLikeFormula', 'odataAdapter',
4138
4300
  'parseUnit', 'planQuery', 'referencesOf', 'registerModules',
4139
4301
  'registerUnitSystem', 'resolveCatalogue', 'resolveLocale', 'restAdapter',
4140
4302
  'restoreState', 'serialiseState', 'setLicence', 'setLicense',
@@ -4330,14 +4492,14 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4330
4492
  <h3 id="remaining-config-example">The remaining option names, executed</h3>
4331
4493
  <p class="section-note">Set on a real grid and checked against its own diagnostics: an unrecognised key
4332
4494
  raises <code>config.unknown</code>, so a renamed or dropped option fails here.</p>
4333
- <pre data-run="js" data-expect="53" data-covers="config:ai config:alignedGrids config:columnDefaults config:columnGroups config:columnMenu config:columnPresets config:columnTagFilter config:comments config:components config:context config:contextMenu config:dataTypes config:detail config:diff config:edit config:environment config:facets config:formatting config:formulaFunctions config:fullWidth config:grandTotalRow config:groupPanel config:highlightOnChange config:historyBar config:hostFilter config:ingest config:licence config:pagination config:permissions config:pinnedBottomRows config:pinnedTopRows config:pipes config:pivot config:presence config:responsive config:rowClass config:rowForm config:rowStyle config:rowTemplate config:rowTransfer config:selection config:source config:state config:statusBar config:toolPanel config:totalFns config:totalOnlyChangedColumns config:tree config:typeOptions config:updates config:variants config:views config:workerUrl config:announce config:apply config:at config:background config:byKey config:compute config:config config:count config:crossFilter config:destroy config:group config:groupSelectsChildren config:groupSelectsFiltered config:height config:loaded config:order config:pageSizes config:refresh config:reload config:render config:rowLabel config:sort"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4495
+ <pre data-run="js" data-expect="54" data-covers="config:ai config:alignedGrids config:anomalySummary config:columnDefaults config:columnGroups config:columnMenu config:columnPresets config:columnTagFilter config:comments config:components config:context config:contextMenu config:dataTypes config:detail config:diff config:edit config:environment config:facets config:formatting config:formulaFunctions config:fullWidth config:grandTotalRow config:groupPanel config:highlightOnChange config:historyBar config:hostFilter config:ingest config:licence config:pagination config:permissions config:pinnedBottomRows config:pinnedTopRows config:pipes config:pivot config:presence config:responsive config:rowClass config:rowForm config:rowStyle config:rowTemplate config:rowTransfer config:selection config:source config:state config:statusBar config:toolPanel config:totalFns config:totalOnlyChangedColumns config:tree config:typeOptions config:updates config:variants config:views config:workerUrl config:announce config:apply config:at config:background config:byKey config:compute config:config config:count config:crossFilter config:destroy config:group config:groupSelectsChildren config:groupSelectsFiltered config:height config:loaded config:order config:pageSizes config:refresh config:reload config:render config:rowLabel config:sort"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4334
4496
  <span class="kw">const</span> { reportedWarnings } = <span class="kw">await</span> import('../packages/core/src/internal/util.js');
4335
4497
 
4336
4498
  <span class="cmt">// Top-level configuration keys. Each is set on a real grid, and the grid is</span>
4337
4499
  <span class="cmt">// asked whether it recognised them: an unrecognised key raises</span>
4338
4500
  <span class="cmt">// `config.unknown:&lt;key&gt;`, so a renamed or dropped option fails right here.</span>
4339
4501
  <span class="kw">const</span> documented = [
4340
- 'ai', 'alignedGrids', 'columnDefaults', 'columnGroups', 'columnMenu',
4502
+ 'ai', 'alignedGrids', 'anomalySummary', 'columnDefaults', 'columnGroups', 'columnMenu',
4341
4503
  'columnPresets', 'columnTagFilter', 'comments', 'components', 'context',
4342
4504
  'contextMenu', 'dataTypes', 'detail', 'diff', 'edit',
4343
4505
  'environment', 'facets', 'formatting', 'formulaFunctions', 'fullWidth',
@@ -4373,7 +4535,7 @@ grid.destroy();
4373
4535
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
4374
4536
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
4375
4537
  has not fired yet — so the name is checked rather than left to be discovered.</p>
4376
- <pre data-run="js" data-expect="102" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4538
+ <pre data-run="js" data-expect="106" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4377
4539
 
4378
4540
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
4379
4541
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -4398,6 +4560,7 @@ grid.destroy();
4398
4560
  'presentation:view', 'range:changed', 'ready', 'redaction:changed',
4399
4561
  'render:done', 'render:first', 'row:clicked', 'row:copied',
4400
4562
  'row:dblclicked', 'row:edit:end', 'row:edit:start', 'row:moved',
4563
+ 'row:pending', 'row:confirmed', 'row:reverted', 'row:conflict',
4401
4564
  'row:received', 'row:sent', 'rows:deferred', 'rows:paused',
4402
4565
  'rows:queued', 'rows:resumed', 'scroll', 'scroll:end',
4403
4566
  'selection:changed', 'size:changed', 'source:error', 'stream:chunk',
@@ -4591,6 +4754,34 @@ session.push(5);
4591
4754
  session.push(15);
4592
4755
  return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')}; session ${session.reduce('avg').value}`;</code></pre>
4593
4756
 
4757
+ <h3 id="anomaly-detection">Anomaly detection</h3>
4758
+ <p class="section-note">Flag the rows that do not belong (BACKLOG-0000749). Interpretable statistics with a written-down cut, never a black box: a robust per-column outlier score, Tukey's fences, and multivariate distance from the joint centre. These are the pure kernels behind <code>grid.statistics.anomalies(...)</code> and the <code>anomalyScore</code>/<code>anomalyFlag</code> shadow columns; a host can score a plain array the same way the grid scores a column.</p>
4759
+ <div class="table-wrap">
4760
+ <table>
4761
+ <thead><tr><th>Name</th><th>Signature</th><th>Description</th></tr></thead>
4762
+ <tbody>
4763
+ <tr><td class="name">ANOMALY_METHODS</td><td class="type">readonly ('modifiedZScore' | 'iqr' | 'mahalanobis')[]</td><td class="desc">The three methods a caller may ask for, named so a result can say which produced a flag.</td></tr>
4764
+ <tr><td class="name">modifiedZScores</td><td class="type">(values, opts?) =&gt; { median, mad, threshold, scores, flags, flagged }</td><td class="desc">Per-row robust outlier score, <code>0.6745·(x − median)/MAD</code>, flagged past <code>threshold</code> (default 3.5). Built on the median and MAD, so one wild reading cannot inflate the spread and hide — the masking effect that fools an ordinary z-score. A non-finite reading and a zero-MAD column yield a null score, not an invented one.</td></tr>
4765
+ <tr><td class="name">iqrFences</td><td class="type">(values, opts?) =&gt; { q1, q3, iqr, lower, upper, k } | null</td><td class="desc">Tukey's fences, <code>[Q1 − k·IQR, Q3 + k·IQR]</code> (default <code>k = 1.5</code>), the same fence the box plot draws, on R type 7 quartiles.</td></tr>
4766
+ <tr><td class="name">mahalanobis</td><td class="type">(matrix, opts?) =&gt; { center, df, cutoff, singular, used, distances, squared, flags, flagged } | null</td><td class="desc">Distance of every row from the joint centre in the metric of the data's own covariance, cut at a χ² quantile (default the 0.975 point). Catches a row impossible only in combination — heavy <em>and</em> short — that a per-column scan misses. A row with any missing coordinate is left unplaced; a singular covariance is ridge-regularised and reported as <code>singular</code> rather than throwing.</td></tr>
4767
+ </tbody>
4768
+ </table>
4769
+ </div>
4770
+ <pre data-run="js" data-expect="methods 3; flagged 1; upper 22.875; joint true" data-covers="export:ANOMALY_METHODS export:modifiedZScores export:iqrFences export:mahalanobis"><code>const { modifiedZScores, iqrFences, mahalanobis, ANOMALY_METHODS } = await import('../packages/core/src/index.js');
4771
+ // Univariate: the robust modified z-score flags the 500 among steady readings,
4772
+ // where the mean and standard deviation an ordinary z uses would be dragged up
4773
+ // by the outlier until it no longer looked like one.
4774
+ const z = modifiedZScores([20, 21, 19, 20, 21, 19, 20, 500]);
4775
+ // Tukey's fences say the same, drawn the way a box plot draws them.
4776
+ const fence = iqrFences([20, 21, 19, 20, 21, 19, 20, 500]);
4777
+ // Multivariate: height and weight move together; the last person is tall but
4778
+ // very light, so the pair is impossible even though neither number is extreme.
4779
+ const hw = [];
4780
+ for (let i = 0; i &lt; 12; i++) hw.push([160 + i, 60 + i]);
4781
+ hw.push([182, 45]);
4782
+ const m = mahalanobis(hw); // the χ² cut flags the off-line row
4783
+ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.upper}; joint ${m.flags[12]}`;</code></pre>
4784
+
4594
4785
 
4595
4786
  <!-- BEGIN GENERATED TYPE REFERENCE -->
4596
4787
  <h2 id="type-reference">Type reference</h2>
@@ -4654,6 +4845,55 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
4654
4845
  </tbody>
4655
4846
  </table>
4656
4847
  </div>
4848
+ <h3 id="type-AnomalyReason">AnomalyReason</h3>
4849
+ <div class="table-wrap">
4850
+ <table>
4851
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4852
+ <tbody>
4853
+ <tr><td class="name">column</td><td class="type">string</td><td class="desc">The column that put this row over the line.</td></tr>
4854
+ <tr><td class="name">name</td><td class="type">string</td><td class="desc">The column's display name, or its id.</td></tr>
4855
+ <tr><td class="name">value</td><td class="type">number</td><td class="desc">The row's value in that column.</td></tr>
4856
+ <tr><td class="name">score</td><td class="type">number</td><td class="desc">The modified z-score, for the `modifiedZScore` method. <small>(optional)</small></td></tr>
4857
+ <tr><td class="name">lower</td><td class="type">number</td><td class="desc">The lower fence, for the `iqr` method. <small>(optional)</small></td></tr>
4858
+ <tr><td class="name">upper</td><td class="type">number</td><td class="desc">The upper fence, for the `iqr` method. <small>(optional)</small></td></tr>
4859
+ <tr><td class="name">method</td><td class="type">'modifiedZScore' | 'iqr'</td><td class="desc">Which rule flagged it. <small>(optional)</small></td></tr>
4860
+ </tbody>
4861
+ </table>
4862
+ </div>
4863
+ <h3 id="type-AnomalyReport">AnomalyReport</h3>
4864
+ <div class="table-wrap">
4865
+ <table>
4866
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4867
+ <tbody>
4868
+ <tr><td class="name">method</td><td class="type">'modifiedZScore' | 'iqr' | 'mahalanobis'</td><td class="desc">Which rule produced the report.</td></tr>
4869
+ <tr><td class="name">k</td><td class="type">number</td><td class="desc">The IQR fence multiplier, for the `iqr` method. <small>(optional)</small></td></tr>
4870
+ <tr><td class="name">n</td><td class="type">number</td><td class="desc">How many rows the scan ran over.</td></tr>
4871
+ <tr><td class="name">rows</td><td class="type">AnomalyRow[]</td><td class="desc">The flagged rows, worst first.</td></tr>
4872
+ <tr><td class="name">flagged</td><td class="type">number</td><td class="desc">How many rows were flagged.</td></tr>
4873
+ <tr><td class="name">skipped</td><td class="type">string[]</td><td class="desc">The column ids that were not numeric and so could not be scored.</td></tr>
4874
+ <tr><td class="name">scored</td><td class="type">number</td><td class="desc">How many numeric columns were scored (univariate). <small>(optional)</small></td></tr>
4875
+ <tr><td class="name">columns</td><td class="type">unknown</td><td class="desc">Per-column summaries (univariate): the centre, spread and fence per column. <small>(optional)</small></td></tr>
4876
+ <tr><td class="name">df</td><td class="type">number</td><td class="desc">The degrees of freedom of the χ² cut (multivariate). <small>(optional)</small></td></tr>
4877
+ <tr><td class="name">cutoff</td><td class="type">number | null</td><td class="desc">The χ² cut the squared distance is compared against (multivariate). <small>(optional)</small></td></tr>
4878
+ <tr><td class="name">center</td><td class="type">number[]</td><td class="desc">The joint centre the distances are measured from (multivariate). <small>(optional)</small></td></tr>
4879
+ <tr><td class="name">used</td><td class="type">number</td><td class="desc">How many complete rows defined the metric (multivariate). <small>(optional)</small></td></tr>
4880
+ <tr><td class="name">singular</td><td class="type">boolean</td><td class="desc">Whether the covariance was singular and had to be regularised (multivariate). <small>(optional)</small></td></tr>
4881
+ </tbody>
4882
+ </table>
4883
+ </div>
4884
+ <h3 id="type-AnomalyRow">AnomalyRow</h3>
4885
+ <div class="table-wrap">
4886
+ <table>
4887
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4888
+ <tbody>
4889
+ <tr><td class="name">rowKey</td><td class="type">string | null</td><td class="desc">The row key — stable across a sort or a feed, where the index is not.</td></tr>
4890
+ <tr><td class="name">index</td><td class="type">number</td><td class="desc">The physical row index at the time of the call.</td></tr>
4891
+ <tr><td class="name">score</td><td class="type">number | null</td><td class="desc">The row's headline score: its most extreme modified z-score across the flagging columns (univariate), the Mahalanobis distance (multivariate), or null for the IQR method, which has no single score.</td></tr>
4892
+ <tr><td class="name">squared</td><td class="type">number | null</td><td class="desc">The squared Mahalanobis distance, for the `mahalanobis` method. <small>(optional)</small></td></tr>
4893
+ <tr><td class="name">why</td><td class="type">AnomalyReason[]</td><td class="desc">Why this row was flagged: the columns and how far, so it is explainable.</td></tr>
4894
+ </tbody>
4895
+ </table>
4896
+ </div>
4657
4897
  <h3 id="type-ApproximateEntry">ApproximateEntry</h3>
4658
4898
  <p class="section-note">One entry of the approximate maintenance tier.</p>
4659
4899
  <div class="table-wrap">
@@ -5679,12 +5919,19 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
5679
5919
  <tr><td class="name">undo</td><td class="type">(): void</td><td class="desc"></td></tr>
5680
5920
  <tr><td class="name">redo</td><td class="type">(): void</td><td class="desc"></td></tr>
5681
5921
  <tr><td class="name">setCells</td><td class="type">(writes: { key: string; colId: string; value: unknown }[], type?: 'cell' | 'fill' | 'paste'): number</td><td class="desc"></td></tr>
5922
+ <tr><td class="name">bulkSet</td><td class="type">(value: unknown, opts?: { cells?: { key: string; colId: string }[] }): number</td><td class="desc">Set one value across a block of cells as a single undoable step (§12, card 740). Defaults to the selected range; read-only and non-editable cells are skipped and every write runs the normal parse/validate path.</td></tr>
5923
+ <tr><td class="name">fill</td><td class="type">(opts?: { direction?: 'down' | 'up' | 'left' | 'right'; series?: boolean; range?: CellRange }): number</td><td class="desc">Fill a selected range from its leading edge as one undoable step (§12, card 740). The default copies the anchor across the range (Excel's Ctrl+D and its natural siblings); `series: true` extrapolates a numeric or date series from the first one or two cells of each line, falling back to a copy for types with no series. `direction` defaults to `'down'`.</td></tr>
5682
5924
  <tr><td class="name">pasteInto</td><td class="type">(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): number</td><td class="desc"></td></tr>
5683
5925
  <tr><td class="name">pastePreview</td><td class="type">boolean</td><td class="desc">Whether a bulk paste is previewed before it commits (`edit.pastePreview`, §12). <small>(read-only)</small></td></tr>
5684
5926
  <tr><td class="name">previewPaste</td><td class="type">(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): {</td><td class="desc">Compute what a paste would change, without committing (§12). The engine behind `edit.pastePreview`: `changes` are the accepted writes with their old and new values (and whether each actually differs), `rejected` are the cells a commit would refuse, each with a reason.</td></tr>
5685
5927
  <tr><td class="name">settle</td><td class="type">(</td><td class="desc">Report the outcome of an in-flight write (§18.3; §5.1-5.2 reconcile). `reconcile` carries server truth on a successful settle: `value` is a server-authoritative value written back before `cell:confirmed` (`returning: 'row'`); `conflict.serverRow` surfaces a last-write-wins conflict via `cell:conflict`. Omit both to keep the optimistic value.</td></tr>
5686
5928
  <tr><td class="name">pending</td><td class="type">(): OpenWrite[]</td><td class="desc"></td></tr>
5687
5929
  <tr><td class="name">status</td><td class="type">(key: string, colId: string): 'pending' | null</td><td class="desc"></td></tr>
5930
+ <tr><td class="name">addRow</td><td class="type">(row: object): string | null</td><td class="desc">Append a row to a remote source optimistically and persist it (§5.3), the structural analog of the cell edit path. The row shows immediately under a client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is asked to persist it; when the server returns the real key the row is rekeyed everywhere the grid tracks it and `row:confirmed` fires, while a refused append is removed and fires `row:reverted`. Only wired when the source declares `mutate.append`; otherwise it warns once and returns null.</td></tr>
5931
+ <tr><td class="name">deleteRow</td><td class="type">(key: string): string | null</td><td class="desc">Delete a row from a remote source optimistically and persist it (§5.3). The row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })` is asked to remove it; on confirmation the row is purged and `row:confirmed` fires, on refusal it is restored and `row:reverted` fires. Only wired when the source declares `mutate.delete`; otherwise it warns once and returns null.</td></tr>
5932
+ <tr><td class="name">settleRow</td><td class="type">(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): boolean</td><td class="desc">Report the outcome of an optimistic structural write (§5.3), the counterpart to {@link settle} for `edit.confirm: 'manual'` over a backend that acknowledges an append/delete on a separate channel. The id arrives on `row:pending`.</td></tr>
5933
+ <tr><td class="name">rowStatus</td><td class="type">(key: string): 'pending' | null</td><td class="desc">Whether a row has a structural op in flight (§5.3).</td></tr>
5934
+ <tr><td class="name">pendingRows</td><td class="type">(): OpenRowOp[]</td><td class="desc">Every structural op still awaiting an outcome (§5.3), oldest first; always empty when the source cannot append or delete.</td></tr>
5688
5935
  </tbody>
5689
5936
  </table>
5690
5937
  </div>
@@ -6102,6 +6349,7 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6102
6349
  <tr><td class="name">cornerRadius</td><td class="type">boolean | number | string</td><td class="desc">Round the grid's outer corners. Square by default. `true` adopts the theme's own radius; a number is pixels; a string is used as written, so a host can pass its own token or a relative unit. <small>(optional)</small></td></tr>
6103
6350
  <tr><td class="name">stripedRows</td><td class="type">boolean</td><td class="desc">Shade alternate data rows (zebra striping). Off by default, and strictly opt-in: an existing grid must look exactly the same on upgrade. When `true`, every other data row takes the theme's `--lattice-surface-alt` background, which every palette already defines, so dark, high-contrast and terminal stripe correctly without extra work. Parity follows the row's *logical* index, not its position in the DOM, so a row keeps its stripe across a scroll even though the rows are recycled. Structural rows — group headings, group footers and the grand total — are never striped, and both selection and hover still win over the stripe. <small>(optional)</small></td></tr>
6104
6351
  <tr><td class="name">columnTagFilter</td><td class="type">boolean | { multiple?: boolean; label?: string }</td><td class="desc">Show a bar above the column headings for filtering columns by tag. Off by default, and it draws nothing unless some column carries a `tags` entry. `multiple: true` lets more than one tag be chosen at once. Only tagged columns are ever hidden, so an untagged account or total column stays visible whatever is selected. <small>(optional)</small></td></tr>
6352
+ <tr><td class="name">anomalySummary</td><td class="type">boolean | { column?: string; label?: string }</td><td class="desc">Show a small chip in the grid chrome that reads how many rows an anomaly shadow column has flagged, and filters the grid to exactly those when it is clicked (BACKLOG-0000799). Off by default, and it draws nothing unless a column declares a `shadow: { kind: 'anomalyFlag' }`. The count and the filter both read that one shadow column, so the number on the chip is the number of rows the click reveals. `column` names the base column to summarise when more than one anomaly-flag shadow is present; `label` overrides the chip's wording. <small>(optional)</small></td></tr>
6105
6353
  <tr><td class="name">typeOptions</td><td class="type">Record&lt;string, {</td><td class="desc">Per-column options a data type reads. `ratio` and `percentRate` use `{ weight }` to name the column their average is weighted by. A unit type reads `{ significantFigures }` to render to a fixed precision rather than a fixed number of decimals. <small>(optional)</small></td></tr>
6106
6354
  <tr><td class="name">rowTemplate</td><td class="type">string | {</td><td class="desc"><small>(optional)</small></td></tr>
6107
6355
  <tr><td class="name">gallery</td><td class="type">boolean | {</td><td class="desc">Present rows as a gallery of tiles (§7.12). The tiled card layout with a size-driven column count: tiles as wide as `tileWidth` allows, as many across as the container holds, laid out by the same 2-D virtualisation the grid already runs. `true` draws a tile per row generated from the columns; an object sizes them or supplies a template. Presentation only — sort, filter, group and the data pipeline are unchanged. <small>(optional)</small></td></tr>
@@ -6158,6 +6406,7 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6158
6406
  <tr><td class="name">rowStyle</td><td class="type">CellStyle | ((p: RowStyleParams) =&gt; CellStyle)</td><td class="desc">Inline styles for every row. Camel-case or hyphenated property names. <small>(optional)</small></td></tr>
6159
6407
  <tr><td class="name">toolPanel</td><td class="type">boolean | {</td><td class="desc"><small>(optional)</small></td></tr>
6160
6408
  <tr><td class="name">groupPanel</td><td class="type">boolean | {</td><td class="desc">A drag-and-drop group-by strip above the column header — the pattern AG Grid calls the row-group panel. Drag a column heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable (arrows navigate, Shift+arrow reorders, Delete ungroups, and an add control groups any column), and every change is announced through the live region, which is why it also addresses the drag-only complaint of BACKLOG-0000429. Off by default and non-breaking, matching `toolPanel`. It drives the same grouping model as `grid.columns.group()`; it reimplements nothing. <small>(optional)</small></td></tr>
6409
+ <tr><td class="name">kpis</td><td class="type">Array&lt;Omit&lt;StatConfig, 'grid' | 'container'&gt;&gt;</td><td class="desc">A built-in KPI/stat strip: a labelled band of {@link createStat} tiles the grid places for you, above the column header. Each entry is a stat spec — the same fields {@link StatConfig} takes, minus `grid` and `container`, which the grid supplies — so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change exactly as a stand-alone stat does. Off by default and non-breaking, matching `groupPanel`: no `kpis` means no band and no cost. It reuses `createStat` and reimplements no compute. <small>(optional)</small></td></tr>
6161
6410
  <tr><td class="name">quickFilterText</td><td class="type">string</td><td class="desc">The quick filter's initial text. <small>(optional)</small></td></tr>
6162
6411
  <tr><td class="name">permissions</td><td class="type">PermissionPolicy</td><td class="desc">Per-column read/write/hidden policy. A usability control, not a security boundary: hidden data is still resident in the store. Enforce the same policy server-side with `permittedColumns` / `permittedExport`. <small>(optional)</small></td></tr>
6163
6412
  <tr><td class="name">diff</td><td class="type">{</td><td class="desc">Prior state for diff and audit mode. <small>(optional)</small></td></tr>
@@ -6213,6 +6462,55 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6213
6462
  </tbody>
6214
6463
  </table>
6215
6464
  </div>
6465
+ <h3 id="type-GroupComparison">GroupComparison</h3>
6466
+ <p class="section-note">The result of {@link StatisticsApi.compareGroups}: how big *and* how sure, as data to interpret. Carries no significance verdict — the p-value is a number, never a flag or a badge.</p>
6467
+ <div class="table-wrap">
6468
+ <table>
6469
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6470
+ <tbody>
6471
+ <tr><td class="name">test</td><td class="type">'welch' | 'mannWhitney' | 'chiSquare'</td><td class="desc">The test used, named so it is never hidden.</td></tr>
6472
+ <tr><td class="name">chosenBy</td><td class="type">'auto' | 'override'</td><td class="desc">Whether the test was chosen automatically or forced by the caller.</td></tr>
6473
+ <tr><td class="name">reason</td><td class="type">string</td><td class="desc">Why this test — the column family, a normality screen, or the override.</td></tr>
6474
+ <tr><td class="name">statistic</td><td class="type">number</td><td class="desc">The test statistic.</td></tr>
6475
+ <tr><td class="name">statisticName</td><td class="type">string</td><td class="desc">What the statistic is: `t`, `U`, or `chiSquare`.</td></tr>
6476
+ <tr><td class="name">df</td><td class="type">number | null</td><td class="desc">The degrees of freedom, where the test has them; null for Mann-Whitney.</td></tr>
6477
+ <tr><td class="name">pValue</td><td class="type">number</td><td class="desc">The two-sided p-value, returned as data for the caller to interpret. Never thresholded into a verdict here.</td></tr>
6478
+ <tr><td class="name">interval</td><td class="type">GroupDifferenceInterval | null</td><td class="desc">The confidence interval on the difference, or null when there is none.</td></tr>
6479
+ <tr><td class="name">effectSize</td><td class="type">GroupEffectSize</td><td class="desc">The paired effect size, so the p-value is never read on its own.</td></tr>
6480
+ <tr><td class="name">nA</td><td class="type">number</td><td class="desc">How many rows the first group stood on.</td></tr>
6481
+ <tr><td class="name">nB</td><td class="type">number</td><td class="desc">How many rows the second group stood on.</td></tr>
6482
+ <tr><td class="name">groups</td><td class="type">[unknown, unknown]</td><td class="desc">The two group values compared, as keys.</td></tr>
6483
+ <tr><td class="name">reliable</td><td class="type">boolean</td><td class="desc">False when either group is under the reliability floor.</td></tr>
6484
+ <tr><td class="name">method</td><td class="type">string</td><td class="desc">The test's method, named per the reference-suite honesty rule.</td></tr>
6485
+ </tbody>
6486
+ </table>
6487
+ </div>
6488
+ <h3 id="type-GroupDifferenceInterval">GroupDifferenceInterval</h3>
6489
+ <p class="section-note">A confidence interval on the difference a two-sample test measured.</p>
6490
+ <div class="table-wrap">
6491
+ <table>
6492
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6493
+ <tbody>
6494
+ <tr><td class="name">estimate</td><td class="type">number</td><td class="desc">The point estimate of the difference the interval is around.</td></tr>
6495
+ <tr><td class="name">lower</td><td class="type">number</td><td class="desc"></td></tr>
6496
+ <tr><td class="name">upper</td><td class="type">number</td><td class="desc"></td></tr>
6497
+ <tr><td class="name">confidence</td><td class="type">number</td><td class="desc">The level the bounds were computed at, 0 to 1.</td></tr>
6498
+ <tr><td class="name">method</td><td class="type">string</td><td class="desc">The method, named for honesty: `welch-t`, `hodges-lehmann`, `newcombe`.</td></tr>
6499
+ <tr><td class="name">category</td><td class="type">unknown</td><td class="desc">For a chi-square interval, which category's share the difference is of. <small>(optional)</small></td></tr>
6500
+ </tbody>
6501
+ </table>
6502
+ </div>
6503
+ <h3 id="type-GroupEffectSize">GroupEffectSize</h3>
6504
+ <p class="section-note">The effect size paired with a two-sample test — the "how big" half.</p>
6505
+ <div class="table-wrap">
6506
+ <table>
6507
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6508
+ <tbody>
6509
+ <tr><td class="name">name</td><td class="type">string</td><td class="desc">The named measure: `pooledStandardMeanDifference` (Cohen's d) for the numeric tests, `categoricalTotalVariation` for chi-square.</td></tr>
6510
+ <tr><td class="name">value</td><td class="type">number | null</td><td class="desc">The effect size in its own terms, or null when it has no scale here.</td></tr>
6511
+ </tbody>
6512
+ </table>
6513
+ </div>
6216
6514
  <h3 id="type-HighlightApi">HighlightApi</h3>
6217
6515
  <div class="table-wrap">
6218
6516
  <table>
@@ -6416,9 +6714,9 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6416
6714
  <table>
6417
6715
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6418
6716
  <tbody>
6419
- <tr><td class="name">append</td><td class="type">boolean</td><td class="desc">The adapter can insert new rows. Wave 1: declared, not yet bridged. <small>(optional)</small></td></tr>
6420
- <tr><td class="name">update</td><td class="type">boolean</td><td class="desc">The adapter can patch existing rows. Wave 1: the wired kind (§4.3 Option A). <small>(optional)</small></td></tr>
6421
- <tr><td class="name">delete</td><td class="type">boolean</td><td class="desc">The adapter can remove rows. Wave 1: declared, not yet bridged. <small>(optional)</small></td></tr>
6717
+ <tr><td class="name">append</td><td class="type">boolean</td><td class="desc">The adapter can insert new rows, bridged by the structural engine (`edit.addRow`, §5.3). <small>(optional)</small></td></tr>
6718
+ <tr><td class="name">update</td><td class="type">boolean</td><td class="desc">The adapter can patch existing rows, bridged by the cell edit path (§4.3 Option A). <small>(optional)</small></td></tr>
6719
+ <tr><td class="name">delete</td><td class="type">boolean</td><td class="desc">The adapter can remove rows, bridged by the structural engine (`edit.deleteRow`, §5.3). <small>(optional)</small></td></tr>
6422
6720
  <tr><td class="name">returning</td><td class="type">'row' | 'key' | 'none'</td><td class="desc">The reconcile contract — what the server hands back after a successful mutation (§5.1). `'row'`: the authoritative row (id, computed columns, timestamps), reconciled before confirm. `'key'`: only the assigned key. `'none'` (the default): nothing — the optimistic value stands (last-write-wins). <small>(optional)</small></td></tr>
6423
6721
  </tbody>
6424
6722
  </table>
@@ -6481,6 +6779,20 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6481
6779
  </tbody>
6482
6780
  </table>
6483
6781
  </div>
6782
+ <h3 id="type-OpenRowOp">OpenRowOp</h3>
6783
+ <p class="section-note">A structural op (append or delete) still awaiting an outcome (§5.3).</p>
6784
+ <div class="table-wrap">
6785
+ <table>
6786
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6787
+ <tbody>
6788
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc"></td></tr>
6789
+ <tr><td class="name">kind</td><td class="type">'append' | 'delete'</td><td class="desc"></td></tr>
6790
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
6791
+ <tr><td class="name">state</td><td class="type">'pending' | 'superseded'</td><td class="desc"></td></tr>
6792
+ <tr><td class="name">age</td><td class="type">number</td><td class="desc"></td></tr>
6793
+ </tbody>
6794
+ </table>
6795
+ </div>
6484
6796
  <h3 id="type-OpenWrite">OpenWrite</h3>
6485
6797
  <div class="table-wrap">
6486
6798
  <table>
@@ -7293,8 +7605,10 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
7293
7605
  <tr><td class="name">tracking</td><td class="type">(): { columns: string[]; rows: number; forgotten: number }</td><td class="desc">What the shadow histories are costing.</td></tr>
7294
7606
  <tr><td class="name">reduce</td><td class="type">(colId: string, fn: string): unknown</td><td class="desc">Reduce a column by a named kernel over the filtered rows.</td></tr>
7295
7607
  <tr><td class="name">profile</td><td class="type">(colId: string): ColumnProfile | null</td><td class="desc">Everything worth knowing about one column, in one pass each.</td></tr>
7608
+ <tr><td class="name">anomalies</td><td class="type">(opts?: { columns?: string[]</td><td class="desc">The rows that do not belong (BACKLOG-0000749): anomaly detection over the filtered rows by the robust modified z-score (`modifiedZScore`, the default), Tukey's IQR fences (`iqr`), or multivariate Mahalanobis distance over the chosen columns (`mahalanobis`). Every flagged row carries the score behind it and the reason for it, so a flag is explainable rather than a verdict from nowhere. Non-numeric columns are returned under `skipped`.</td></tr>
7296
7609
  <tr><td class="name">subsetVsPopulation</td><td class="type">(opts?: { columns?: string[] }): SubsetComparison</td><td class="desc">Which columns differ most between the filtered subset and the whole population it was drawn from, ranked by effect size — never by a p-value. The measure is stated per column; a numeric and a categorical column are put on one bounded scale so they rank against each other.</td></tr>
7297
7610
  <tr><td class="name">datasetVsDataset</td><td class="type">(other: Grid, opts?: { columns?: string[] }): DatasetComparison</td><td class="desc">Which columns differ most between this grid and another, ranked by effect size — never by a p-value (BACKLOG-0000735). The generalisation of {@link subsetVsPopulation} from subset-vs-population to dataset-vs-dataset: two independent grids, yoked by passing one in, no shared store. A numeric column reports a pooled standardised mean difference (Cohen's d, symmetric in the two peers where Glass's delta is not); a categorical column the total variation of its category mix; both land on one bounded scale. Both sides are read over their filtered rows. Only shared columns are ranked; a column on one side alone is returned under `unmatched`.</td></tr>
7611
+ <tr><td class="name">compareGroups</td><td class="type">(colId: string, opts: TwoSampleSpec): GroupComparison | null</td><td class="desc">Is the difference between two groups real? A two-sample test returned as data to interpret — never a verdict (BACKLOG-0000750). The significance boundary the comparison story (653, 735) stopped short of: those rank by how *much* columns differ and return no p-value; this answers *how sure* for one chosen pair of groups and hands the p-value back as data. There is no `significant` flag, no badge, and no multiple-comparison correction. The rows are split by `opts.by`, the test is chosen by the column's family and named in the result (overridable with `opts.test`): Welch's t or Mann-Whitney U for a numeric column, chi-square for a categorical one. Every result pairs a confidence interval on the difference with the effect size, so it is always "how big and how sure".</td></tr>
7298
7612
  <tr><td class="name">correlation</td><td class="type">(a: string, b: string): number | null</td><td class="desc">Pearson's correlation between two columns.</td></tr>
7299
7613
  <tr><td class="name">covariance</td><td class="type">(a: string, b: string, opts?: { population?: boolean }): number | null</td><td class="desc">Covariance, a correlation before the scales are divided out.</td></tr>
7300
7614
  <tr><td class="name">regression</td><td class="type">(a: string, b: string): RegressionFit | null</td><td class="desc">Least-squares fit of `b` on `a`: in finance, beta and alpha.</td></tr>
@@ -7400,6 +7714,20 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
7400
7714
  </tbody>
7401
7715
  </table>
7402
7716
  </div>
7717
+ <h3 id="type-TwoSampleSpec">TwoSampleSpec</h3>
7718
+ <p class="section-note">How {@link StatisticsApi.compareGroups} splits the rows and picks a test.</p>
7719
+ <div class="table-wrap">
7720
+ <table>
7721
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7722
+ <tbody>
7723
+ <tr><td class="name">by</td><td class="type">string</td><td class="desc">The column whose values split the rows into groups. Required.</td></tr>
7724
+ <tr><td class="name">groups</td><td class="type">[unknown, unknown]</td><td class="desc">The two group values to compare. The two most frequent when omitted. <small>(optional)</small></td></tr>
7725
+ <tr><td class="name">test</td><td class="type">'auto' | 'welch' | 'mannWhitney' | 'chiSquare'</td><td class="desc">Force a test rather than choosing by column family. `auto` (the default) picks Welch or Mann-Whitney for a numeric column and chi-square for a categorical one; the choice is always named in the result. <small>(optional)</small></td></tr>
7726
+ <tr><td class="name">confidence</td><td class="type">number</td><td class="desc">The confidence level for the interval, 0 to 1. 0.95 by default. <small>(optional)</small></td></tr>
7727
+ <tr><td class="name">category</td><td class="type">unknown</td><td class="desc">The focal category for a chi-square difference interval, when the column has more than two categories. Without it, a multi-category comparison reports no scalar interval, only the effect size. <small>(optional)</small></td></tr>
7728
+ </tbody>
7729
+ </table>
7730
+ </div>
7403
7731
  <h3 id="type-UnitConfig">UnitConfig</h3>
7404
7732
  <p class="section-note">How a column stores, parses and renders a quantity.</p>
7405
7733
  <div class="table-wrap">