Module: Hecks::Runtime::CommandRules::Arithmetic

Included in:
Hecks::Runtime::CommandRules
Defined in:
lib/hecks/runtime/command_rules/arithmetic.rb

Overview

The arithmetic half of mutation: what a source resolves to, and how increment/decrement land on an Integer or a one-numeric-field value object.

Defined Under Namespace

Classes: MutationOp

Constant Summary collapse

MUTATION_OPS =
[
  MutationOp.new(name: "set",       sign: nil),
  MutationOp.new(name: "append",    sign: nil),
  MutationOp.new(name: "increment", sign: 1),
  MutationOp.new(name: "decrement", sign: -1),
  # Vendored addition, not (yet) upstream hecks (migration
  # plan task 4, i106): multiply/clamp carry no sign -- like
  # set/append, they do no add-or-subtract arithmetic (multiply
  # scales, clamp bounds). See #multiply/#clamp below.
  MutationOp.new(name: "multiply",  sign: nil),
  MutationOp.new(name: "clamp",     sign: nil),
  # Vendored addition, not (yet) upstream hecks (migration
  # plan task 4): remove -- carries no sign, like set/append; it
  # matches a list element by value rather than doing arithmetic.
  # Declared here so this table stays exactly what
  # Vocabulary::MutationOp declares (spec/vocabulary_conformance_spec
  # holds the two equal) -- MutationApplier's own `when :remove`
  # branch (mutation_applier.rb) never calls #sign_of, so this was
  # a declared-vocabulary gap, not a behaviour gap.
  MutationOp.new(name: "remove",    sign: nil),
  # Vendored addition, not (yet) upstream hecks —
  # CommandBuilder#delegates_to's own comment gives the full
  # reasoning; carries no sign, like set/append/remove — it does
  # no arithmetic, only a synchronous handoff into one nested
  # entity command. Declared here so this table stays exactly
  # what Vocabulary::MutationOp declares — MutationApplier's own
  # `when :delegate` branch (mutation_applier.rb) never calls
  # #sign_of either, same as `remove`'s own note above.
  MutationOp.new(name: "delegate",  sign: nil),
  # CommandBuilder#corrects_impl's own comment gives the full
  # reasoning — a command amending a past event rather than
  # acting fresh. Carries no sign, like delegate: it does no
  # arithmetic of its own; the record's actual change, if any,
  # is an ordinary `sets` declared alongside it.
  MutationOp.new(name: "corrects",  sign: nil)
].freeze

Instance Method Summary collapse

Instance Method Details

#arithmetic(current, amount, target, sign) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 110

def arithmetic(current, amount, target, sign)
  op = sign.positive? ? "increment" : "decrement"
  current ||= 0

  if current.is_a?(Value) && amount.is_a?(Value)
    return arithmetic_value_object(current, amount, target, sign, op)
  end

  # `current` genuinely absent (no declared default, never set) and
  # `amount` arrives VO-wrapped — a real command argument typed the
  # same as the attribute, but with nothing to combine field-by-
  # field against yet (that is what `arithmetic_value_object`,
  # above, is for once BOTH sides carry real fields). Before this,
  # falling straight to `unless amount.is_a?(Numeric)` below
  # refused with "increment needs an Integer, got 500" — true of
  # nothing: 500 is exactly the Integer it asked for, just still
  # wearing the Money wrapper the command's own declared attribute
  # type put it in. Unwrapped here, the same shape #clamp already
  # falls through to for an absent VO-typed attribute
  # (`current ||= 0`, then a raw scalar) — the mutation applier
  # re-wraps the raw result into the declared VO type on write,
  # the same way it already does for clamp's own result.
  amount = unwrap_single_numeric_field(amount) if amount.is_a?(Value)

  # Widened from Integer to Numeric (migration plan task 4, i106):
  # miette's organ math increments a Float (`increment: 0.02`) --
  # the raw, non-value-object path only ever mattered for Integer
  # counters before this corpus existed. Integer stays the common
  # case; Float is now accepted the same way.
  unless amount.is_a?(Numeric)
    raise TypeMismatch, RefusalWording.render("TypeMismatch", "arithmetic_amount",
                                              op: op, target: target, offered: Rendering.describe(amount))
  end
  unless current.is_a?(Numeric)
    raise TypeMismatch, RefusalWording.render("TypeMismatch", "arithmetic_current",
                                              op: op, target: target, offered: Rendering.describe(current))
  end

  current + (sign * amount)
end

#arithmetic_value_object(current, amount, target, sign, op) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 151

def arithmetic_value_object(current, amount, target, sign, op)
  current_fields = current.to_h
  amount_fields  = amount.to_h
  # Widened from Integer to Numeric -- see #arithmetic's own
  # comment. A synthesised value-object wrapper around a bare
  # Float attribute (miette's Synapse#strength, auto-wrapped per
  # Part 3a's "bare primitives forbidden" finding) lands here as
  # a one-Float-field Value exactly the way a one-Integer-field
  # Value already did.
  shared_numeric = current_fields.keys.select do |field|
    current_fields[field].is_a?(Numeric) && amount_fields[field].is_a?(Numeric)
  end
  unless shared_numeric.size == 1
    raise TypeMismatch,
          RefusalWording.render("TypeMismatch", "arithmetic_shared_field", op: op, target: target)
  end

  field = shared_numeric.first
  current.with(field, current[field] + (sign * amount[field]))
end

#clamp(current, bounds, target) ⇒ Object

Vendored addition, not (yet) upstream hecks (migration plan task 4, i106): bound the CURRENT value into [min, max] -- no "amount" to combine, so it does not go through #arithmetic/#multiply's shared-numeric-field matching at all; it clamps whichever single numeric field the wrapping value object carries (a synthesised wrapper always carries exactly one, per Part 3a's auto-synthesis).



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 222

def clamp(current, bounds, target)
  min, max = bounds
  # THE SAME `current ||= 0` #arithmetic/#multiply both give a
  # PHANTOM (never-set) numeric field, one line up from each —
  # this was the one arithmetic op that didn't, so a VO-typed
  # attribute with no declared `default:` (genuinely absent,
  # `Instance.defaults`/`#default_for`) hit TypeMismatch on the
  # FIRST clamp. (#arithmetic/#multiply's OWN absent-current gap
  # was a real, separate bug this comment used to describe wrong —
  # they did not "silently treat the same absent field as zero";
  # they raised too, blaming a perfectly valid `amount` for not
  # being an Integer when it was one, just still Money-wrapped.
  # Fixed alongside this one — see #unwrap_single_numeric_field.)
  current ||= 0
  if current.is_a?(Value)
    fields = current.to_h
    field  = fields.keys.find { |f| fields[f].is_a?(Numeric) } or
      raise TypeMismatch, RefusalWording.render("TypeMismatch", "arithmetic_current",
                                                op: "clamp", target: target, offered: Rendering.describe(current))
    return current.with(field, fields[field].clamp(min, max))
  end

  unless current.is_a?(Numeric)
    raise TypeMismatch, RefusalWording.render("TypeMismatch", "arithmetic_current",
                                              op: "clamp", target: target, offered: Rendering.describe(current))
  end

  current.clamp(min, max)
end

#multiply(current, amount, target) ⇒ Object

Vendored addition, not (yet) upstream hecks (migration plan task 4, i106): current * amount -- the scaling counterpart to increment/decrement's add/subtract. Same raw-vs-value-object branch shape as #arithmetic/#arithmetic_value_object, reused rather than duplicated verb-for-verb (a Proc picks the actual arithmetic; everything else -- the Value unwrap/rewrap, the TypeMismatch refusals -- is identical to the additive pair).



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 195

def multiply(current, amount, target)
  current ||= 0

  if current.is_a?(Value) && amount.is_a?(Value)
    return combine_value_object(current, amount, target, "multiply") { |c, a| c * a }
  end

  # Same absent-`current`, VO-wrapped-`amount` gap as `#arithmetic`
  # — see that method's own comment.
  amount = unwrap_single_numeric_field(amount) if amount.is_a?(Value)

  unless amount.is_a?(Numeric) && current.is_a?(Numeric)
    raise TypeMismatch, RefusalWording.render("TypeMismatch", "arithmetic_amount",
                                              op: "multiply", target: target,
                                              offered: Rendering.describe(current.is_a?(Numeric) ? amount : current))
  end

  current * amount
end

#resolve_source(source, args) ⇒ Object

A mutation's source is either the NAME OF AN ARGUMENT or a LITERAL, and the two are told apart by type : a Symbol is always a name, a String or a number is always a value. Checked across all eight chapters — to: :name and to: "sold", never a Symbol meant as a value.

&& args.key?(source) used to guard the lookup, and that guard is what made an ABSENT argument fall through to source and return THE SYMBOL ITSELF as the value. Customer.Register without its name set name to the literal :name, coercion met a Symbol where a PersonName belonged, and the refusal read "name is a PersonName — pass its fields as an object, not :name" — a message describing a mistake the caller had not made. The real mistake, an absent argument, was never the one refused, which is what fuzz surfaced.

STALE (as of the equivalence-gap plan's own audit): this used to say "the language cannot yet say which arguments are optional" — it already can, and always could once attribute ..., optional: true existed (CommandBuilder#attribute_impl, attribute_collector.rb): sets already sources correctly from an optional attribute, resolving absent to nil exactly as this method does, and REFUSING it here would be wrong, not merely undone work — TillRoom::Till.TakeIn's own note (spec/ fixtures/till.bluebook) and Banking's CardPayment.Authorize's tags (payment_cards.bluebook) are real, live commands whose sets mutation is deliberately sourced from an optional attribute the caller may omit — spec/runtime/command_rules_spec .rb's own "says an absent OPTIONAL argument is nil, not the name of the argument" pins exactly this as correct, not pending. The meta-domain's own self-hosted commands (Command.Declare's role/goal/provenance/from/position, and ~35 more sites across the language) all lean on the identical pattern — nil is the RIGHT answer for a sets sourced from a declared-optional attribute the caller left out, every time.

The one thing that WAS still a real, narrow gap — a sets source Symbol naming NOTHING the command declares at all (a typo, not an optional argument) — silently resolved to nil forever the same way, indistinguishable at either build or run time from a legitimate optional absence. Closed at BUILD time instead of here: CommandBuilder#refuse_unknown_argument_sources! refuses it the moment the .bluebook file loads, mirroring AggregateBuilder#seal_query_argument's identical check for a query's own where-clause argument. This function stays exactly what it always was — a pure, unconditional lookup — because by the time ANY mutation reaches it, the source has already been proven to name either a real, possibly-optional argument, or a StateRef/literal; there is nothing left here to refuse.



104
105
106
107
108
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 104

def resolve_source(source, args)
  return args[source] if source.is_a?(Symbol)

  source
end

#sign_of(op) ⇒ Object

Not a bare .find(...)&.sign || -1 — that silently answered DECREMENT'S sign for BOTH an op this table has never heard of AND a declared, real op that simply carries no sign at all (set/append/multiply/clamp/remove — see MUTATION_OPS above). Callers today only ever reach this for :increment/:decrement (both MutationApplier#apply and EntityInterpreter# apply_to_element gate every other op through their own case first, each with its own loud WiringError backstop), so this raise is not a real runtime path yet — it is the same backstop one level down, in case a future caller reaches #sign_of directly for an op that was never meant to have one.



183
184
185
186
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 183

def sign_of(op)
  MUTATION_OPS.find { |candidate| candidate.name == op.to_s }&.sign ||
    raise(WiringError, "no sign declared for mutation op #{op.inspect} — add one before calling #sign_of")
end