Keep the pivot's pf_ columns in the slice, and log the panel's expected change

The ledger filtered on every column of the loaded table, while the request
kept only pf_segment and pf_bucket and stripped pf_iter, pf_note, pf_op,
pf_user and pf_logid. Under the default layout, grouped by pf_iter, a click
on a customer's `scale` row totalled the scale rows and wrote to every row
the customer has -- on GRIFFIN, $4.5M shown against $9.7M written. They now
travel: pf_iter / pf_user / pf_logid compared directly, pf_note / pf_op
resolved to log ids as pf_segment / pf_bucket are, a null note as IS NULL.
They narrow a slice but do not make one selective on their own.

Each scale also carries `expected`, the Result line as a change, and
`plug`, both kept in the log's params beside `resolved` so a panel/server
disagreement can be found by reading it. Nothing compares them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-28 22:40:00 -04:00
parent 770e22d865
commit 5feb28eb76
5 changed files with 85 additions and 22 deletions

View File

@ -680,7 +680,9 @@ Turning a region back into slices re-derives, per cell, the same filters Perspec
**Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree. **Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree.
`pf_iter` is not a col_meta column, so it is stripped when a slice is built: two cells differing only by iter band produce the same effective slice. Duplicates are collapsed before the request — without that, `apply_mode: each` would apply the same change twice. **The `pf_` columns the pivot shows are slice columns too.** `pf_segment`, `pf_bucket`, `pf_note` and `pf_op` resolve to log ids (`COMPUTED_SLICE_COLS`); `pf_iter`, `pf_user` and `pf_logid` are forecast-table columns compared directly (`TABLE_SLICE_COLS`). They used to be stripped — `pf_iter` on the theory that two cells differing only by iter band are the same slice — while the ledger filtered on them, so under the default layout (grouped by `pf_iter`) a click on a customer's `scale` row totalled the scale rows and wrote to every row the customer has. They narrow a slice but do not make one selective on their own: `pf_iter = scale` alone is every adjustment in the version, and `assertSelective` still wants a dimension, date, `pf_segment` or `pf_bucket`. Duplicates are collapsed before the request — without that, `apply_mode: each` would apply the same change twice.
A scale also carries `expected` — the panel's Result line as a change (`value_incr`, `units_incr`, `price`, `basis`) — which the log keeps beside `resolved` so a disagreement between panel and server can be found by reading it. Nothing compares them yet.
**A blank dimension value is a value.** Both blanks occur in this data and they **A blank dimension value is a value.** Both blanks occur in this data and they
are different rows — on `fc_osm_skinny_34`, `partgroup` is null on 51,964 rows are different rows — on `fc_osm_skinny_34`, `partgroup` is null on 51,964 rows

View File

@ -541,7 +541,25 @@ function applyTokens(sql, tokens) {
// Without this they were dropped from the slice, and clicking a single bucket's // Without this they were dropped from the slice, and clicking a single bucket's
// cell scaled every bucket at that dimension intersection while the panel showed // cell scaled every bucket at that dimension intersection while the panel showed
// only the one clicked. // only the one clicked.
const COMPUTED_SLICE_COLS = { pf_segment: SEGMENT_EXPR, pf_bucket: BUCKET_EXPR }; //
// pf_note and pf_op are the same kind of column -- the pivot shows them, from
// the log entry -- and used to be refused, which the client pre-empted by
// stripping them. The ledger did not strip them, so a pivot grouped by one
// totalled a narrower set of rows than the operation then wrote.
const COMPUTED_SLICE_COLS = {
pf_segment: SEGMENT_EXPR,
pf_bucket: BUCKET_EXPR,
pf_note: NOTE_EXPR,
pf_op: 'l.operation',
};
// pf_ columns that are real columns of the forecast table, compared directly.
// pf_iter was stripped by the client and skipped here, on the reasoning that
// two cells differing only by iter band are the same slice. They are not to the
// ledger, which filtered on it: with the default layout grouped by pf_iter, a
// click on a customer's `scale` row showed the scale rows' total and wrote to
// every row the customer has.
const TABLE_SLICE_COLS = new Set(['pf_iter', 'pf_user', 'pf_logid']);
function computedSlicePredicate(col, val, versionId) { function computedSlicePredicate(col, val, versionId) {
if (versionId == null) { if (versionId == null) {
@ -549,12 +567,21 @@ function computedSlicePredicate(col, val, versionId) {
err.status = 500; err.status = 500;
throw err; throw err;
} }
const vals = (Array.isArray(val) ? val : [val]).map(v => `'${esc(v)}'`).join(', '); // a blank is a value here too -- a load has no note -- and `IN (NULL)` is
// never true, so the null arm is ORed on as buildWhere does
const list = Array.isArray(val) ? val : [val];
const nulls = list.some(v => v === null);
const vals = list.filter(v => v !== null).map(v => `'${esc(v)}'`);
const expr = COMPUTED_SLICE_COLS[col];
const inList = vals.length ? `(${expr}) IN (${vals.join(', ')})` : null;
const test = nulls && inList ? `((${expr}) IS NULL OR ${inList})`
: nulls ? `(${expr}) IS NULL`
: inList;
return `pf_logid IN ( return `pf_logid IN (
SELECT l.id SELECT l.id
FROM pf.log l FROM pf.log l
WHERE l.version_id = ${parseInt(versionId)} WHERE l.version_id = ${parseInt(versionId)}
AND ${COMPUTED_SLICE_COLS[col]} IN (${vals}) AND ${test}
)`; )`;
} }
@ -571,15 +598,13 @@ function buildWhere(slice, dimCols, versionId) {
} }
// A pf_ key this does not understand is refused rather than skipped. // A pf_ key this does not understand is refused rather than skipped.
// Skipping is how a selection silently widened: the operation ran against // Skipping is how a selection silently widened: the operation ran against
// everything the dropped key would have excluded. pf_iter is the one // everything the dropped key would have excluded.
// exception -- the client strips it deliberately, since two cells that if (col.startsWith('pf_') && !TABLE_SLICE_COLS.has(col)) {
// differ only by iter band are the same slice.
if (col.startsWith('pf_') && col !== 'pf_iter') {
const err = new Error(`Slice names ${col}, which cannot be filtered on`); const err = new Error(`Slice names ${col}, which cannot be filtered on`);
err.status = 400; err.status = 400;
throw err; throw err;
} }
if (!allowed.has(col)) continue; if (!allowed.has(col) && !TABLE_SLICE_COLS.has(col)) continue;
// A blank dimension value is a value. Both blanks exist in this data and // A blank dimension value is a value. Both blanks exist in this data and
// they are different rows -- on fc_osm_skinny_34, partgroup is null on // they are different rows -- on fc_osm_skinny_34, partgroup is null on
// 51,964 and '' on 111,696 -- so a null has to become IS NULL. It used // 51,964 and '' on 111,696 -- so a null has to become IS NULL. It used
@ -821,6 +846,6 @@ function esc(val) {
return String(val).replace(/'/g, "''"); return String(val).replace(/'/g, "''");
} }
module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, module.exports = { generateSQL, grainOf, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS, SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, LABEL_GROUP_COLS,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc }; ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };

View File

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow'); const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc, const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, TABLE_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator'); ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth'); const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
@ -133,7 +133,10 @@ module.exports = function(pool) {
// An empty slice passes only under a scope (see normalizeSlices), which // An empty slice passes only under a scope (see normalizeSlices), which
// buildScopeClause refuses to let reduce to nothing. // buildScopeClause refuses to let reduce to nothing.
function assertSelective(slices, ctx, scope) { function assertSelective(slices, ctx, scope) {
const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]); // pf_iter, pf_note, pf_op, pf_user and pf_logid narrow a slice once it
// is selective, but do not make it so: they were only just accepted at
// all, and `pf_iter = scale` alone is every adjustment in the version
const allowed = new Set([...ctx.filterCols, 'pf_segment', 'pf_bucket']);
const scoped = Array.isArray(scope) && scope.length > 0; const scoped = Array.isArray(scope) && scope.length > 0;
slices.forEach((sl, i) => { slices.forEach((sl, i) => {
if (scoped && Object.keys(sl).length === 0) return; if (scoped && Object.keys(sl).length === 0) return;
@ -218,7 +221,11 @@ module.exports = function(pool) {
// echo back what the caller asked for, for the audit log // echo back what the caller asked for, for the audit log
function pickIntent(body) { function pickIntent(body) {
const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct', const keys = ['mode', 'target_basis', 'value_incr', 'units_incr', 'value_pct', 'units_pct', 'pct',
'target_value', 'target_units', 'target_price', 'scope']; 'target_value', 'target_units', 'target_price', 'scope', 'plug',
// What the panel's Result line promised, kept beside
// `resolved` so a disagreement between the two can be
// found by reading the log. Not checked -- yet.
'expected'];
const out = {}; const out = {};
for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k]; for (const k of keys) if (body[k] !== undefined && body[k] !== null && body[k] !== '') out[k] = body[k];
return out; return out;

View File

@ -583,6 +583,8 @@ function TagInput({ value, onChange, tags = [], className }) {
// here -- so flipping the plug brings it straight back. Forecast.jsx builds // here -- so flipping the plug brings it straight back. Forecast.jsx builds
// the request from the same function, so the panel and the write agree. // the request from the same function, so the panel and the write agree.
// Returns the inputs that count, and whether the plug is the one choosing. // Returns the inputs that count, and whether the plug is the one choosing.
const round2 = (x) => Math.round(x * 100) / 100
const hasNumber = (edit) => edit?.raw != null && edit.raw !== '' && isFinite(parseFloat(edit.raw)) const hasNumber = (edit) => edit?.raw != null && edit.raw !== '' && isFinite(parseFloat(edit.raw))
export function effectiveScaleInputs(inputs = {}, plug = 'volume') { export function effectiveScaleInputs(inputs = {}, plug = 'volume') {
@ -601,7 +603,7 @@ export function effectiveScaleInputs(inputs = {}, plug = 'volume') {
let editSeq = 0 let editSeq = 0
const stamp = () => ++editSeq const stamp = () => ++editSeq
function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) { function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, scaleExpectedRef, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
// everything below reads the inputs that count; only the setters touch the raw ones // everything below reads the inputs that count; only the setters touch the raw ones
const { inputs: scaleInputs, choosing: plugChoosing } = effectiveScaleInputs(rawInputs, scalePlug) const { inputs: scaleInputs, choosing: plugChoosing } = effectiveScaleInputs(rawInputs, scalePlug)
const valueCol = currentTotals?.valueCol const valueCol = currentTotals?.valueCol
@ -822,6 +824,20 @@ function ScaleLedger({ currentTotals, scaleInputs: rawInputs, setScaleInputs, sc
} }
})() })()
// The change the Result line promises, handed to the request as `expected`.
// Measured on the same basis as the ledger, so it compares directly with
// `resolved` in the log entry. Under 'each' it is the combined figure across
// the slices, which is what the panel shows -- not a per-slice amount.
if (scaleExpectedRef) {
scaleExpectedRef.current = outcome ? {
value_incr: round2(outcome.value - (basisOf('value') ?? 0)),
units_incr: round2(outcome.units - (basisOf('units') ?? 0)),
price: outcome.price == null ? null : Math.round(outcome.price * 1e6) / 1e6,
basis: onTotal ? 'selected' : 'adjustable',
...(perSlice ? { combined_across_slices: true } : {}),
} : null
}
// The same three readings for a measure nobody typed into, taken from where // The same three readings for a measure nobody typed into, taken from where
// the edit actually lands it. Scaling dollars with price holding moves qty, // the edit actually lands it. Scaling dollars with price holding moves qty,
// and leaving that column blank until the Result row meant the number you had // and leaving that column blank until the Result row meant the number you had
@ -1464,7 +1480,7 @@ export default function OperationPanel({
applyMode, setApplyMode, applyMode, setApplyMode,
currentTotals, currentTotals,
activeOp, setActiveOp, activeOp, setActiveOp,
scaleInputs, setScaleInputs, scaleInputs, setScaleInputs, scaleExpectedRef,
scalePlug, setScalePlug, scalePlug, setScalePlug,
targetBasis, setTargetBasis, targetBasis, setTargetBasis,
opTag, setOpTag, knownTags = [], logMeta = {}, opTag, setOpTag, knownTags = [], logMeta = {},
@ -1520,7 +1536,7 @@ export default function OperationPanel({
{activeOp === 'scale' && ( {activeOp === 'scale' && (
<ScaleLedger <ScaleLedger
currentTotals={currentTotals} currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs} scaleInputs={scaleInputs} setScaleInputs={setScaleInputs} scaleExpectedRef={scaleExpectedRef}
scalePlug={scalePlug} setScalePlug={setScalePlug} scalePlug={scalePlug} setScalePlug={setScalePlug}
targetBasis={targetBasis} setTargetBasis={setTargetBasis} targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta} logMeta={logMeta}

View File

@ -15,10 +15,17 @@ import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid' import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes' import '@perspective-dev/viewer/themes'
// Slice keys that are not col_meta columns: computed from pf.log when the rows are // Slice keys that are not col_meta columns but are columns of the loaded table,
// served, so they are real columns in the loaded table but have to be resolved back // so a click on them narrows the ledger -- and therefore has to narrow the write.
// to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js. // pf_segment / pf_bucket / pf_note / pf_op come from pf.log and are resolved back
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket']) // to log ids server-side; pf_iter / pf_user / pf_logid are forecast-table columns.
// Mirrors COMPUTED_SLICE_COLS and TABLE_SLICE_COLS in lib/sql_generator.js.
//
// Only pf_segment and pf_bucket used to be kept. The rest were stripped, while
// the ledger still filtered on them: a click on a customer's `scale` row under
// the default layout (grouped by pf_iter) totalled the scale rows and wrote to
// every row the customer has.
const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket', 'pf_note', 'pf_op', 'pf_iter', 'pf_user', 'pf_logid'])
// The unnamed last-used config stays in localStorage: it is per-browser session // The unnamed last-used config stays in localStorage: it is per-browser session
// continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named // continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named
@ -156,6 +163,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// you type in decides how the number is interpreted, and the other two rows of // you type in decides how the number is interpreted, and the other two rows of
// that measure are derived from it. // that measure are derived from it.
const [scaleInputs, setScaleInputs] = useState({}) const [scaleInputs, setScaleInputs] = useState({})
// What the panel's Result line says the scale will change, written there on
// each render and sent as `expected` so the log keeps it next to what the
// server resolved. Nothing checks the two against each other yet; it is
// there so a disagreement can be found by reading the log.
const scaleExpectedRef = useRef(null)
// which side of price x volume absorbs a dollar-only change: 'price' keeps // which side of price x volume absorbs a dollar-only change: 'price' keeps
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's // volume flat, 'volume' keeps price flat. Mirrors the Excel form's
// Plug Price / Plug Volume choice. // Plug Price / Plug Volume choice.
@ -1965,7 +1977,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const viewFilter = viewFilterRef.current || [] const viewFilter = viewFilterRef.current || []
if (!slices.length) return null if (!slices.length) return null
// Two clicked cells can differ only by a column the operation cannot filter on // Two clicked cells can differ only by a column the operation cannot filter on
// (pf_iter, say, which is not in col_meta and so is dropped here). Those become // (a computed split_by column, say, which is dropped here). Those become
// the same effective slice, and sending it twice would apply the change twice // the same effective slice, and sending it twice would apply the change twice
// under apply_mode 'each'. Collapse duplicates before they reach the API. // under apply_mode 'each'. Collapse duplicates before they reach the API.
const seen = new Set() const seen = new Set()
@ -2029,6 +2041,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// sent when dollars are the sole input; naming units or price has already // sent when dollars are the sole input; naming units or price has already
// answered the question. // answered the question.
if (vn != null && un == null && pn == null) body.plug = scalePlug if (vn != null && un == null && pn == null) body.plug = scalePlug
if (scaleExpectedRef.current) body.expected = scaleExpectedRef.current
} else if (op === 'recode') { } else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim())) const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set } body = { ...body, note: recodeNote || undefined, set }
@ -2154,7 +2167,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
applyMode, setApplyMode, applyMode, setApplyMode,
currentTotals, currentTotals,
activeOp, setActiveOp, activeOp, setActiveOp,
scaleInputs, setScaleInputs, scaleInputs, setScaleInputs, scaleExpectedRef,
scalePlug, setScalePlug, scalePlug, setScalePlug,
targetBasis, setTargetBasis, targetBasis, setTargetBasis,
opTag, setOpTag, knownTags, logMeta, opTag, setOpTag, knownTags, logMeta,