← All writing

Technical note · 2026-09-14

Constrained editing: preserving totals with local compensation

A focused approach to fixed-total editing: state invariants, neighboring compensation, invalid input and integer precision, illustrated with dimensions and applicable to allocations and layouts.

  • TypeScript
  • State modeling
  • Invariants
  • Numeric precision

Related case: Custom furniture design and quotation system. This article focuses on preserving totals and bounds across linked values, not industry geometry rules or Canvas rendering.

Separate inputs can own one shared state

Layout widths, quota allocations and fixed-size task groups share a problem: editing one value must not change the total or violate another value’s bounds.

Validating each field independently cannot ensure that the complete allocation is valid. Start with a system-level invariant:

Allocatable total S = x₁ + x₂ + … + xₙ
Every xᵢ ≥ minimum L

Choose a compensation policy explicitly. Proportional resizing, neighboring compensation and distributed adjustments are different interactions, not interchangeable implementation details.

One state transition instead of competing watchers

A watcher updating B in response to A, followed by another watcher updating A, can produce loops and invalid intermediate states.

Treat an edit as an intent. A pure function computes the complete next state; commit it only after validation. All fields render from that result.

Local compensation

The example uses the right neighbor, except for the final item, which uses the left. Values use integer smallest units. The original array stays unchanged.

export function redistribute(
  values: readonly number[],
  index: number,
  target: number,
  minimum = 0
): number[] {
  if (!Number.isSafeInteger(minimum) || minimum < 0
      || values.length < 2 || !Number.isInteger(index)
      || index < 0 || index >= values.length) {
    throw new RangeError('Invalid constraint or item index')
  }
  if (!values.every(v => Number.isSafeInteger(v) && v >= minimum)
      || !Number.isSafeInteger(target) || target < minimum) {
    throw new RangeError('Values must be integers at or above the minimum')
  }
  const neighbor = index === values.length - 1
    ? index - 1 : index + 1
  const delta = target - values[index]
  const compensation = values[neighbor] - delta
  if (!Number.isSafeInteger(compensation) || compensation < minimum) {
    throw new RangeError('The neighbor cannot absorb this change')
  }
  const next = [...values]
  next[index] = target
  next[neighbor] = compensation
  return next
}

Increasing one value by Δ and decreasing its neighbor by Δ preserves the sum. If compensation violates the minimum, reject the whole edit.

Upper bounds, locked values, single-item allocations and compensation across several neighbors need separate policies; this function does not implement them.

Precision is a modeling decision

Splitting 2366 units into three begins with three values of 788 and a remainder of two. Distribute the remainder in a stable order to obtain [789, 789, 788]. Independent rounding would produce 2367.

In the cabinet example, subtract four fixed 18mm boards from 2438mm before allocating the remaining 2366mm. Editing the middle value to 820 with a minimum of 400 yields [789, 820, 757].

The reusable concept is separating fixed occupancy from editable allocation. Millimeters and the specific minimum belong to that example. Adding or removing items may also change fixed occupancy.

Test the invariant

Verify unchanged totals, changes limited to the intended pair and input immutability. Cover final-item compensation, fractional values, invalid indices and insufficient room. Integer distribution should preserve the exact total with deterministic remainder handling.

The UI should commit only a valid result and explain rejected edits. Keep the overall constraint in one state transformation; let each application define its own allocation policy.