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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +23 -2
data/lib/hecks/fuzzing/replay.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:` —
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|