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
@@ -15,11 +15,12 @@ module Hecks
15
15
  parent_scalar = identity_scalar_of(aggregate, args)
16
16
 
17
17
  @known_ids[aggregate.hecks_name] << parent_scalar if entry[:entity].nil? && entry[:command].creates?
18
+ record_grant(args) if entry[:verb] == Adversary::GRANT_VERB
18
19
 
19
- populator = catalog[:populators].find { |p| p[:command].equal?(entry[:command]) && p[:aggregate].equal?(aggregate) }
20
+ populator = populator_for_entry(catalog, entry)
20
21
  return unless populator
21
22
 
22
- key = "#{aggregate.hecks_name}.#{populator[:entity].hecks_name}##{parent_scalar}"
23
+ key = append_pool_key(populator, args)
23
24
 
24
25
  # The auto-minted case (CommandInterpreter#entity_element): the
25
26
  # element just landed at count-so-far + 1. The explicit case: this
@@ -33,6 +34,51 @@ module Hecks
33
34
  (@entity_known_ids[key].size + 1).to_s
34
35
  end
35
36
  @entity_known_ids[key] << new_id
37
+
38
+ # THE CALLER-SUPPLIED IDENTITY TUPLE, kept whole — every mapped
39
+ # identity argument and the exact value this step offered for it
40
+ # — so the adversarial duplicate-identity mutation (BUG#13's own
41
+ # shape, `Folder.AddSlip` twice under the same `reference`) can
42
+ # offer it AGAIN later against the same parent, composite
43
+ # identities included (BUG#13's fix explicitly did NOT cover
44
+ # those; this is how a sequence gets to ask).
45
+ return if populator[:identity_arguments].empty?
46
+
47
+ @appended_identities[key] << populator[:identity_arguments].to_h { |name| [name.to_s, args[name.to_s]] }
48
+ end
49
+
50
+ # A GRANT THIS SEQUENCE MADE FOR REAL — `Governance::RoleAssignment.
51
+ # Assign` succeeded with these exact args, so `actor_id` now holds
52
+ # `role_name` in this boot's own store, on both replay sides. The
53
+ # `actor_known` caller shape (adversary.rb) replays that identity
54
+ # against a command gated on the same role: the one way a
55
+ # generated step reaches `holds_role?`'s AUTHORIZED branch rather
56
+ # than only its refusing one.
57
+ def record_grant(args)
58
+ role = ValueGenerator.scalar_of(args["role_name"]).to_s
59
+ actor = ValueGenerator.scalar_of(args["actor_id"]).to_s
60
+ @granted[role] << actor unless role.empty? || actor.empty?
61
+ end
62
+
63
+ # THE POOL AN APPENDED ELEMENT LANDS IN — the aggregate's own
64
+ # identity, then one scalar per owning hop (an aggregate-level
65
+ # append has none; `Board.AddCard` has `Board`'s own `number`,
66
+ # read straight back off the args this step addressed it by).
67
+ def append_pool_key(populator, args)
68
+ parent_scalar = identity_scalar_of(populator[:aggregate], args)
69
+ owner_scalars = populator[:owner_chain].map do |piece|
70
+ ValueGenerator.scalar_of(args[(piece.identified_by || :id).to_s])
71
+ end
72
+ entity_pool_key(populator[:aggregate].hecks_name,
73
+ populator[:owner_chain].map(&:hecks_name) + [populator[:entity].hecks_name],
74
+ [parent_scalar] + owner_scalars)
75
+ end
76
+
77
+ # `"Agg.Board#w1"` for a depth-1 pool — byte-identical to the key
78
+ # this always used, so nothing a pinned seed draws from moves —
79
+ # and `"Agg.Board.Card#w1/1"` one hop deeper.
80
+ def entity_pool_key(aggregate_name, chain_names, scalars)
81
+ "#{aggregate_name}.#{chain_names.join('.')}##{scalars.join('/')}"
36
82
  end
37
83
 
38
84
  # THE SCALAR THIS STEP'S OWN AGGREGATE IDENTITY RESOLVES TO, from the
@@ -68,8 +114,8 @@ module Hecks
68
114
  pool.sample(random: @random)
69
115
  end
70
116
 
71
- def pick_entity_known(aggregate_name, entity_name, parent_id)
72
- pool = @entity_known_ids["#{aggregate_name}.#{entity_name}##{parent_id}"]
117
+ def pick_entity_known(key)
118
+ pool = @entity_known_ids[key]
73
119
  return ValueGenerator.random_id(@random) if pool.empty? || @random.rand < ValueGenerator::INVALID_REFERENCE_PROBABILITY
74
120
 
75
121
  pool.sample(random: @random)
@@ -16,10 +16,21 @@ module Hecks
16
16
 
17
17
  makers = catalog[:creating].select { |entry| satisfiable?(catalog, entry) }
18
18
  pool = rest + makers.flat_map { |entry| [entry] * creating_weight(rest.size) }
19
+ pool += deep_entity_bias(rest) if adversarial?
19
20
 
20
21
  steer(pool).sample(random: @random)
21
22
  end
22
23
 
24
+ # BUG#11's own preference (adversary.rb) — an entity command two
25
+ # or more hops deep is weighted up the same way an unexercised
26
+ # verb is, ONLY in adversarial mode: the eligibility rules above
27
+ # still decide what is possible, and a default-mode pool is
28
+ # exactly the pool it always was.
29
+ def deep_entity_bias(rest)
30
+ deep = rest.select { |entry| (entry[:chain] || []).size >= Adversary::DEEP_ENTITY_DEPTH }
31
+ deep * Adversary::DEEP_ENTITY_WEIGHT
32
+ end
33
+
23
34
  # WHILE THERE IS NOTHING TO FIND, MAKING SOMETHING IS THE ONLY USEFUL MOVE.
24
35
  #
25
36
  # A flat weight is right once the domain has records in it, and badly
@@ -40,16 +40,66 @@ module Hecks
40
40
  { "query" => entry[:verb], "args" => args }
41
41
  end
42
42
 
43
+ # THE ADVERSARIAL LAYER SITS HERE, AND ONLY HERE — after the step's
44
+ # arguments and identity are fully built, BEFORE the one real
45
+ # dispatch this generator makes to learn what the step did, and
46
+ # before the step is returned as corpus data. That ordering is
47
+ # the whole contract: the mutated `args` are what this generator's
48
+ # own inline dispatch sees (so `known_ids` tracking reflects what
49
+ # actually happened), AND they are the bytes `Fuzzing::Replay`
50
+ # later hands Ruby's runtime and the bytes `JSON.generate({steps:
51
+ # ...})` hands the compiled Rust binary — one step, one payload,
52
+ # both engines. Nothing downstream of this method can tell a
53
+ # mutated step from an ordinary one except by reading the
54
+ # `"adversarial"` metadata it carries (see adversary.rb).
55
+ #
56
+ # TWO MORE DRAWS SIT RIGHT AFTER THE MUTATION, IN A FIXED ORDER —
57
+ # a caller (`caller_draw!`, adversary.rb: `role:`/`actor_id:` on
58
+ # the step, bound around this one dispatch exactly the way
59
+ # `Fuzzing::Replay` and `kernel/cli.rs` will later bind it) and
60
+ # then the dry-run coin (`{"dry_run": verb}` instead of
61
+ # `{"verb": verb}` — `Dispatcher#dry_run?` here, the same door on
62
+ # both replay sides). Order matters for the seed contract: every
63
+ # draw comes from the one `Random.new(seed)`, so the sequence of
64
+ # draws per step is what makes a seed reproducible. Both are
65
+ # off by default and draw nothing when off.
43
66
  def build_command_step(runtime, catalog, entry)
44
67
  args = args_for(entry[:command].attributes, entry[:aggregate])
45
68
  add_identity!(args, entry)
69
+ steer_grant!(args, entry, catalog)
70
+ mutations = adversarial_mutations!(args, entry, catalog)
71
+ caller, caller_note = caller_draw!(entry, catalog)
72
+ mutations << caller_note if caller_note
46
73
 
47
- outcome = safe_call { runtime.dispatch(entry[:verb], **symbolize(args)) }
48
- if outcome
49
- record_outcome(catalog, entry, args)
50
- @event_count += outcome.events.length
51
- end
52
- { "verb" => entry[:verb], "args" => args }
74
+ step =
75
+ if dry_run_draw?
76
+ safe_call { as_caller(caller) { runtime.dry_run?(entry[:verb], **symbolize(args)) } }
77
+ { "dry_run" => entry[:verb], "args" => args }
78
+ else
79
+ outcome = safe_call { as_caller(caller) { runtime.dispatch(entry[:verb], **symbolize(args)) } }
80
+ if outcome
81
+ record_outcome(catalog, entry, args)
82
+ @event_count += outcome.events.length
83
+ end
84
+ { "verb" => entry[:verb], "args" => args }
85
+ end
86
+ step.merge!(caller) if caller
87
+ step["adversarial"] = mutations unless mutations.empty?
88
+ step
89
+ end
90
+
91
+ # No RNG draw at all when the fraction is zero — the same "off is
92
+ # byte-identical" contract `adversarial?` keeps.
93
+ def dry_run_draw? = @dry_run.positive? && @random.rand < @dry_run
94
+
95
+ # `Hecks.as_caller` for exactly this block, or a bare yield — the
96
+ # SAME binding `Fuzzing::Replay` makes from the step's own keys
97
+ # later, so the generator's inline dispatch and both replays see
98
+ # one caller.
99
+ def as_caller(caller, &)
100
+ return yield unless caller
101
+
102
+ Hecks.as_caller(role: caller["role"], actor_id: caller["actor_id"], &)
53
103
  end
54
104
 
55
105
  def args_for(attributes, aggregate)
@@ -154,9 +204,23 @@ module Hecks
154
204
  if entry[:entity]
155
205
  parent_scalar = pick_known(aggregate.hecks_name)
156
206
  args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, parent_scalar, aggregate)
157
- entity_key = (entry[:entity].identified_by || :id).to_s
158
- entity_scalar = pick_entity_known(aggregate.hecks_name, entry[:entity].hecks_name, parent_scalar)
159
- args[entity_key] = identity_shaped(entry[:entity], entry[:entity].identified_by, entity_scalar, aggregate)
207
+ # ONE IDENTITY PER HOP, each drawn from the pool its OWN
208
+ # parent-plus-hops landed elements in (`entity_pool_key`)
209
+ # a depth-1 chain draws exactly what it always did; a
210
+ # `Board.Card` chain draws a Board under this Workspace, then
211
+ # a Card under THAT Board. Flat args, one head per hop, is
212
+ # the legacy addressing `EntityElement#locate_chain` reads
213
+ # (`args[head]` per identity path); the routed `to: {
214
+ # aggregate:, entities: [...] }` spelling is an adversarial
215
+ # shape layered on top (adversary.rb), never the default.
216
+ scalars = [parent_scalar]
217
+ names = []
218
+ entry[:chain].each do |piece|
219
+ names << piece.hecks_name
220
+ scalar = pick_entity_known(entity_pool_key(aggregate.hecks_name, names, scalars))
221
+ args[(piece.identified_by || :id).to_s] = identity_shaped(piece, piece.identified_by, scalar, aggregate)
222
+ scalars << scalar
223
+ end
160
224
  elsif entry[:command].creates?
161
225
  # A COMPOSITE IDENTITY (`identified_by` answering nil with MORE
162
226
  # THAN ONE declared path — Behaviour::Identified's own "a
@@ -7,6 +7,7 @@ require_relative "sequence_generator/catalog"
7
7
  require_relative "sequence_generator/picker"
8
8
  require_relative "sequence_generator/step_builder"
9
9
  require_relative "sequence_generator/outcome_tracker"
10
+ require_relative "sequence_generator/adversary"
10
11
 
11
12
  module Hecks
12
13
  module Fuzzing
@@ -28,13 +29,16 @@ module Hecks
28
29
  #
29
30
  # One concern per file beside this one: what the domain offers
30
31
  # (sequence_generator/catalog.rb), which step to try next (picker.rb),
31
- # how a step is built and dispatched (step_builder.rb), and what a
32
- # success taught us (outcome_tracker.rb).
32
+ # how a step is built and dispatched (step_builder.rb), what a
33
+ # success taught us (outcome_tracker.rb), and — opt-in, `adversarial:`
34
+ # — the argument shapes real Ruby/Rust divergences were found through
35
+ # (adversary.rb).
33
36
  class SequenceGenerator
34
37
  include Catalog
35
38
  include Picker
36
39
  include StepBuilder
37
40
  include OutcomeTracker
41
+ include Adversary
38
42
 
39
43
  # Creating commands are always eligible ; weighting them heavier (not
40
44
  # exclusively — a domain with only one or two aggregates would starve
@@ -72,8 +76,22 @@ module Hecks
72
76
  # the generating end rather than the scoring end.
73
77
  UNEXERCISED_WEIGHT = 4
74
78
 
75
- def self.generate(domain_path, seed:, steps:, adapter: :memory)
76
- new(domain_path, seed: seed, steps: steps, adapter: adapter).call
79
+ # `adversarial:` the fraction of generated COMMAND steps (0.0..1.0)
80
+ # that get one deliberately adversarial argument mutation
81
+ # (adversary.rb — the shapes BUG#7–#16 were found through). `0.0`,
82
+ # the default, draws nothing extra from the seeded RNG, so a seed's
83
+ # output is byte-for-byte what it was before the option existed;
84
+ # any positive value is just as deterministic per seed, since every
85
+ # choice the mutation makes comes from the same `Random.new(seed)`.
86
+ #
87
+ # `role_draw:` / `dry_run:` — two more opt-in fractions with the
88
+ # identical contract (adversary.rb's `caller_draw!`, step_builder.rb's
89
+ # `dry_run_draw?`): `0.0` draws nothing, so every pinned seed is
90
+ # byte-for-byte what it was; `bin/qa_sweep` reads them from
91
+ # `QualityControlDials::ROLE_DRAW_PROBABILITY`/`DRY_RUN_FRACTION`.
92
+ def self.generate(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
93
+ new(domain_path, seed: seed, steps: steps, adapter: adapter, adversarial: adversarial,
94
+ role_draw: role_draw, dry_run: dry_run).call
77
95
  end
78
96
 
79
97
  # How many EVENTS the generated sequence actually produced — not
@@ -86,16 +104,31 @@ module Hecks
86
104
  # replay one.
87
105
  attr_reader :event_count
88
106
 
89
- def initialize(domain_path, seed:, steps:, adapter: :memory)
90
- @domain_path = domain_path
91
- @seed = seed
92
- @step_count = steps
93
- @adapter = adapter
94
- @random = Random.new(seed)
95
- @known_ids = Hash.new { |h, k| h[k] = [] }
96
- @entity_known_ids = Hash.new { |h, k| h[k] = [] }
97
- @exercised = Set.new
98
- @event_count = 0
107
+ def initialize(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
108
+ { adversarial: adversarial, role_draw: role_draw, dry_run: dry_run }.each do |name, fraction|
109
+ next if fraction.is_a?(Numeric) && fraction.between?(0, 1)
110
+
111
+ raise ArgumentError, "#{name}: must be a fraction between 0.0 and 1.0, got #{fraction.inspect}"
112
+ end
113
+
114
+ @domain_path = domain_path
115
+ @seed = seed
116
+ @step_count = steps
117
+ @adapter = adapter
118
+ @adversarial = adversarial.to_f
119
+ @role_draw = role_draw.to_f
120
+ @dry_run = dry_run.to_f
121
+ @random = Random.new(seed)
122
+ @known_ids = Hash.new { |h, k| h[k] = [] }
123
+ @entity_known_ids = Hash.new { |h, k| h[k] = [] }
124
+ @appended_identities = Hash.new { |h, k| h[k] = [] }
125
+ # ROLE => [actor ids] this sequence's own successful
126
+ # `Governance::RoleAssignment.Assign` steps granted — what the
127
+ # `actor_known` caller shape draws from (adversary.rb).
128
+ @granted = Hash.new { |h, k| h[k] = [] }
129
+ @precedence_caller = nil
130
+ @exercised = Set.new
131
+ @event_count = 0
99
132
  end
100
133
 
101
134
  def call
@@ -0,0 +1,146 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # WHAT A "NOT GENERATED FOR THIS DOMAIN" REFUSAL IS HIDING, PER VERB.
4
+ #
5
+ # `RustConformanceHelpers#structurally_refused_verbs` drops every named
6
+ # query/read model the compiled Rust kernel answers "is not generated
7
+ # for this domain" from BOTH sides of the differential comparison —
8
+ # correct (the refusal is codegen's own honest "I cannot execute this
9
+ # construct at all", `rust/project/queries.rb`'s and `read_models.rb`'s
10
+ # documented boundary), but SILENT: a sweep over a domain whose every
11
+ # `offset`/`cursor`/`group_by`/`count`/`median` ask was dropped reads
12
+ # as "agreed across all steps" with nothing on the record saying how
13
+ # much of the domain was never compared at all. Worse, the drop keys
14
+ # on Rust's wording alone, so a codegen regression that started
15
+ # refusing a plain wheres-only query the same way would ALSO vanish
16
+ # into "agreed" — the exact quiet divergence the practice hunts.
17
+ #
18
+ # This module attributes each dropped verb to the constructs its OWN
19
+ # Ruby declaration carries, in the vocabulary `queries.rb`/
20
+ # `read_models.rb`'s skip reasons already use, so `bin/qa_sweep` can
21
+ # log one Check per sweep saying exactly which verbs were skipped and
22
+ # WHY — and mark it Surprised whenever a skipped verb declares nothing
23
+ # the dial `QualityControlDials::STRUCTURAL_REFUSAL_BOUNDARY` admits as
24
+ # a reason to skip (a wheres-only query on plain fields, a rooted
25
+ # read model with nothing but heads, a verb Ruby doesn't even declare).
26
+ #
27
+ # COARSER THAN CODEGEN'S OWN PREDICATE, ON PURPOSE. `query_where_skip_
28
+ # reason` decides per where clause from the field's resolved KIND
29
+ # (number/string/multi-member value object) — reproducing that here
30
+ # would be a second copy of codegen's typing rules, drifting on its
31
+ # own. Instead a literal-valued where is reported as `where_literal`,
32
+ # a family the dial admits, and only shapes with NO admitted family at
33
+ # all surprise. That trades some sensitivity for zero false surprises
34
+ # on today's corpus; the Check's observation still names every skipped
35
+ # verb with its constructs, so a human reading the sweep sees what was
36
+ # accepted and can narrow the dial when codegen grows.
37
+ module StructuralSkips
38
+ module_function
39
+
40
+ # `Query#to_h`/`ReadModel#to_h` keys that, when present and
41
+ # non-empty, are constructs codegen documents as (conditionally or
42
+ # wholly) ungenerated — the same names as the dial's own vocabulary.
43
+ QUERY_OPTION_KEYS = %i[order_by limit offset cursor consistency freshness authorization inspection
44
+ index_hints scope_to].freeze
45
+ READ_MODEL_OPTION_KEYS = %i[wheres order_by limit offset cursor consistency freshness authorization inspection
46
+ index_hints group_by count median_field].freeze
47
+
48
+ # One entry per skipped verb: `{ verb:, constructs: [...] }`, the
49
+ # constructs sorted so the printed observation is stable across
50
+ # seeds and runs.
51
+ def attribute(bluebooks, verbs)
52
+ verbs.sort.map { |verb| { verb: verb, constructs: constructs_of(bluebooks, verb) } }
53
+ end
54
+
55
+ # Every skipped verb whose constructs are NOT all inside `boundary`
56
+ # — including a verb with no constructs at all (nothing explains the
57
+ # skip) and a verb Ruby never declared (`unknown`).
58
+ def outside_boundary(attributed, boundary)
59
+ admitted = boundary.map(&:to_s)
60
+ attributed.select { |entry| entry[:constructs].empty? || (entry[:constructs] - admitted).any? }
61
+ end
62
+
63
+ def constructs_of(bluebooks, verb)
64
+ if verb.include?("::")
65
+ query = find_query(bluebooks, verb)
66
+ return %w[unknown] unless query
67
+
68
+ query_constructs(query)
69
+ else
70
+ model = find_read_model(bluebooks, verb)
71
+ return %w[unknown] unless model
72
+
73
+ read_model_constructs(model)
74
+ end
75
+ end
76
+
77
+ # "Domain::Aggregate.Query" or "Domain::Aggregate.Entity.Query" — the
78
+ # two named-query spellings `Fuzzing::SequenceGenerator`'s catalog
79
+ # generates (entity queries stay one hop deep there).
80
+ def find_query(bluebooks, verb)
81
+ domain, rest = verb.split("::", 2)
82
+ aggregate_name, *path = rest.to_s.split(".")
83
+ aggregate = bluebooks[domain]&.aggregate(aggregate_name)
84
+ return nil unless aggregate && path.any?
85
+
86
+ owner = aggregate
87
+ path[0...-1].each do |entity_name|
88
+ owner = owner.entities.find { |entity| entity.hecks_name == entity_name }
89
+ return nil unless owner
90
+ end
91
+ owner.query(path.last)
92
+ end
93
+
94
+ # "Domain.report_name" — the bare read-model form `Dispatcher#query`
95
+ # routes by the absence of "::".
96
+ def find_read_model(bluebooks, verb)
97
+ domain, name = verb.split(".", 2)
98
+ bluebooks[domain]&.read_models&.find { |model| model.query_name.to_s == name.to_s }
99
+ end
100
+
101
+ def query_constructs(query)
102
+ spec = query.to_h
103
+ constructs = QUERY_OPTION_KEYS.select { |key| present?(spec[key]) }.map(&:to_s)
104
+ constructs << "null_semantics" if spec[:null_semantics] && spec[:null_semantics] != { mode: "native" }
105
+ constructs << "no_wheres" if Array(spec[:wheres]).empty?
106
+ constructs.concat(where_constructs(Array(spec[:wheres])))
107
+ constructs.uniq.sort
108
+ end
109
+
110
+ def read_model_constructs(model)
111
+ spec = model.to_h
112
+ constructs = READ_MODEL_OPTION_KEYS.select { |key| present?(spec[key]) }.map(&:to_s)
113
+ constructs << "null_semantics" if spec[:null_semantics] && spec[:null_semantics] != { mode: "native" }
114
+ constructs << "rootless" if model.reference_target.nil?
115
+ constructs.concat(where_constructs(Array(spec[:wheres])))
116
+ constructs.uniq.sort
117
+ end
118
+
119
+ # The where-clause families `query_where_skip_reason` refuses by:
120
+ # a `/` hop through a reference, a `.` walk into a nested field
121
+ # (an entity-scoped or value-object path codegen may not resolve),
122
+ # `none_in_state` (admitted by the vocabulary, deliberately not
123
+ # generated), and any literal (non-Symbol) value, whose true wire
124
+ # type codegen may not recover from the IR.
125
+ def where_constructs(wheres)
126
+ wheres.flat_map do |where|
127
+ field = where[:field].to_s
128
+ value = where[:value].to_s
129
+ families = []
130
+ families << "reference_hop_where" if field.include?("/")
131
+ families << "where_nested_field" if field.include?(".")
132
+ families << "where_none_in_state" if where[:op].to_s == "none_in_state"
133
+ families << "where_literal" unless value.start_with?(":")
134
+ families
135
+ end
136
+ end
137
+
138
+ def present?(value)
139
+ return false if value.nil? || value == false
140
+ return value.any? if value.respond_to?(:empty?)
141
+
142
+ true
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,53 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # HOW HARD ONE SWEEP FUZZES, AS A FUNCTION OF `Target.clean_streak`
4
+ # (qa/bluebook/quality_control.bluebook — that attribute's own comment
5
+ # says what the streak means). The sibling of `RotationPriority`: the
6
+ # ledger holds the number, `QualityControlDials::WIDENING_TIERS` holds
7
+ # the policy, and this module is where the two meet — a pure function
8
+ # of both, nothing else read.
9
+ #
10
+ # WHY THIS IS NOT IN `bin/qa_sweep` ANY MORE. It was — `WIDENING_TIERS`
11
+ # and `widen_for_streak` lived at the top of that script, which meant
12
+ # policy data lived in a script (unlike every other dial, which lives
13
+ # in the bluebook a human edits and reviews) and duplicated itself as
14
+ # prose in SKILL.md. Now the table is a dial, this is the one reader,
15
+ # and `bin/qa_sweep` calls it the way it already calls
16
+ # `RotationPriority.pick`.
17
+ #
18
+ # PURE, DELIBERATELY — the same discipline `RotationPriority` keeps:
19
+ # same streak in, same `[seeds, steps]` out, every time, which is what
20
+ # lets a human predict what a given sweep is about to do before it
21
+ # runs one. `tiers:` defaults to the dial but is a plain argument, so
22
+ # a unit spec can pass its own table with no ledger boot at all and a
23
+ # `bin/qa_sweep` run against a ledger that declares no dials (an
24
+ # isolated spec's own fixture) can fall back the same way it already
25
+ # does for `ADVERSARIAL_FRACTION`.
26
+ module SweepDepth
27
+ module_function
28
+
29
+ # THE FALLBACK TABLE, for a boot with no `QualityControlDials` at all
30
+ # — the same shape and the same three rows the dial ships with, so a
31
+ # dial-less ledger fuzzes exactly as a dialled one does by default.
32
+ # Never read when the dial exists; `bin/qa_sweep` passes the dial in.
33
+ DEFAULT_TIERS = [
34
+ { upto: 4, seeds: 10, steps: 25 },
35
+ { upto: 19, seeds: 25, steps: 50 },
36
+ { upto: Float::INFINITY, seeds: 50, steps: 100 }
37
+ ].freeze
38
+
39
+ # `[seeds, steps]` for one streak. The first row whose `upto` the
40
+ # streak does not exceed wins — rows are read in order, so the table
41
+ # must be ascending, and the last row's `Float::INFINITY` is what
42
+ # makes it the ceiling rather than a gap.
43
+ def for_streak(streak, tiers: DEFAULT_TIERS)
44
+ raise ArgumentError, "streak must not be negative" if streak.negative?
45
+
46
+ tier = tiers.find { |row| streak <= row[:upto] }
47
+ raise ArgumentError, "no tier covers a streak of #{streak} — the last row must be upto: Float::INFINITY" unless tier
48
+
49
+ [tier[:seeds], tier[:steps]]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,149 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # WHAT A SWEEP TARGET CAN ACTUALLY BE CHECKED FOR, read off the
4
+ # filesystem — never off a stored list.
5
+ #
6
+ # `bin/qa_sweep` used to decide its ONE comparison mode inline: "is
7
+ # there a Cargo feature named after this directory? then
8
+ # `:differential`, else `:ruby_only`", and a separate hand-typed abort
9
+ # for `--persistence-parity` ("does any .hecksagon bind PostgresEra?").
10
+ # Every further mode the practice adds (era boundary, concurrency, a
11
+ # WASM front) would have grown one more inline `if`, each one a
12
+ # policy decision hiding in a script. This module is those decisions
13
+ # as DATA: `MODE_REQUIREMENTS` says which capabilities each mode
14
+ # needs, `infer` says which capabilities a target's own directory
15
+ # actually has, and `resolve` is the one rule that joins them —
16
+ # `modes_to_run = enabled ∩ eligible`.
17
+ #
18
+ # INFERENCE DECIDES; A STORED LIST ONLY RECORDS. `Target.capabilities`
19
+ # (qa/bluebook/quality_control.bluebook, once PR-1's era lands) is
20
+ # written by the runner from exactly this inference at release time
21
+ # so `Target.EligibleFor(mode)` can AUDIT the rotation from the
22
+ # ledger alone — but the runner re-infers every sweep, because a
23
+ # stored list that lags yesterday's Cargo feature is precisely the
24
+ # "quiet divergence" (the chapter's own opening comment) this whole
25
+ # practice exists to hunt. Nothing here ever reads the ledger.
26
+ #
27
+ # EVERY REGEX IS ONE THE HARNESS ALREADY OWNED, moved here rather than
28
+ # re-derived, and each one's provenance is named beside it so a
29
+ # future edit to the original site is a visible drift, not a silent
30
+ # one: the Cargo feature line (`RustConformanceHelpers#build_rust_for`),
31
+ # the PostgresEra binding (`bin/qa_sweep`'s old `POSTGRES_ERA_BINDING`),
32
+ # the translations glob (`IsolatedBoot#strip_translations!`).
33
+ module TargetCapabilities
34
+ module_function
35
+
36
+ # `RustConformanceHelpers#build_rust_for`'s own test, scoped to the
37
+ # `[features]` table the way `bin/project_rust`'s Cargo sync scopes
38
+ # its own lookup (a `[package] name = "rust"` line must never read
39
+ # as a feature named `rust`).
40
+ FEATURES_TABLE = /^\[features\](?:\n(?!\[).*)*$/
41
+
42
+ # `bin/qa_sweep`'s former `POSTGRES_ERA_BINDING` — both spellings
43
+ # `IsolatedBoot#rewrite_bindings!` has to catch: aggregate-scoped
44
+ # (`Directory::Member.persisted_by("PostgresEra")`) and the bare
45
+ # domain-level default (`persisted_by "PostgresEra"`).
46
+ POSTGRES_ERA_BINDING = /persisted_by\s*\(?\s*"PostgresEra"/
47
+
48
+ # `HecksagonBuilder#uses_framework` — the one line that attaches
49
+ # Governance's own `RoleAssignment` lookup to a domain's `role` checks
50
+ # (`CommandRules::Authorization#governance_attached?`).
51
+ GOVERNANCE_ATTACHED = /uses_framework\s*\(?\s*"Governance"/
52
+
53
+ # A command-level `role "..."` — the only construct
54
+ # `refuse_role_mismatch` ever has anything to check a caller against.
55
+ ROLE_GATED = /^\s*role\s+"/
56
+
57
+ # `authorize :vault_access, tenant: :branch_code`
58
+ # (examples/banking/bluebook/safe_deposit_boxes.bluebook) — the one
59
+ # `tenant:` spelling the corpus has.
60
+ TENANT_SCOPED = /\btenant:/
61
+
62
+ PROCESS_MANAGER = /^\s*process_manager\s+"/
63
+
64
+ # WHICH CAPABILITIES EACH MODE NEEDS BEFORE IT CAN SAY ANYTHING TRUE
65
+ # ABOUT A TARGET. An empty list means "any target at all" — every
66
+ # domain boots under Memory, so Ruby-only properties and the
67
+ # self-consistency pass are always answerable. `ruby_only` is listed
68
+ # requirement-free on purpose and then EXCLUDED by `resolve` whenever
69
+ # `differential` resolved too: they are the same seat, and a compiled
70
+ # Rust binary is strictly the better occupant (item 1 of the
71
+ # detection plan folded the Ruby-only property battery INTO the
72
+ # differential seat, so nothing is lost by the exclusion).
73
+ #
74
+ # The four `false`-by-default modes in `QualityControlDials::MODES`
75
+ # (`adapter_parity_postgres`, `era_boundary`, `concurrency`,
76
+ # `wasm_front`) are named here with their requirements even though
77
+ # nothing runs them yet — so `resolved modes:` can already say, per
78
+ # target, which of them WOULD be eligible the day a human flips the
79
+ # dial, and so flipping it is a one-line data change rather than a
80
+ # code change plus a data change.
81
+ MODE_REQUIREMENTS = {
82
+ differential: %w[rust],
83
+ ruby_only: [],
84
+ self_consistency: [],
85
+ properties_in_differential: %w[rust],
86
+ structural_skip_report: %w[rust],
87
+ adapter_parity_sqlite: %w[sqlite],
88
+ persistence_parity: %w[postgres_era],
89
+ adapter_parity_postgres: %w[postgres_era],
90
+ era_boundary: %w[translations postgres_era],
91
+ concurrency: %w[postgres_era],
92
+ wasm_front: %w[rust]
93
+ }.freeze
94
+
95
+ # MODES THAT NAME A SEPARATE, EXPENSIVE PASS OF THEIR OWN rather than
96
+ # an extra check folded into the ordinary per-seed loop —
97
+ # `bin/qa_sweep` runs these only when asked by name (`--modes
98
+ # persistence_parity`, or its older alias `--persistence-parity`) or
99
+ # as `--all`'s own second wave, never silently inside a plain
100
+ # single-target sweep (the seed cap `PERSISTENCE_PARITY_SEED_CAP`
101
+ # exists because that pass pays for real Postgres I/O per dispatch).
102
+ DEFERRED_MODES = %i[persistence_parity adapter_parity_postgres era_boundary concurrency].freeze
103
+
104
+ # Sorted, plain strings — comma-joined by the runner into the
105
+ # `Target.Release(capabilities:)` value object and printed verbatim
106
+ # on the `resolved modes:` line, so the same spelling is what a
107
+ # human reads, what `--all` parses back, and what the ledger stores.
108
+ def infer(domain_path, rust_dir: File.expand_path("../../../rust", __dir__))
109
+ capabilities = %w[sqlite]
110
+ capabilities << "rust" if rust_feature?(domain_path, rust_dir)
111
+ capabilities << "postgres_era" if any_file?(domain_path, "*.hecksagon", POSTGRES_ERA_BINDING)
112
+ capabilities << "translations" if Dir.glob(File.join(domain_path, "**", "translations", "*.bluebook")).any?
113
+ capabilities << "governance" if any_file?(domain_path, "*.hecksagon", GOVERNANCE_ATTACHED)
114
+ capabilities << "role_gated" if any_file?(domain_path, "*.bluebook", ROLE_GATED)
115
+ capabilities << "tenant" if any_file?(domain_path, "*.bluebook", TENANT_SCOPED)
116
+ capabilities << "sagas" if any_file?(domain_path, "*.bluebook", PROCESS_MANAGER)
117
+ capabilities.sort
118
+ end
119
+
120
+ def eligible?(mode, capabilities)
121
+ required = MODE_REQUIREMENTS.fetch(mode.to_sym) { raise ArgumentError, "unknown sweep mode #{mode.inspect}" }
122
+ (required - capabilities).empty?
123
+ end
124
+
125
+ # THE ONE RULE. `enabled` is whatever the dial (or `--modes`) turned
126
+ # on, in the dial's own declaration order — that order is preserved
127
+ # so the printed line reads the same way the dial does. Then the
128
+ # single exclusion named on `MODE_REQUIREMENTS`.
129
+ def resolve(enabled, capabilities)
130
+ resolved = enabled.map(&:to_sym).select { |mode| eligible?(mode, capabilities) }
131
+ resolved.delete(:ruby_only) if resolved.include?(:differential)
132
+ resolved
133
+ end
134
+
135
+ def rust_feature?(domain_path, rust_dir)
136
+ cargo_toml = File.join(rust_dir, "Cargo.toml")
137
+ return false unless File.file?(cargo_toml)
138
+
139
+ feature = File.basename(domain_path).downcase
140
+ features = File.read(cargo_toml)[FEATURES_TABLE] || ""
141
+ features.match?(/^#{Regexp.escape(feature)}\s*=\s*\[\]/)
142
+ end
143
+
144
+ def any_file?(domain_path, glob, pattern)
145
+ Dir.glob(File.join(domain_path, "**", glob)).any? { |path| File.read(path).match?(pattern) }
146
+ end
147
+ end
148
+ end
149
+ end