hecks 1.2.0 → 1.3.0

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
@@ -1,6 +1,7 @@
1
1
  require "fileutils"
2
2
  require "tmpdir"
3
3
  require_relative "isolated_boot"
4
+ require_relative "self_consistency"
4
5
  require_relative "../query_specification/common/comparators"
5
6
  require_relative "../query_specification/common/where_clause"
6
7
  require_relative "../query_specification/field_path"
@@ -71,20 +72,41 @@ module Hecks
71
72
  # rubocop:disable-next Metrics/CyclomaticComplexity
72
73
  # rubocop:disable-next Metrics/MethodLength
73
74
  # rubocop:disable-next Metrics/PerceivedComplexity
74
- def call(domain_path, steps, adapter: :memory)
75
+ # `self_consistency:` — OFF by default, same "existing callers see no
76
+ # change" contract `adapter:` already has. `bin/qa_sweep` is the one
77
+ # real caller that opts in (gated by `QualityControlDials::
78
+ # SELF_CONSISTENCY_CHECKS`/`--self-consistency`): `bin/fuzz`,
79
+ # `Properties.check`'s own callers, and every existing spec keep
80
+ # calling this with no second axis of comparison at all, exactly as
81
+ # before. Computed HERE, not by a caller reading `runtime` back out
82
+ # afterward — `runtime` and the whole `IsolatedBoot` tmp directory
83
+ # go out of scope the moment this method returns (see this file's
84
+ # own header), so `Hecks::Fuzzing::SelfConsistency.check` has to run
85
+ # while both are still alive, against the exact same repositories
86
+ # this replay's own dispatch loop just wrote to.
87
+ #
88
+ # `database:`/`schema:` — ONLY meaningful, and REQUIRED, for
89
+ # `adapter: :postgres_era` — see `IsolatedBoot#rebind_to_postgres_era!`'s
90
+ # own header for why that one mode takes caller-owned connection
91
+ # identity rather than a shared default the way `:postgres` does.
92
+ # Forwarded straight through, unchanged, exactly like `adapter:`
93
+ # itself already was.
94
+ def call(domain_path, steps, adapter: :memory, database: nil, schema: nil, self_consistency: false)
75
95
  # See isolated_boot.rb's own header: resets data/ AND rebinds
76
96
  # persistence to the chosen adapter (Memory by default), since a
77
97
  # Postgres-bound domain's real store lives outside the copied
78
98
  # directory and cannot be reached by resetting data/ alone.
79
- IsolatedBoot.call(domain_path, adapter: adapter) do |copy|
99
+ IsolatedBoot.call(domain_path, adapter: adapter, database: database, schema: schema) do |copy|
80
100
  runtime = Hecks.boot(copy)
81
101
 
82
102
  refusals = []
83
103
  queries = []
84
104
  dry_runs = []
105
+ dry_run_traces = []
85
106
  fan_outs = []
86
107
  guard_checks = []
87
108
  mutation_traces = []
109
+ outbox_traces = []
88
110
 
89
111
  # EVERY AGGREGATE A `for_each` COULD EVER QUERY, resolved ONCE —
90
112
  # `[domain, aggregate_name]` pairs, gleaned from every loaded
@@ -181,13 +203,38 @@ module Hecks
181
203
  # `{"dry_run": verb, "args": …}` — `Dispatcher#dry_run?`: the command
182
204
  # evaluated hypothetically, nothing saved or emitted, no reaction.
183
205
  # Recorded, never a refusal: a refused dry run is an ANSWER.
206
+ #
207
+ # `dry_runs` STAYS EXACTLY `{verb:, ok:, error?:}` — the SAME
208
+ # shape it always had, and the SAME shape `kernel/cli.rs`'s own
209
+ # `dry_run` answers (`{"verb", "ok"}` or `{"verb", "ok": false,
210
+ # "error"}`, that function's own doc comment) — `spec/rust_
211
+ # conformance_spec.rb` compares this array against the compiled
212
+ # binary's own verbatim, so it can never carry a key Rust's own
213
+ # answer does not. The role-gated binding (`as_step_caller`,
214
+ # the SAME `role:`/`actor_id:` a real dispatch gets below) still
215
+ # applies to the dry-run call itself — only what gets RECORDED
216
+ # about it is unchanged.
217
+ #
218
+ # `dry_run_traces` — A SEPARATE, PARALLEL array (same order,
219
+ # not merged into `dry_runs` above) carrying `before:`/`after:`
220
+ # snapshots of the whole observable store (every instance, the
221
+ # event count) on either side of the hypothetical call, so
222
+ # `Properties.dry_runs_leave_no_trace` can hold `Dispatcher
223
+ # #dry_run?`'s own contract to the store rather than trusting
224
+ # it. Ruby's own oracle data — Rust has nothing to compare it
225
+ # against, so it stays out of the compared surface entirely.
184
226
  if (hypothetical = step["dry_run"])
227
+ before = { instances: snapshot_instances(runtime), events: runtime.events.size }
228
+ entry = { verb: hypothetical }
185
229
  begin
186
- runtime.dry_run?(hypothetical, **args)
187
- dry_runs << { verb: hypothetical, ok: true }
230
+ as_step_caller(step) { runtime.dry_run?(hypothetical, **args) }
231
+ entry[:ok] = true
188
232
  rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
189
- dry_runs << { verb: hypothetical, ok: false, error: e.message }
233
+ entry.merge!(ok: false, error: e.message)
190
234
  end
235
+ after = { instances: snapshot_instances(runtime), events: runtime.events.size }
236
+ dry_runs << entry
237
+ dry_run_traces << entry.merge(before: before, after: after)
191
238
  next
192
239
  end
193
240
 
@@ -203,6 +250,32 @@ module Hecks
203
250
  # named ask.
204
251
  reaction_mark = runtime.reactions.size
205
252
 
253
+ # THE OUTBOX ORACLE'S OWN LOW-WATER MARKS — taken before
254
+ # dispatch, same idiom as `reaction_mark` right above:
255
+ # `saga_log_mark` slices `runtime.sagas` (a single flat
256
+ # array, safe to index into directly) the identical way
257
+ # `reaction_mark` already slices `runtime.reactions`.
258
+ # `outbox_before_ids` is a SET OF delivery_ids, not a
259
+ # size — `runtime.outbox.rows` concatenates every bound
260
+ # repository's own array in a FIXED per-store order
261
+ # (`Outbox::Relay#rows`, `stores.flat_map`), so a row a
262
+ # DIFFERENT step's dispatch enqueues into an
263
+ # earlier-iterated store would land in the MIDDLE of
264
+ # that concatenated list, not at its tail — a plain
265
+ # "grew from N to M, take the tail" slice (the shape
266
+ # `reaction_mark`/`saga_log_mark` both get away with,
267
+ # since `reaction_log`/`saga_log` are each already ONE
268
+ # flat array irrespective of domain) would silently miss
269
+ # or misattribute rows the moment more than one
270
+ # repository has an outbox. `delivery_id` is unique per
271
+ # store by construction (`Row#to_h`'s own header;
272
+ # `hecks_outbox`'s `UNIQUE` column in the Postgres/Sqlite
273
+ # DDL, `outbox_enqueue`'s own de-dup check in Memory), so
274
+ # membership in this set is exactly "existed before this
275
+ # step's own dispatch ran."
276
+ saga_log_mark = runtime.sagas.size
277
+ outbox_before_ids = runtime.outbox.rows.map(&:delivery_id)
278
+
206
279
  # THE SNAPSHOT A `for_each` QUERY WOULD HAVE SEEN — taken
207
280
  # BEFORE this step's own dispatch, not after. The real
208
281
  # `deliver_for_each` runs its query SYNCHRONOUSLY, inside
@@ -249,22 +322,41 @@ module Hecks
249
322
  # all, or one whose identity args don't resolve).
250
323
  mutation_trace = build_mutation_trace(runtime, step["verb"], args)
251
324
 
252
- # `role:` — an OPTIONAL per-step key, absent on every one of
253
- # the 231 existing `spec/corpus/*.json` steps (their own
325
+ # `role:`/`actor_id:` — OPTIONAL per-step keys, absent on every
326
+ # one of the 231 existing `spec/corpus/*.json` steps (their own
254
327
  # unwrapped `runtime.dispatch` call, unchanged, so nothing
255
328
  # already pinned changes behavior). Binds the SAME ambient
256
329
  # caller `refuse_role_mismatch` reads (`Hecks.as_caller`,
257
330
  # `Runtime::Caller.as`) for exactly the one dispatch this
258
331
  # step makes, then unbinds — mirrors `Caller.as`'s own
259
332
  # `ensure`-restore, so back-to-back steps with different (or
260
- # no) `role:` never leak into each other.
261
- result = if step["role"]
262
- Hecks.as_caller(role: step["role"]) { runtime.dispatch(step["verb"], **args) }
263
- else
264
- runtime.dispatch(step["verb"], **args)
265
- end
333
+ # no) `role:` never leak into each other. `actor_id:` is the
334
+ # sibling `kernel/cli.rs` already read (its own comment on
335
+ # the key): with it, a Governance-attached domain runs the
336
+ # real `holds_role?` lookup instead of the string fallback.
337
+ result = as_step_caller(step) { runtime.dispatch(step["verb"], **args) }
266
338
 
267
339
  fan_outs.concat(fan_out_findings(runtime, fan_out_snapshot, result.events, runtime.reactions[reaction_mark..]))
340
+
341
+ # THE OUTBOX ORACLE'S OWN CAPTURE — every outbox row THIS
342
+ # STEP'S OWN dispatch newly wrote (across every bound
343
+ # repository, including any a reaction cascade touched —
344
+ # `outbox_before_ids` was taken before `dispatch`, which
345
+ # is the same call that runs the whole cascade
346
+ # synchronously, `reenter` included), paired with the
347
+ # `reaction_log`/`saga_log` rows that same dispatch
348
+ # produced. Skipped entirely when empty — a step whose
349
+ # own aggregate has no outbox enqueues nothing here, and
350
+ # there is nothing for `Properties.outbox_rows_match_
351
+ # reactions` to check for it (its own reactions, if any,
352
+ # went through the direct, pre-outbox path instead).
353
+ outbox_new_rows = runtime.outbox.rows.reject { |row| outbox_before_ids.include?(row.delivery_id) }
354
+ if outbox_new_rows.any?
355
+ outbox_traces << { verb: step["verb"], rows: outbox_new_rows.map(&:to_h),
356
+ reactions: runtime.reactions[reaction_mark..].dup,
357
+ sagas: runtime.sagas[saga_log_mark..].dup }
358
+ end
359
+
268
360
  guard_checks << guard_check.merge(actual_refused: false, actual_kind: nil) if guard_check
269
361
  # AFTER — only on SUCCESS ; a refused step mutated nothing,
270
362
  # so there is no "after" to compare (and #build_mutation_
@@ -346,16 +438,34 @@ module Hecks
346
438
  # whichever one happened to load first. A property that needs
347
439
  # to resolve a verb back to its OWN declaring bluebook — not
348
440
  # "the" bluebook — reads this instead.
349
- { instances: instances, events: events, refusals: refusals,
350
- reactions: runtime.reactions, sagas: runtime.sagas, saga_instances: saga_instances,
351
- queries: queries, dry_runs: dry_runs, fan_outs: fan_outs, guard_checks: guard_checks,
352
- mutation_traces: mutation_traces,
353
- saga_dispatches: runtime.saga_dispatches, policy_dispatches: runtime.policy_dispatches,
354
- bluebook: runtime.registry.bluebooks.values.first,
355
- bluebooks: runtime.registry.bluebooks.dup }
441
+ history = { instances: instances, events: events, refusals: refusals,
442
+ reactions: runtime.reactions, sagas: runtime.sagas, saga_instances: saga_instances,
443
+ queries: queries, dry_runs: dry_runs, dry_run_traces: dry_run_traces,
444
+ fan_outs: fan_outs, guard_checks: guard_checks,
445
+ mutation_traces: mutation_traces, outbox_traces: outbox_traces,
446
+ saga_dispatches: runtime.saga_dispatches, policy_dispatches: runtime.policy_dispatches,
447
+ bluebook: runtime.registry.bluebooks.values.first,
448
+ bluebooks: runtime.registry.bluebooks.dup }
449
+
450
+ # `runtime` IS STILL LIVE HERE — this is the one and only place
451
+ # it is. See `SelfConsistency`'s own header for why this needs
452
+ # to happen NOW, against the SAME registry/repositories this
453
+ # replay's own dispatch loop just populated, not a second boot.
454
+ history[:self_consistency] = SelfConsistency.check(runtime, history) if self_consistency
455
+
456
+ history
356
457
  end
357
458
  end
358
459
 
460
+ # A step with no `role:` dispatches exactly as every corpus step
461
+ # always has — bare, no caller bound at all (`Caller.current` nil,
462
+ # so `refuse_role_mismatch` returns before checking anything).
463
+ def as_step_caller(step, &)
464
+ return yield unless step["role"]
465
+
466
+ Hecks.as_caller(role: step["role"], actor_id: step["actor_id"], &)
467
+ end
468
+
359
469
  # THE GUARD ORACLE'S OWN RESOLUTION — "which record, if any, is
360
470
  # this step about, and would enforce_givens/enforce_lifecycle_guard
361
471
  # have refused it against that record's PRE-DISPATCH state" —
@@ -0,0 +1,94 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # THE WEIGHTED HALF OF `QualityControl::Target.Rotation`
4
+ # (qa/bluebook/quality_control.bluebook) — see that query's own
5
+ # comment for why the ledger itself still only offers
6
+ # least-recently-swept order: `order_by` sorts by exactly one stored
7
+ # field, and a target's actual rotation priority wants two blended
8
+ # together (time since last swept, AND recent yield), which nothing
9
+ # in the query sublanguage can express. `Rotation` stays the raw
10
+ # view; this module is where the blend actually happens, against the
11
+ # rows that query already returns.
12
+ #
13
+ # PURE, DELIBERATELY. Every method here is a function of its own
14
+ # arguments only — no clock read, no query dispatched, nothing
15
+ # random — so this is unit-testable with plain hashes and no ledger
16
+ # boot at all, and reruns identically given the same rotation, the
17
+ # same `now` and the same dials. `now` arrives as an argument for
18
+ # the exact reason `Target.Claim`'s own comment already gives: a
19
+ # pure function cannot ask the time, so whoever calls this supplies
20
+ # it, exactly as it supplies an id.
21
+ #
22
+ # WHERE THIS ACTUALLY MATTERS, AND WHERE IT DOES NOT — checked
23
+ # against `bin/qa_sweep`'s own code, not assumed. `bin/qa_sweep
24
+ # --all` sweeps every currently-waiting target in one pass,
25
+ # regardless of order (`run_all_mode`'s own `waiting.map { spawn_
26
+ # sweep_child }` — every element the query returned, never
27
+ # `.first`), so nothing about this module's own ordering changes
28
+ # what `--all` does; it already sweeps everything the ledger has to
29
+ # offer. What it changes is `bin/qa_sweep` invoked with NO target
30
+ # argument, which today picks exactly one target via `Target.
31
+ # Rotation.first` — wiring `.pick` in there instead is this module's
32
+ # one real caller, and (per the same investigation) its only one:
33
+ # there is no separate scheduling or concurrency-limit mechanism
34
+ # elsewhere in this repository for it to feed instead.
35
+ module RotationPriority
36
+ module_function
37
+
38
+ # THE NEXT STORED `Target.yield_score` — decay what survives from
39
+ # before, then add whatever this just-concluded period actually
40
+ # found. Integer division, like every other count in this ledger
41
+ # (`Sweep.CheckCount`, `Bug.BugOrder`): a yield score is a
42
+ # priority signal, not a precise statistic, and a fractional score
43
+ # would need a value object this ledger does not have.
44
+ #
45
+ # `decay_percent` DEFAULTS TO THE DIAL, NOT A LITERAL, so a caller
46
+ # that does not care still gets the practice's own current answer
47
+ # to "how much of this is 'recent'" rather than a second, silently
48
+ # drifting copy of the same number.
49
+ def next_yield_score(old_score:, surprises_this_period:,
50
+ decay_percent: QualityControlDials::YIELD_DECAY_PERCENT)
51
+ raise ArgumentError, "old_score must not be negative" if old_score.negative?
52
+ raise ArgumentError, "surprises_this_period must not be negative" if surprises_this_period.negative?
53
+
54
+ ((old_score * decay_percent) / 100) + surprises_this_period
55
+ end
56
+
57
+ # THE PICK ITSELF. `rows` is whatever `Target.Rotation` returned —
58
+ # each row a hash carrying at least `:last_swept` and
59
+ # `:yield_score`, both `{ value: Integer }`, the shape every
60
+ # VO-typed field in this ledger already comes back as. Returns
61
+ # `nil` for an empty rotation, the same "nothing waiting" case
62
+ # `bin/qa_sweep` already handles by seeding the default targets.
63
+ #
64
+ # THE FLOOR WINS OUTRIGHT — no starvation, on purpose (see
65
+ # `QualityControlDials::ROTATION_STALE_FLOOR_SECONDS`'s own
66
+ # comment for why a floor and not a total exclusion). A row stale
67
+ # past `floor_seconds` is picked ahead of every row that is not,
68
+ # oldest-first among however many have crossed it — the same
69
+ # tiebreak `Rotation` itself already uses for everything, which
70
+ # keeps the floor from becoming a new source of starvation between
71
+ # two long-neglected targets.
72
+ #
73
+ # BELOW THE FLOOR, HIGHEST COMBINED SCORE WINS. `staleness +
74
+ # (yield_score * weight_seconds)` — plain addition, never a ratio
75
+ # or anything that could divide by zero, and the units are
76
+ # deliberately the same (seconds) so `weight_seconds` reads as
77
+ # "how many seconds of extra staleness one point of yield is
78
+ # worth" rather than an opaque multiplier nobody could sanity
79
+ # check by eye.
80
+ def pick(rows, now:, weight_seconds: QualityControlDials::YIELD_WEIGHT_SECONDS,
81
+ floor_seconds: QualityControlDials::ROTATION_STALE_FLOOR_SECONDS)
82
+ return nil if rows.empty?
83
+
84
+ floored = rows.select { |row| staleness(row, now) >= floor_seconds }
85
+ return floored.min_by { |row| row[:last_swept][:value] } unless floored.empty?
86
+
87
+ rows.max_by { |row| staleness(row, now) + (row[:yield_score][:value] * weight_seconds) }
88
+ end
89
+
90
+ def staleness(row, now) = now - row[:last_swept][:value]
91
+ private_class_method :staleness
92
+ end
93
+ end
94
+ end