@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.
@@ -1,7 +1,8 @@
1
1
  import { create, all } from 'mathjs';
2
- import { Units } from '../types/firestore.js';
3
- import { fieldDefaultUnit, fieldDimension } from './schema.js';
4
- import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
2
+ import { ColumnType } from '../types/firestore.js';
3
+ import { bindingDefaultUnit, bindingDimension, bindingUnitForBase, equationBindings, fieldDimension, } from './schema.js';
4
+ import { findConversionColumn } from './conversionTable.js';
5
+ import { UNIT_BY_BASE, equationBaseOfUnit, isEquationBase, toCanonical, unitBase, unitDimension, unitForBase, } from './units.js';
5
6
  const math = create(all);
6
7
  // ---------------------------------------------------------------------------
7
8
  // Expression unit inference.
@@ -43,23 +44,41 @@ export const MATHJS_CONSTANTS = new Set([
43
44
  'NaN',
44
45
  'null',
45
46
  ]);
47
+ // Parse cache, mirroring evaluate.ts's compile cache and sized to match. Unit
48
+ // inference now runs on the evaluation path (an equation's scope units are
49
+ // derived from its base, not read off the document), so the same handful of
50
+ // expressions get parsed on every keystroke of the editor preview.
51
+ //
52
+ // The cached node is handed to callers directly. Every consumer in this
53
+ // package only reads it — `traverse` in expressionSymbols, the structural walk
54
+ // below — so nothing mutates the shared tree.
55
+ const parseCache = new Map();
56
+ const PARSE_CACHE_MAX = 200;
46
57
  /** Shared mathjs parse — the one place expression text becomes a tree. */
47
58
  export const parseExpression = (expression) => {
59
+ const hit = parseCache.get(expression);
60
+ if (hit)
61
+ return hit;
62
+ let result;
48
63
  try {
49
- return { ok: true, node: math.parse(expression) };
64
+ result = { ok: true, node: math.parse(expression) };
50
65
  }
51
66
  catch (err) {
52
- return {
67
+ result = {
53
68
  ok: false,
54
69
  error: err instanceof Error ? err.message : String(err),
55
70
  };
56
71
  }
72
+ if (parseCache.size >= PARSE_CACHE_MAX)
73
+ parseCache.clear();
74
+ parseCache.set(expression, result);
75
+ return result;
57
76
  };
58
77
  // The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
59
78
  // 'liter' -> 'L'. Two units share a base exactly when they are the same
60
79
  // physical unit at different exponents, which is what makes a result unit
61
80
  // nameable.
62
- const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
81
+ const baseUnitOf = unitBase;
63
82
  /**
64
83
  * Bases whose values are ALREADY canonical, so an equation in them needs no
65
84
  * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
@@ -68,17 +87,6 @@ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split(
68
87
  export const CANONICAL_BASES = new Set(['um', 'deg']);
69
88
  const CANONICAL_LENGTH_BASE = 'um';
70
89
  const CANONICAL_ANGLE_BASE = 'deg';
71
- // Base + length exponent -> the unit that names it. Yards have no length
72
- // entry (the Units enum has no yard) and liters/gallons no length base at all,
73
- // so both simply fail to name a unit rather than guessing.
74
- const UNIT_BY_BASE = {
75
- mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
76
- cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
77
- m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
78
- in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
79
- ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
80
- yd: { 2: 'sq_yd', 3: 'cu_yd' },
81
- };
82
90
  const SCALAR = { len: 0, ang: 0 };
83
91
  const isScalar = (e) => e.len === 0 && e.ang === 0;
84
92
  const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
@@ -143,7 +151,7 @@ export const inferExpressionUnit = (fields, equation) => {
143
151
  const field = fieldId != null ? fieldById.get(fieldId) : undefined;
144
152
  if (field) {
145
153
  // No variable unit: the value enters the scope canonically (units.ts).
146
- const dimension = fieldDimension(field);
154
+ const dimension = bindingDimension(field, equation.variableColumnIds?.[name]);
147
155
  const exponents = EXPONENTS_BY_DIMENSION[dimension];
148
156
  noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
149
157
  return exponents;
@@ -300,28 +308,35 @@ export const inferExpressionUnit = (fields, equation) => {
300
308
  if (!exponents) {
301
309
  return { ok: false, reason: failure ?? 'unsupported-operation' };
302
310
  }
311
+ // Every success path reports both axes; `bases` narrows to whichever axis
312
+ // the RESULT lives on.
313
+ const axes = { lengthBases: [...lengthBases], angleBases: [...angleBases] };
303
314
  if (exponents.ang !== 0) {
304
315
  // Angles don't combine with lengths into anything nameable (a length·deg
305
316
  // has no unit here), and neither does deg².
306
317
  if (exponents.len !== 0 || exponents.ang !== 1) {
307
318
  return { ok: false, reason: 'unnameable-dimension' };
308
319
  }
309
- const bases = [...angleBases];
320
+ const bases = axes.angleBases;
310
321
  return {
311
322
  ok: true,
312
323
  dimension: 'angle',
313
324
  unit: bases.length === 1 ? bases[0] : null,
314
325
  bases,
326
+ ...axes,
315
327
  };
316
328
  }
317
329
  if (exponents.len === 0) {
318
- return { ok: true, dimension: 'none', unit: null, bases: [] };
330
+ // Dimensionless. `bases` is empty because the RESULT names no unit — but
331
+ // the per-axis lists still carry what went in, which is the only record
332
+ // that a cancelling ratio mixed inches with feet.
333
+ return { ok: true, dimension: 'none', unit: null, bases: [], ...axes };
319
334
  }
320
335
  if (exponents.len < 1 || exponents.len > 3) {
321
336
  return { ok: false, reason: 'unnameable-dimension' };
322
337
  }
323
338
  const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
324
- const bases = [...lengthBases];
339
+ const bases = axes.lengthBases;
325
340
  return {
326
341
  ok: true,
327
342
  dimension,
@@ -329,6 +344,7 @@ export const inferExpressionUnit = (fields, equation) => {
329
344
  ? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
330
345
  : null,
331
346
  bases,
347
+ ...axes,
332
348
  };
333
349
  };
334
350
  /**
@@ -364,73 +380,312 @@ export const deriveResultUnit = (fields, equation) => {
364
380
  ? current
365
381
  : inferred.unit;
366
382
  };
383
+ // ---------------------------------------------------------------------------
384
+ // Base-anchored derivation.
385
+ //
386
+ // With `CalculatorEquation.base` set, units stop being authored and become a
387
+ // function of (base, binding dimension). `deriveVariableUnits` answers "what
388
+ // does each variable arrive as", `deriveResultUnitForBase` answers "what does
389
+ // the expression hand back", and `deriveEquationBase` picks a base for an
390
+ // equation that predates the field.
391
+ // ---------------------------------------------------------------------------
392
+ /** The scope unit for every variable of a base-anchored equation. */
393
+ export const deriveVariableUnits = (base, fields, equation) => {
394
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
395
+ const units = {};
396
+ for (const { variable, fieldId, columnId } of equationBindings(equation)) {
397
+ const field = fieldById.get(fieldId);
398
+ if (!field)
399
+ continue;
400
+ const unit = bindingUnitForBase(base, field, columnId);
401
+ if (unit != null)
402
+ units[variable] = unit;
403
+ }
404
+ return units;
405
+ };
406
+ /** The unit a base-anchored expression's raw value carries. */
407
+ export const deriveResultUnitForBase = (base, fields, equation) => {
408
+ const inferred = inferExpressionUnit(fields, {
409
+ ...equation,
410
+ variableUnits: deriveVariableUnits(base, fields, equation),
411
+ });
412
+ if (!inferred.ok)
413
+ return { kind: 'unknown' };
414
+ if (inferred.dimension === 'none' || inferred.dimension === 'angle') {
415
+ return { kind: 'none' };
416
+ }
417
+ const unit = unitForBase(base, inferred.dimension);
418
+ return unit ? { kind: 'unit', unit } : { kind: 'unknown' };
419
+ };
420
+ /** Inert default when an equation has no length-valued binding at all. */
421
+ export const DEFAULT_EQUATION_BASE = 'in';
422
+ const LENGTH_DIMENSIONS = new Set(['length', 'area', 'volume']);
423
+ // Most frequent base wins; ties break toward the first one seen, so the result
424
+ // is stable across runs (variable order is the document's own key order).
425
+ const mostCommon = (bases) => {
426
+ if (bases.length === 0)
427
+ return null;
428
+ const counts = new Map();
429
+ for (const b of bases)
430
+ counts.set(b, (counts.get(b) ?? 0) + 1);
431
+ let best = bases[0];
432
+ for (const b of bases) {
433
+ if ((counts.get(b) ?? 0) > (counts.get(best) ?? 0))
434
+ best = b;
435
+ }
436
+ return best;
437
+ };
438
+ /**
439
+ * The base a pre-`base` equation should adopt, in descending order of what the
440
+ * source can be trusted to mean:
441
+ *
442
+ * 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
443
+ * a human typed; its unit is the only thing that says what the number is,
444
+ * so it is the one declaration in the equation that cannot be re-expressed
445
+ * without reinterpreting authored data.
446
+ * 2. An existing `variableUnits` entry — the author's own stated intent,
447
+ * even if it disagreed with its neighbours.
448
+ * 3. A bound measurement field's display unit. Values are stored canonically,
449
+ * so this only reflects a preference, but it is the author's preference.
450
+ * 4. Nothing length-valued in the equation, so the base cannot matter.
451
+ *
452
+ * Declines entirely (`base: null`) when a variable is ALREADY authored in a
453
+ * unit no base can name — cubic yards, liters, gallons. Re-expressing those is
454
+ * numerically lossless but semantically not: a gravel estimator multiplying a
455
+ * cu_yd volume by a tons-per-cubic-yard constant is correct only while the
456
+ * volume stays in cubic yards, and that constant is a bare number with nothing
457
+ * to declare its units. Silently rebasing it to cu_in would make the answer
458
+ * 46,656× too large. Better to leave such an equation on the pre-base path,
459
+ * where the mixed-base check still watches it, than to guess.
460
+ */
461
+ export const deriveEquationBase = (fields, equation) => {
462
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
463
+ const fromColumns = [];
464
+ const fromVariableUnits = [];
465
+ const fromFieldDefaults = [];
466
+ const blockedBy = [];
467
+ for (const { variable, fieldId, columnId } of equationBindings(equation)) {
468
+ const field = fieldById.get(fieldId);
469
+ if (!field)
470
+ continue;
471
+ // Only length-family bindings can disagree about scale; angles are their
472
+ // own axis and dimensionless bindings carry nothing.
473
+ if (!LENGTH_DIMENSIONS.has(bindingDimension(field, columnId)))
474
+ continue;
475
+ // bindingDefaultUnit resolves an absent/unknown column id to the first
476
+ // column, matching what the evaluator actually reads.
477
+ const columnUnit = field.kind === ColumnType.ConversionTable
478
+ ? bindingDefaultUnit(field, columnId)
479
+ : null;
480
+ const declared = equation.variableUnits?.[variable];
481
+ // The unit this variable is evaluating in TODAY. That is what a base has
482
+ // to be able to reproduce; a field's display default is only a preference.
483
+ const effective = declared ?? columnUnit;
484
+ if (effective != null && equationBaseOfUnit(effective) == null) {
485
+ blockedBy.push(effective);
486
+ continue;
487
+ }
488
+ if (columnUnit) {
489
+ const base = equationBaseOfUnit(columnUnit);
490
+ if (base)
491
+ fromColumns.push(base);
492
+ }
493
+ if (declared) {
494
+ const base = equationBaseOfUnit(declared);
495
+ if (base)
496
+ fromVariableUnits.push(base);
497
+ }
498
+ if (field.kind === ColumnType.Measurement) {
499
+ const base = equationBaseOfUnit(field.unit.defaultUnit);
500
+ if (base)
501
+ fromFieldDefaults.push(base);
502
+ }
503
+ }
504
+ if (blockedBy.length > 0) {
505
+ return {
506
+ base: null,
507
+ source: 'unanchorable',
508
+ candidates: [],
509
+ blockedBy: [...new Set(blockedBy)],
510
+ };
511
+ }
512
+ const tiers = [
513
+ ['conversion-column', fromColumns],
514
+ ['variable-unit', fromVariableUnits],
515
+ ['field-default', fromFieldDefaults],
516
+ ];
517
+ for (const [source, candidates] of tiers) {
518
+ const base = mostCommon(candidates);
519
+ if (base)
520
+ return { base, source, candidates: [...new Set(candidates)] };
521
+ }
522
+ return {
523
+ base: DEFAULT_EQUATION_BASE,
524
+ source: 'fallback',
525
+ candidates: [],
526
+ };
527
+ };
367
528
  // Rebuild an equation without a key, since Firestore rejects `undefined`.
368
529
  const withoutResultUnit = (equation) => {
369
530
  const { resultUnit: _dropped, ...rest } = equation;
370
531
  return rest;
371
532
  };
533
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
534
+ const withoutVariableColumnIds = (equation) => {
535
+ const { variableColumnIds: _dropped, ...rest } = equation;
536
+ return rest;
537
+ };
538
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
539
+ const withoutVariableUnits = (equation) => {
540
+ const { variableUnits: _dropped, ...rest } = equation;
541
+ return rest;
542
+ };
372
543
  /**
373
- * Bring an equation's unit annotations back in line with the fields it
374
- * references. Field edits (a dimension switch, a kind change, a deletion) are
375
- * written independently of equations, so a stale `variableUnits` entry or
376
- * `resultUnit` would otherwise keep converting against a unit the field no
377
- * longer uses — the failure mode this whole module exists to prevent.
544
+ * The pre-base repair, kept verbatim for equations that cannot adopt a base.
545
+ * It only realigns a unit whose DIMENSION no longer matches its binding — it
546
+ * has no opinion about bases, which is precisely why it is safe here: an
547
+ * equation holding cubic yards keeps holding cubic yards.
548
+ */
549
+ const reconcileLegacyUnits = (fields, fieldById, equation) => {
550
+ let repaired = equation;
551
+ if (repaired.variableUnits) {
552
+ const variableUnits = {};
553
+ let unitsChanged = false;
554
+ for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
555
+ const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
556
+ if (!field) {
557
+ unitsChanged = true; // variable or field is gone
558
+ continue;
559
+ }
560
+ const columnId = repaired.variableColumnIds?.[variable];
561
+ if (unitDimension(unit) === bindingDimension(field, columnId)) {
562
+ variableUnits[variable] = unit;
563
+ continue;
564
+ }
565
+ const replacement = bindingDefaultUnit(field, columnId);
566
+ unitsChanged = true;
567
+ if (replacement)
568
+ variableUnits[variable] = replacement;
569
+ }
570
+ if (unitsChanged) {
571
+ repaired =
572
+ Object.keys(variableUnits).length > 0
573
+ ? { ...repaired, variableUnits }
574
+ : withoutVariableUnits(repaired);
575
+ }
576
+ }
577
+ const target = fieldById.get(repaired.targetFieldId);
578
+ const targetDimension = target ? fieldDimension(target) : 'none';
579
+ if (targetDimension === 'none') {
580
+ if (repaired.resultUnit != null)
581
+ repaired = withoutResultUnit(repaired);
582
+ return repaired;
583
+ }
584
+ const derived = deriveResultUnit(fields, repaired);
585
+ if (derived != null) {
586
+ if (repaired.resultUnit !== derived) {
587
+ repaired = { ...repaired, resultUnit: derived };
588
+ }
589
+ }
590
+ else if (repaired.resultUnit != null &&
591
+ unitDimension(repaired.resultUnit) !== targetDimension) {
592
+ // Not derivable AND not even the right dimension: drop it rather than
593
+ // keep converting a length factor into an area field.
594
+ repaired = withoutResultUnit(repaired);
595
+ }
596
+ return repaired;
597
+ };
598
+ /**
599
+ * Bring an equation's field-derived annotations — variable column bindings,
600
+ * variable units, result unit — back in line with the fields it references.
601
+ * Field edits (a dimension switch, a kind change, a deleted conversion column,
602
+ * a deleted field) are written independently of equations, so a stale entry
603
+ * would otherwise keep converting against a unit or reading a column the field
604
+ * no longer has — the failure mode this whole module exists to prevent.
378
605
  */
379
606
  export const reconcileEquationUnits = (fields, equations) => {
380
607
  const fieldById = new Map(fields.map((f) => [f.id, f]));
381
608
  let changed = false;
382
609
  const next = equations.map((equation) => {
383
610
  let repaired = equation;
384
- // 1. Variable units must match the dimension of the field they annotate.
385
- if (equation.variableUnits) {
386
- const variableUnits = {};
387
- let unitsChanged = false;
388
- for (const [variable, unit] of Object.entries(equation.variableUnits)) {
611
+ // 1. Column bindings must name a column the bound field still has. A
612
+ // dropped entry falls back to the first column, which is the same
613
+ // reading the evaluator gives it — so pruning here only removes a lie,
614
+ // it never changes what the equation computes.
615
+ if (equation.variableColumnIds) {
616
+ const variableColumnIds = {};
617
+ let bindingsChanged = false;
618
+ for (const [variable, columnId] of Object.entries(equation.variableColumnIds)) {
389
619
  const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
390
- if (!field) {
391
- unitsChanged = true; // variable or field is gone
392
- continue;
393
- }
394
- if (unitDimension(unit) === fieldDimension(field)) {
395
- variableUnits[variable] = unit;
620
+ if (field?.kind === ColumnType.ConversionTable &&
621
+ findConversionColumn(field.columnData, columnId)) {
622
+ variableColumnIds[variable] = columnId;
396
623
  continue;
397
624
  }
398
- const replacement = fieldDefaultUnit(field);
399
- unitsChanged = true;
400
- if (replacement)
401
- variableUnits[variable] = replacement;
625
+ bindingsChanged = true;
402
626
  }
403
- if (unitsChanged) {
627
+ if (bindingsChanged) {
404
628
  repaired =
405
- Object.keys(variableUnits).length > 0
406
- ? { ...repaired, variableUnits }
407
- : (() => {
408
- const { variableUnits: _dropped, ...rest } = repaired;
409
- return rest;
410
- })();
629
+ Object.keys(variableColumnIds).length > 0
630
+ ? { ...repaired, variableColumnIds }
631
+ : withoutVariableColumnIds(repaired);
411
632
  }
412
633
  }
413
- // 2. The result unit follows from the repaired variable units.
634
+ // 2. Adopt a base. An equation authored before bases existed gets one
635
+ // inferred from what it already says (deriveEquationBase), so opening
636
+ // an old calculator and touching a field migrates it in place. An
637
+ // unrecognized stored base is treated as absent rather than trusted.
638
+ // Read against `repaired`: step 1 may have moved a variable back to the
639
+ // first column, whose unit is what the base should follow.
640
+ //
641
+ // An EXPLICIT base always wins — converting a cu_yd column into ft³ is
642
+ // well defined, and an author who asked for it meant it. Only the
643
+ // inference declines, and only for an equation already sitting in a
644
+ // unit family no base can name (see deriveEquationBase).
645
+ const base = isEquationBase(repaired.base)
646
+ ? repaired.base
647
+ : deriveEquationBase(fields, repaired).base;
648
+ if (base == null) {
649
+ // Unanchorable: leave it exactly as the pre-base code would have.
650
+ repaired = reconcileLegacyUnits(fields, fieldById, repaired);
651
+ if (repaired !== equation)
652
+ changed = true;
653
+ return repaired;
654
+ }
655
+ if (repaired.base !== base)
656
+ repaired = { ...repaired, base };
657
+ // 3. Variable units are fully determined by the base and each binding's
658
+ // dimension, so they are not stored at all — the evaluator derives them.
659
+ // Anything left over from the pre-base form is dead weight that a reader
660
+ // would reasonably mistake for the source of truth.
661
+ if (repaired.variableUnits != null) {
662
+ repaired = withoutVariableUnits(repaired);
663
+ }
664
+ // 4. The result unit is derived the same way, with ONE exception worth
665
+ // persisting: an expression whose units inference cannot model at all
666
+ // (an unmodelled function, a dimension this vocabulary can't name). The
667
+ // base says nothing useful there, so the author picks, and that choice
668
+ // is real authored data rather than a cache.
669
+ //
670
+ // Number targets take the raw value either way — that is what makes
671
+ // `area_in_ft² / 33` read as 33 ft², not 33 µm².
414
672
  const target = fieldById.get(equation.targetFieldId);
415
673
  const targetDimension = target ? fieldDimension(target) : 'none';
416
- if (targetDimension === 'none') {
417
- if (repaired.resultUnit != null)
418
- repaired = withoutResultUnit(repaired);
419
- }
420
- else {
421
- const derived = deriveResultUnit(fields, repaired);
422
- if (derived != null) {
423
- if (repaired.resultUnit !== derived) {
424
- repaired = { ...repaired, resultUnit: derived };
425
- }
426
- }
427
- else if (repaired.resultUnit != null &&
674
+ const derived = targetDimension === 'none'
675
+ ? { kind: 'none' }
676
+ : deriveResultUnitForBase(base, fields, repaired);
677
+ if (derived.kind === 'unknown') {
678
+ // Keep the author's pick, unless it isn't even the target's dimension —
679
+ // that would convert a length factor into an area field.
680
+ if (repaired.resultUnit != null &&
428
681
  unitDimension(repaired.resultUnit) !== targetDimension) {
429
- // Not derivable AND not even the right dimension: drop it rather than
430
- // keep converting a length factor into an area field.
431
682
  repaired = withoutResultUnit(repaired);
432
683
  }
433
684
  }
685
+ else if (repaired.resultUnit != null) {
686
+ // Derivable, so a stored unit is either redundant or wrong. Drop it.
687
+ repaired = withoutResultUnit(repaired);
688
+ }
434
689
  if (repaired !== equation)
435
690
  changed = true;
436
691
  return repaired;
@@ -1,6 +1,7 @@
1
1
  export * from './schema.js';
2
2
  export * from './categories.js';
3
3
  export * from './units.js';
4
+ export * from './conversionTable.js';
4
5
  export * from './expressionUnits.js';
5
6
  export * from './evaluate.js';
6
7
  export * from './solve.js';
@@ -5,6 +5,7 @@
5
5
  export * from './schema.js';
6
6
  export * from './categories.js';
7
7
  export * from './units.js';
8
+ export * from './conversionTable.js';
8
9
  export * from './expressionUnits.js';
9
10
  export * from './evaluate.js';
10
11
  export * from './solve.js';
@@ -1,5 +1,5 @@
1
1
  import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
2
- import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
2
+ import type { AngleUnit, CalculatorUnit, EquationBase, FieldDimension, MeasurementDimension } from './units.js';
3
3
  import type { CalculatorCategoryId } from './categories.js';
4
4
  /**
5
5
  * Version of the definition format itself. Bumped only on breaking schema
@@ -112,15 +112,55 @@ export interface CalculatorEquation {
112
112
  /** Variable letter -> field id. */
113
113
  variableToFieldId: Record<string, string>;
114
114
  /**
115
- * Variable letter -> display unit its value enters the scope in. A mapped
116
- * variable without an entry stays canonical (µm-scale) the pre-1.9
117
- * behavior. Unit dimension must match the mapped field's dimension.
115
+ * Variable letter -> `ConversionColumn.id`, for variables bound to a
116
+ * conversion-table field. Absent (or absent for one letter) means that
117
+ * variable reads the table's FIRST column — the only reading under which
118
+ * equations written before multi-column tables keep computing.
119
+ *
120
+ * Two letters may map to the same field with different columns: that is the
121
+ * whole point, one row selection feeding several terms of an expression.
122
+ */
123
+ variableColumnIds?: Record<string, string>;
124
+ /**
125
+ * The ONE length base this expression evaluates in ('ft', 'in', 'mm', …) —
126
+ * and, when present, the only unit information the equation stores. Each
127
+ * variable's scope unit is this base at the variable's own exponent (ft,
128
+ * ft², ft³); the result unit is this base at the result's exponent. Both are
129
+ * computed on demand, never written down.
130
+ *
131
+ * This exists because per-variable units could disagree. Authoring
132
+ * `(A_in * B_in) / C_ft²` passed every dimensional check — length·length ÷
133
+ * area cancels to a valid dimensionless count — while computing 144× the
134
+ * truth, because nothing reconciled inches against feet. A single base makes
135
+ * that unrepresentable rather than merely detectable.
136
+ *
137
+ * The base is per EQUATION, not per calculator: values are stored
138
+ * canonically, so two equations may work in different bases and still feed
139
+ * each other — each converts in and out at its own edge.
140
+ *
141
+ * Absent means the pre-base form below. Still read, because saved instances
142
+ * freeze their definition and older docs migrate lazily, but never written
143
+ * for new equations.
144
+ */
145
+ base?: EquationBase;
146
+ /**
147
+ * LEGACY (pre-`base`). Variable letter -> display unit its value enters the
148
+ * scope in; a mapped variable with no entry stays canonical (µm-scale).
149
+ *
150
+ * Ignored entirely when `base` is set, and stripped by
151
+ * reconcileEquationUnits, since the base determines every one of these — a
152
+ * stored copy could only go stale and be mistaken for the source of truth.
118
153
  */
119
154
  variableUnits?: Record<string, CalculatorUnit>;
120
155
  /**
121
156
  * Display unit the expression's RESULT is expressed in; converted back to
122
157
  * canonical before storage/chaining. Only meaningful for measurement/angle
123
158
  * targets — number targets take the result raw. Absent = canonical.
159
+ *
160
+ * With `base` set this survives in ONE case: an expression whose units
161
+ * inference cannot model (an unmodelled function, an unnameable dimension).
162
+ * The base yields no answer there, so the author picks one and it is real
163
+ * authored data. Everywhere else it is derived and not stored.
124
164
  */
125
165
  resultUnit?: CalculatorUnit;
126
166
  }
@@ -171,12 +211,55 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
171
211
  export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
172
212
  /** The dimension a field's numeric value carries. */
173
213
  export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
214
+ /**
215
+ * What one equation VARIABLE binds to: a field, and for a conversion table
216
+ * which of its value columns. Everything downstream of the variable map — unit
217
+ * inference, validation, the scope build — needs the pair, because a
218
+ * conversion table's dimension is a property of the column, not the field.
219
+ */
220
+ export interface VariableBinding {
221
+ fieldId: string;
222
+ /** Undefined for non-conversion fields and for first-column bindings. */
223
+ columnId?: string;
224
+ }
225
+ /** The binding for one variable letter, or null when the letter is unmapped. */
226
+ export declare const equationBinding: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">, variable: string) => VariableBinding | null;
227
+ /** Every variable's binding, in the variable map's order. */
228
+ export declare const equationBindings: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => (VariableBinding & {
229
+ variable: string;
230
+ })[];
231
+ /**
232
+ * The dimension a BOUND value carries. Same as `fieldDimension` for every kind
233
+ * but a conversion table, where a column may declare a unit and so give the
234
+ * binding a real dimension — the thing that lets `width · depth` off one table
235
+ * row infer as an area rather than a bare product.
236
+ */
237
+ export declare const bindingDimension: (field: CalculatorField, columnId?: string) => FieldDimension;
174
238
  /**
175
239
  * The unit a field's value is entered and displayed in, or null for fields
176
240
  * that carry no unit. Also the unit an equation referencing the field starts
177
241
  * out annotated with.
178
242
  */
179
243
  export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
244
+ /**
245
+ * The unit a BOUND value is DECLARED in — the conversion column's unit for a
246
+ * table binding (the unit the cell text was typed in), the field's display
247
+ * unit otherwise. This is what the value means at rest, not what it means
248
+ * inside an equation; `bindingUnitForBase` converts it to the latter.
249
+ */
250
+ export declare const bindingDefaultUnit: (field: CalculatorField, columnId?: string) => CalculatorUnit | null;
251
+ /**
252
+ * The unit a bound value enters a base-anchored equation's scope in — the
253
+ * base's member at the binding's own exponent, so a length arrives in `ft` and
254
+ * an area off the same equation in `ft²`.
255
+ *
256
+ * Never an author choice, and deliberately independent of how the value is
257
+ * declared or displayed: a column typed in ft² feeding an inch-based equation
258
+ * converts to in², which is a lossless re-expression of the same quantity, not
259
+ * a reinterpretation of the author's cell text. Returns null for dimensionless
260
+ * bindings, which enter the scope raw.
261
+ */
262
+ export declare const bindingUnitForBase: (base: EquationBase, field: CalculatorField, columnId?: string) => CalculatorUnit | null;
180
263
  /** Fields whose value participates in equations as a number. */
181
264
  export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
182
265
  /** Field kinds an equation may target (compute into). */