@reekon-tools/boldr-utils 1.9.6 → 1.10.1

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.
@@ -0,0 +1,81 @@
1
+ import type { Conversion, ConversionColumn, ConversionTableColumnData } from '../types/firestore.js';
2
+ import { type FieldDimension } from './units.js';
3
+ /**
4
+ * Id of the implicit first column. Stable and reserved: a table that has never
5
+ * been given explicit columns is addressed by this id, so equations written
6
+ * against a single-column table keep resolving after columns are added (the
7
+ * editor preserves it as `columns[0].id`).
8
+ */
9
+ export declare const DEFAULT_CONVERSION_COLUMN_ID = "value";
10
+ /** Name shown for the implicit first column. */
11
+ export declare const DEFAULT_CONVERSION_COLUMN_NAME = "Value";
12
+ /**
13
+ * A table's value columns, always at least one. An absent or empty `columns`
14
+ * normalizes to the single implicit column, so callers never branch on shape.
15
+ */
16
+ export declare const conversionColumns: (data: Pick<ConversionTableColumnData, "columns">) => ConversionColumn[];
17
+ /** True when equations must name a column to be unambiguous. */
18
+ export declare const hasMultipleConversionColumns: (data: Pick<ConversionTableColumnData, "columns">) => boolean;
19
+ /**
20
+ * The column an equation variable binds to. An absent/unknown `columnId` is the
21
+ * first column: that is what a binding written before multi-column tables
22
+ * meant, and the only reading that keeps such equations computing.
23
+ */
24
+ export declare const resolveConversionColumn: (data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => ConversionColumn;
25
+ export declare const findConversionColumn: (data: Pick<ConversionTableColumnData, "columns">, columnId: string) => ConversionColumn | undefined;
26
+ /** Raw cell text for one row/column, or '' when the cell was never filled. */
27
+ export declare const conversionCellValue: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId: string) => string;
28
+ /**
29
+ * `conversion` with one cell replaced, honouring the column-0-in-`value` rule.
30
+ * Returns a new object; `values` is omitted entirely when it would be empty so
31
+ * Firestore never receives a stray `{}`.
32
+ */
33
+ export declare const writeConversionCell: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId: string, cell: string) => Conversion;
34
+ /** The dimension a column's values carry — 'none' for an unannotated column. */
35
+ export declare const conversionColumnDimension: (column: ConversionColumn) => FieldDimension;
36
+ /**
37
+ * The CANONICAL number a cell contributes to an equation, or null when the
38
+ * cell is blank or unparseable. Unit-less columns pass their value through
39
+ * unchanged (the historical meaning of a conversion factor); a column with a
40
+ * unit converts, so downstream code can treat it like any measurement value.
41
+ */
42
+ export declare const conversionCellNumber: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId?: string) => number | null;
43
+ /** The row a selected label refers to. */
44
+ export declare const findConversionRow: (data: Pick<ConversionTableColumnData, "conversions">, label: string) => Conversion | undefined;
45
+ /**
46
+ * How a column is named in a chip, a picker or a validation message. Falls back
47
+ * to the positional `[i]` form the column is otherwise anonymous — an unnamed
48
+ * column still has to be tellable apart from its neighbours.
49
+ */
50
+ export declare const conversionColumnLabel: (data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => string;
51
+ /**
52
+ * Qualified name for a binding — what the formula builder puts on a chip and
53
+ * what validation quotes back. Single-column tables read as the bare field
54
+ * name, so nothing changes for the tables that already exist.
55
+ */
56
+ export declare const conversionBindingLabel: (fieldName: string, data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => string;
57
+ export interface ConversionRowDraft {
58
+ label: string;
59
+ /** Cell text per column id. Missing entries are treated as blank. */
60
+ cells: Record<string, string>;
61
+ }
62
+ /**
63
+ * Serialize an editor's (columns, rows) working state into storage shape.
64
+ * The single place the column-0 split is applied on write, and the reason the
65
+ * editor can add, rename, unit-annotate, reorder and delete columns without
66
+ * ever reasoning about which key a cell belongs in.
67
+ *
68
+ * A table whose only column is the plain implicit one is written WITHOUT a
69
+ * `columns` key, so it stays byte-identical to what the pre-multi-column editor
70
+ * produced — no schema churn on every table that doesn't use the feature. A
71
+ * single column that has been named or given a unit IS persisted: dropping it
72
+ * would silently discard the annotation.
73
+ */
74
+ export declare const buildConversionTableColumnData: (columns: ConversionColumn[], rows: ConversionRowDraft[]) => ConversionTableColumnData;
75
+ /** Storage shape back to editor working state. */
76
+ export declare const toConversionRowDrafts: (data: ConversionTableColumnData) => ConversionRowDraft[];
77
+ /** Convenience for pickers: every column's cell text for one row. */
78
+ export declare const conversionRowCells: (data: ConversionTableColumnData, conversion: Conversion) => {
79
+ column: ConversionColumn;
80
+ value: string;
81
+ }[];
@@ -0,0 +1,170 @@
1
+ import { toCanonical, unitDimension, } from './units.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Multi-column conversion tables.
4
+ //
5
+ // A conversion table is a labelled lookup: the operator picks a ROW ("2x4",
6
+ // "30-year architectural") and the calculator gets a number. With more than one
7
+ // VALUE COLUMN, one selection carries several numbers — nominal vs. actual
8
+ // dimensions, weight and price per unit, rise and run — and an equation binds
9
+ // to a specific (field, column) pair rather than just the field.
10
+ //
11
+ // STORAGE (see types/firestore.ts): column 0's cell text lives in
12
+ // `Conversion.value`, columns 1..n in `Conversion.values[columnId]`. That split
13
+ // is not tidiness — `value` is the only key readers that predate this feature
14
+ // know about (job grids, label tiles, template formulas), so keeping column 0
15
+ // there means a table authored in a calculator still reads correctly
16
+ // everywhere else. Nothing outside this module should apply the rule by hand:
17
+ // go through `conversionCellValue` / `writeConversionCell`.
18
+ //
19
+ // UNITS: a column may declare the unit its cell text is written in. Cells then
20
+ // convert to canonical (µm/µm²/µm³/deg) on the way into an equation, so a
21
+ // column of inch values composes with measurement fields and with the unit
22
+ // inference in expressionUnits.ts. A column with no unit stays a plain scalar,
23
+ // which is exactly what a single-column table has always been.
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Id of the implicit first column. Stable and reserved: a table that has never
27
+ * been given explicit columns is addressed by this id, so equations written
28
+ * against a single-column table keep resolving after columns are added (the
29
+ * editor preserves it as `columns[0].id`).
30
+ */
31
+ export const DEFAULT_CONVERSION_COLUMN_ID = 'value';
32
+ /** Name shown for the implicit first column. */
33
+ export const DEFAULT_CONVERSION_COLUMN_NAME = 'Value';
34
+ const IMPLICIT_COLUMN = {
35
+ id: DEFAULT_CONVERSION_COLUMN_ID,
36
+ name: DEFAULT_CONVERSION_COLUMN_NAME,
37
+ };
38
+ /**
39
+ * A table's value columns, always at least one. An absent or empty `columns`
40
+ * normalizes to the single implicit column, so callers never branch on shape.
41
+ */
42
+ export const conversionColumns = (data) => data.columns && data.columns.length > 0 ? data.columns : [IMPLICIT_COLUMN];
43
+ /** True when equations must name a column to be unambiguous. */
44
+ export const hasMultipleConversionColumns = (data) => conversionColumns(data).length > 1;
45
+ /**
46
+ * The column an equation variable binds to. An absent/unknown `columnId` is the
47
+ * first column: that is what a binding written before multi-column tables
48
+ * meant, and the only reading that keeps such equations computing.
49
+ */
50
+ export const resolveConversionColumn = (data, columnId) => {
51
+ const columns = conversionColumns(data);
52
+ if (columnId == null)
53
+ return columns[0];
54
+ return columns.find((c) => c.id === columnId) ?? columns[0];
55
+ };
56
+ export const findConversionColumn = (data, columnId) => conversionColumns(data).find((c) => c.id === columnId);
57
+ /** Raw cell text for one row/column, or '' when the cell was never filled. */
58
+ export const conversionCellValue = (data, conversion, columnId) => {
59
+ const columns = conversionColumns(data);
60
+ return columnId === columns[0].id
61
+ ? conversion.value
62
+ : (conversion.values?.[columnId] ?? '');
63
+ };
64
+ /**
65
+ * `conversion` with one cell replaced, honouring the column-0-in-`value` rule.
66
+ * Returns a new object; `values` is omitted entirely when it would be empty so
67
+ * Firestore never receives a stray `{}`.
68
+ */
69
+ export const writeConversionCell = (data, conversion, columnId, cell) => {
70
+ const columns = conversionColumns(data);
71
+ if (columnId === columns[0].id)
72
+ return { ...conversion, value: cell };
73
+ const values = { ...conversion.values, [columnId]: cell };
74
+ return { ...conversion, values };
75
+ };
76
+ /** The dimension a column's values carry — 'none' for an unannotated column. */
77
+ export const conversionColumnDimension = (column) => column.unit != null ? unitDimension(column.unit) : 'none';
78
+ /**
79
+ * The CANONICAL number a cell contributes to an equation, or null when the
80
+ * cell is blank or unparseable. Unit-less columns pass their value through
81
+ * unchanged (the historical meaning of a conversion factor); a column with a
82
+ * unit converts, so downstream code can treat it like any measurement value.
83
+ */
84
+ export const conversionCellNumber = (data, conversion, columnId) => {
85
+ const column = resolveConversionColumn(data, columnId);
86
+ const parsed = parseFloat(conversionCellValue(data, conversion, column.id));
87
+ if (!Number.isFinite(parsed))
88
+ return null;
89
+ return column.unit != null ? toCanonical(parsed, column.unit) : parsed;
90
+ };
91
+ /** The row a selected label refers to. */
92
+ export const findConversionRow = (data, label) => data.conversions.find((c) => c.label === label);
93
+ /**
94
+ * How a column is named in a chip, a picker or a validation message. Falls back
95
+ * to the positional `[i]` form the column is otherwise anonymous — an unnamed
96
+ * column still has to be tellable apart from its neighbours.
97
+ */
98
+ export const conversionColumnLabel = (data, columnId) => {
99
+ const columns = conversionColumns(data);
100
+ // An unknown id resolves to the first column, matching resolveConversionColumn
101
+ // — the label must name the column the equation will actually read.
102
+ const found = columnId == null ? -1 : columns.findIndex((c) => c.id === columnId);
103
+ const index = found < 0 ? 0 : found;
104
+ return columns[index].name.trim() !== '' ? columns[index].name : `[${index}]`;
105
+ };
106
+ /**
107
+ * Qualified name for a binding — what the formula builder puts on a chip and
108
+ * what validation quotes back. Single-column tables read as the bare field
109
+ * name, so nothing changes for the tables that already exist.
110
+ */
111
+ export const conversionBindingLabel = (fieldName, data, columnId) => hasMultipleConversionColumns(data)
112
+ ? `${fieldName} · ${conversionColumnLabel(data, columnId)}`
113
+ : fieldName;
114
+ /**
115
+ * True when a column carries nothing the implicit column doesn't already imply,
116
+ * so persisting it would only add a key. A named or unit-annotated column is
117
+ * NOT implicit even when it's the only one — that annotation is the difference
118
+ * between a table of inch values and a table of bare factors.
119
+ */
120
+ const isImplicitColumn = (column) => column.id === DEFAULT_CONVERSION_COLUMN_ID &&
121
+ column.unit == null &&
122
+ (column.name.trim() === '' || column.name === DEFAULT_CONVERSION_COLUMN_NAME);
123
+ /**
124
+ * Serialize an editor's (columns, rows) working state into storage shape.
125
+ * The single place the column-0 split is applied on write, and the reason the
126
+ * editor can add, rename, unit-annotate, reorder and delete columns without
127
+ * ever reasoning about which key a cell belongs in.
128
+ *
129
+ * A table whose only column is the plain implicit one is written WITHOUT a
130
+ * `columns` key, so it stays byte-identical to what the pre-multi-column editor
131
+ * produced — no schema churn on every table that doesn't use the feature. A
132
+ * single column that has been named or given a unit IS persisted: dropping it
133
+ * would silently discard the annotation.
134
+ */
135
+ export const buildConversionTableColumnData = (columns, rows) => {
136
+ const effective = columns.length > 0 ? columns : [IMPLICIT_COLUMN];
137
+ const [first, ...rest] = effective;
138
+ const conversions = rows.map((row) => {
139
+ const values = {};
140
+ for (const column of rest) {
141
+ const cell = row.cells[column.id];
142
+ if (cell != null && cell !== '')
143
+ values[column.id] = cell;
144
+ }
145
+ return {
146
+ label: row.label,
147
+ value: row.cells[first.id] ?? '',
148
+ ...(Object.keys(values).length > 0 ? { values } : {}),
149
+ };
150
+ });
151
+ return effective.length === 1 && isImplicitColumn(effective[0])
152
+ ? { conversions }
153
+ : { conversions, columns: effective };
154
+ };
155
+ /** Storage shape back to editor working state. */
156
+ export const toConversionRowDrafts = (data) => {
157
+ const columns = conversionColumns(data);
158
+ return data.conversions.map((conversion) => ({
159
+ label: conversion.label,
160
+ cells: Object.fromEntries(columns.map((column) => [
161
+ column.id,
162
+ conversionCellValue(data, conversion, column.id),
163
+ ])),
164
+ }));
165
+ };
166
+ /** Convenience for pickers: every column's cell text for one row. */
167
+ export const conversionRowCells = (data, conversion) => conversionColumns(data).map((column) => ({
168
+ column,
169
+ value: conversionCellValue(data, conversion, column.id),
170
+ }));
@@ -16,8 +16,12 @@ export declare const evaluateExpression: (expression: string, scope: Record<stri
16
16
  * The canonical numeric value a field contributes to an equation scope, or
17
17
  * null when the field has no usable value. Select and instructions fields are
18
18
  * non-computational (validation rejects mapping them into equations).
19
+ *
20
+ * `columnId` selects which value column a conversion-table field yields; it is
21
+ * ignored for every other kind. Omitted means the first column, so a caller
22
+ * that predates multi-column tables reads exactly what it used to.
19
23
  */
20
- export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined) => number | null;
24
+ export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined, columnId?: string) => number | null;
21
25
  export type EquationResult = {
22
26
  ok: true;
23
27
  value: number;
@@ -1,7 +1,9 @@
1
1
  import { create, all } from 'mathjs';
2
2
  import { ColumnType } from '../types/firestore.js';
3
- import { equationForField, findField } from './schema.js';
4
- import { fromCanonical, toCanonical } from './units.js';
3
+ import { bindingUnitForBase, equationForField, fieldDimension, findField, } from './schema.js';
4
+ import { conversionCellNumber, findConversionRow } from './conversionTable.js';
5
+ import { deriveResultUnitForBase } from './expressionUnits.js';
6
+ import { fromCanonical, isEquationBase, toCanonical, } from './units.js';
5
7
  // Trig in calculator equations works in DEGREES: angle fields are stored in
6
8
  // degrees canonically, and construction authors write `H = W * tan(A)`
7
9
  // expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
@@ -77,8 +79,12 @@ export const evaluateExpression = (expression, scope) => {
77
79
  * The canonical numeric value a field contributes to an equation scope, or
78
80
  * null when the field has no usable value. Select and instructions fields are
79
81
  * non-computational (validation rejects mapping them into equations).
82
+ *
83
+ * `columnId` selects which value column a conversion-table field yields; it is
84
+ * ignored for every other kind. Omitted means the first column, so a caller
85
+ * that predates multi-column tables reads exactly what it used to.
80
86
  */
81
- export const numericFieldValue = (field, value) => {
87
+ export const numericFieldValue = (field, value, columnId) => {
82
88
  switch (field.kind) {
83
89
  case ColumnType.Number:
84
90
  case ColumnType.Measurement:
@@ -87,21 +93,61 @@ export const numericFieldValue = (field, value) => {
87
93
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
88
94
  }
89
95
  case ColumnType.ConversionTable: {
90
- // Value is the selected conversion's label; the numeric factor comes
91
- // from the matched entry (Conversion.value is a string app-wide).
96
+ // The stored value is the selected ROW's label; the number comes from
97
+ // that row's cell in the requested column (cells are strings app-wide,
98
+ // and a unit-annotated column converts to canonical on the way out).
92
99
  const label = value ?? field.defaultValue;
93
100
  if (typeof label !== 'string')
94
101
  return null;
95
- const entry = field.columnData.conversions.find((c) => c.label === label);
96
- if (!entry)
102
+ const row = findConversionRow(field.columnData, label);
103
+ if (!row)
97
104
  return null;
98
- const parsed = parseFloat(entry.value);
99
- return Number.isFinite(parsed) ? parsed : null;
105
+ return conversionCellNumber(field.columnData, row, columnId);
100
106
  }
101
107
  default:
102
108
  return null;
103
109
  }
104
110
  };
111
+ // ---------------------------------------------------------------------------
112
+ // Scope units.
113
+ //
114
+ // A base-anchored equation derives every unit from its base rather than
115
+ // reading `variableUnits` / `resultUnit` off the document. Those fields are
116
+ // still written (older app builds evaluate from them), but they are a cache of
117
+ // this derivation — and trusting a cache over its source is exactly how a
118
+ // stale entry silently rescales a live answer. So the evaluator recomputes.
119
+ //
120
+ // Equations with no base keep the legacy reading, byte for byte: saved
121
+ // calculator instances freeze their definition, so pre-base equations must go
122
+ // on evaluating exactly as they did the day they were saved.
123
+ // ---------------------------------------------------------------------------
124
+ /** The unit one variable's canonical value is re-expressed in before scoping. */
125
+ const scopeUnitFor = (definition, equation, variable, fieldId) => {
126
+ if (!isEquationBase(equation.base)) {
127
+ return equation.variableUnits?.[variable] ?? null;
128
+ }
129
+ const field = findField(definition, fieldId);
130
+ if (!field)
131
+ return null;
132
+ return bindingUnitForBase(equation.base, field, equation.variableColumnIds?.[variable]);
133
+ };
134
+ /** The unit the expression's raw result carries, or null when it needs none. */
135
+ const resultUnitFor = (definition, equation) => {
136
+ if (!isEquationBase(equation.base))
137
+ return equation.resultUnit ?? null;
138
+ const target = findField(definition, equation.targetFieldId);
139
+ // A number target takes the raw value — the whole point of evaluating in
140
+ // display units is that `area_in_ft² / 33` yields squares, not µm².
141
+ if (!target || fieldDimension(target) === 'none')
142
+ return null;
143
+ const derived = deriveResultUnitForBase(equation.base, definition.fields, equation);
144
+ if (derived.kind === 'unit')
145
+ return derived.unit;
146
+ if (derived.kind === 'none')
147
+ return null;
148
+ // Inference declined; the author's stored unit is the only information left.
149
+ return equation.resultUnit ?? null;
150
+ };
105
151
  const evaluateEquationInContext = (equation, ctx) => {
106
152
  const cached = ctx.cache.get(equation.id);
107
153
  if (cached)
@@ -118,7 +164,7 @@ const evaluateEquationInContext = (equation, ctx) => {
118
164
  const scope = {};
119
165
  const missing = [];
120
166
  for (const [variable, fieldId] of Object.entries(equation.variableToFieldId)) {
121
- const resolved = resolveFieldNumeric(fieldId, ctx);
167
+ const resolved = resolveFieldNumeric(fieldId, ctx, equation.variableColumnIds?.[variable]);
122
168
  if (!resolved.ok) {
123
169
  if (resolved.reason === 'missing-inputs') {
124
170
  missing.push(...resolved.missingFieldIds);
@@ -126,9 +172,9 @@ const evaluateEquationInContext = (equation, ctx) => {
126
172
  }
127
173
  return resolved;
128
174
  }
129
- // Field values resolve canonically; a variable unit re-expresses the
130
- // value in the author's chosen display unit before it enters the scope.
131
- const unit = equation.variableUnits?.[variable];
175
+ // Field values resolve canonically; the scope unit re-expresses each one
176
+ // in the equation's own terms before it enters the expression.
177
+ const unit = scopeUnitFor(ctx.definition, equation, variable, fieldId);
132
178
  try {
133
179
  scope[variable] =
134
180
  unit != null ? fromCanonical(resolved.value, unit) : resolved.value;
@@ -158,11 +204,12 @@ const evaluateEquationInContext = (equation, ctx) => {
158
204
  else {
159
205
  // A result unit means the expression produced a display-unit value;
160
206
  // convert back so the returned value is canonical like everything else.
207
+ const resultUnit = resultUnitFor(ctx.definition, equation);
161
208
  try {
162
209
  result = {
163
210
  ok: true,
164
- value: equation.resultUnit != null
165
- ? toCanonical(evaluated.value, equation.resultUnit)
211
+ value: resultUnit != null
212
+ ? toCanonical(evaluated.value, resultUnit)
166
213
  : evaluated.value,
167
214
  };
168
215
  }
@@ -185,7 +232,9 @@ const evaluateEquationInContext = (equation, ctx) => {
185
232
  // computation is blocked on missing inputs but the user supplied a value for
186
233
  // the output directly (the two-way-solving posture), the supplied value is
187
234
  // used instead. Everything else resolves from `values`/defaults.
188
- const resolveFieldNumeric = (fieldId, ctx) => {
235
+ const resolveFieldNumeric = (fieldId, ctx,
236
+ /** Conversion-table column the referencing variable binds to. */
237
+ columnId) => {
189
238
  const field = findField(ctx.definition, fieldId);
190
239
  if (!field) {
191
240
  return {
@@ -200,13 +249,13 @@ const resolveFieldNumeric = (fieldId, ctx) => {
200
249
  if (computed.ok)
201
250
  return computed;
202
251
  if (computed.reason === 'missing-inputs') {
203
- const supplied = numericFieldValue(field, ctx.values[fieldId]);
252
+ const supplied = numericFieldValue(field, ctx.values[fieldId], columnId);
204
253
  if (supplied != null)
205
254
  return { ok: true, value: supplied };
206
255
  }
207
256
  return computed;
208
257
  }
209
- const value = numericFieldValue(field, ctx.values[fieldId]);
258
+ const value = numericFieldValue(field, ctx.values[fieldId], columnId);
210
259
  if (value == null) {
211
260
  return { ok: false, reason: 'missing-inputs', missingFieldIds: [fieldId] };
212
261
  }
@@ -1,6 +1,6 @@
1
1
  import { type MathNode } from 'mathjs';
2
2
  import type { CalculatorEquation, CalculatorField } from './schema.js';
3
- import { type CalculatorUnit, type FieldDimension } from './units.js';
3
+ import { type CalculatorUnit, type EquationBase, type FieldDimension } from './units.js';
4
4
  /** Constants mathjs resolves without a scope entry. */
5
5
  export declare const MATHJS_CONSTANTS: Set<string>;
6
6
  export type ParseResult = {
@@ -39,6 +39,20 @@ export interface InferredExpressionUnit {
39
39
  unit: CalculatorUnit | null;
40
40
  /** Distinct base units among the variables carrying that dimension. */
41
41
  bases: string[];
42
+ /**
43
+ * Every distinct length-family base the variables contributed, whether or
44
+ * not the exponents survived to the result.
45
+ *
46
+ * Separate from `bases` because `bases` describes the RESULT, and a result
47
+ * can be dimensionless while its inputs still disagree: `(A_in * B_in) /
48
+ * C_ft²` cancels to a plain count, so `bases` is empty and `dimension` is
49
+ * 'none' — and the 144× scale error that ratio carries is invisible there.
50
+ * Tracked per axis, since a length and an angle in one expression is normal
51
+ * (trig consumes the angle) while two length bases never is.
52
+ */
53
+ lengthBases: string[];
54
+ /** Every distinct angle base the variables contributed ('deg' / 'rad'). */
55
+ angleBases: string[];
42
56
  }
43
57
  export type ExpressionUnitInference = ({
44
58
  ok: true;
@@ -50,7 +64,7 @@ export type ExpressionUnitInference = ({
50
64
  * The dimension and unit of an equation's raw expression value — what the
51
65
  * number means BEFORE `resultUnit` is applied.
52
66
  */
53
- export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
67
+ export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits">) => ExpressionUnitInference;
54
68
  /**
55
69
  * True when an expression's raw value is already canonical, so omitting
56
70
  * `resultUnit` is correct rather than a 25400× mistake. Only the
@@ -70,13 +84,94 @@ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: Calculat
70
84
  * (dimensionless or already-canonical result) or cannot be derived. An
71
85
  * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
72
86
  */
73
- export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
87
+ export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
88
+ /** The scope unit for every variable of a base-anchored equation. */
89
+ export declare const deriveVariableUnits: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => Record<string, CalculatorUnit>;
90
+ export type DerivedResultUnit =
91
+ /** The raw value carries this unit and must be converted back through it. */
92
+ {
93
+ kind: 'unit';
94
+ unit: CalculatorUnit;
95
+ }
96
+ /**
97
+ * Inference succeeded and the raw value needs NO conversion: dimensionless,
98
+ * or an angle (degrees are already canonical).
99
+ */
100
+ | {
101
+ kind: 'none';
102
+ }
103
+ /**
104
+ * Inference declined — an unmodelled function, a non-integer power, a
105
+ * dimension this vocabulary can't name. The base says nothing useful here,
106
+ * so callers fall back to whatever unit the equation already carried. This
107
+ * is the escape hatch the authoring UI's manual result-unit picker fills.
108
+ */
109
+ | {
110
+ kind: 'unknown';
111
+ };
112
+ /** The unit a base-anchored expression's raw value carries. */
113
+ export declare const deriveResultUnitForBase: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds">) => DerivedResultUnit;
114
+ /** Where a migrated equation's base came from — reported by the migration. */
115
+ export type EquationBaseSource =
116
+ /** A bound conversion column's declared unit. */
117
+ 'conversion-column'
118
+ /** An existing authored `variableUnits` entry. */
119
+ | 'variable-unit'
120
+ /** A bound measurement field's display unit. */
121
+ | 'field-default'
122
+ /** Nothing length-valued to anchor to; the base is inert. */
123
+ | 'fallback'
124
+ /** A variable is in a unit family no base can express — see `blockedBy`. */
125
+ | 'unanchorable';
126
+ export interface DerivedEquationBase {
127
+ /** Null when the equation cannot be anchored without changing its meaning. */
128
+ base: EquationBase | null;
129
+ source: EquationBaseSource;
130
+ /**
131
+ * Distinct bases seen at the winning tier. More than one means the source
132
+ * disagreed with itself and `base` is the most common of them — the case a
133
+ * migration should surface rather than apply silently.
134
+ */
135
+ candidates: EquationBase[];
136
+ /**
137
+ * Units that blocked anchoring: cubic yards, liters, gallons — real units a
138
+ * field may hold, but outside the five families a base can name.
139
+ */
140
+ blockedBy?: CalculatorUnit[];
141
+ }
142
+ /** Inert default when an equation has no length-valued binding at all. */
143
+ export declare const DEFAULT_EQUATION_BASE: EquationBase;
144
+ /**
145
+ * The base a pre-`base` equation should adopt, in descending order of what the
146
+ * source can be trusted to mean:
147
+ *
148
+ * 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
149
+ * a human typed; its unit is the only thing that says what the number is,
150
+ * so it is the one declaration in the equation that cannot be re-expressed
151
+ * without reinterpreting authored data.
152
+ * 2. An existing `variableUnits` entry — the author's own stated intent,
153
+ * even if it disagreed with its neighbours.
154
+ * 3. A bound measurement field's display unit. Values are stored canonically,
155
+ * so this only reflects a preference, but it is the author's preference.
156
+ * 4. Nothing length-valued in the equation, so the base cannot matter.
157
+ *
158
+ * Declines entirely (`base: null`) when a variable is ALREADY authored in a
159
+ * unit no base can name — cubic yards, liters, gallons. Re-expressing those is
160
+ * numerically lossless but semantically not: a gravel estimator multiplying a
161
+ * cu_yd volume by a tons-per-cubic-yard constant is correct only while the
162
+ * volume stays in cubic yards, and that constant is a bare number with nothing
163
+ * to declare its units. Silently rebasing it to cu_in would make the answer
164
+ * 46,656× too large. Better to leave such an equation on the pre-base path,
165
+ * where the mixed-base check still watches it, than to guess.
166
+ */
167
+ export declare const deriveEquationBase: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds" | "variableUnits">) => DerivedEquationBase;
74
168
  /**
75
- * Bring an equation's unit annotations back in line with the fields it
76
- * references. Field edits (a dimension switch, a kind change, a deletion) are
77
- * written independently of equations, so a stale `variableUnits` entry or
78
- * `resultUnit` would otherwise keep converting against a unit the field no
79
- * longer uses the failure mode this whole module exists to prevent.
169
+ * Bring an equation's field-derived annotations variable column bindings,
170
+ * variable units, result unit back in line with the fields it references.
171
+ * Field edits (a dimension switch, a kind change, a deleted conversion column,
172
+ * a deleted field) are written independently of equations, so a stale entry
173
+ * would otherwise keep converting against a unit or reading a column the field
174
+ * no longer has — the failure mode this whole module exists to prevent.
80
175
  */
81
176
  export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
82
177
  equations: CalculatorEquation[];