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)
].freeze

Instance Method Summary collapse

Instance Method Details

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



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 74

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



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 115

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).



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 186

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).



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 159

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.

Absent now resolves to nil. Whether it should be REFUSED instead is a separate question — the language cannot yet say which arguments are optional, and the meta-domain has plenty that are.



68
69
70
71
72
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 68

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.



147
148
149
150
# File 'lib/hecks/runtime/command_rules/arithmetic.rb', line 147

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