rbbb 0.1.0.pre.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +159 -0
- data/lib/rbbb/configuration.rb +93 -0
- data/lib/rbbb/decision.rb +56 -0
- data/lib/rbbb/engine.rb +870 -0
- data/lib/rbbb/event.rb +44 -0
- data/lib/rbbb/increment_schedule.rb +78 -0
- data/lib/rbbb/money.rb +70 -0
- data/lib/rbbb/state.rb +462 -0
- data/lib/rbbb/timestamp.rb +55 -0
- data/lib/rbbb/version.rb +7 -0
- data/lib/rbbb.rb +27 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b73b49d61e104b20d7f14c8894477fd34079184c053d115d25cc8fca098a16b5
|
|
4
|
+
data.tar.gz: 206540fe6a06c4cdec54b076eab100e8fe712e103ca3f6ec4bb7acc3b185eb49
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8424b68bfbf71aeeb87cf4cffa988719db1a78719c38e0d1d44b2e1979e237ae0edfca3b80638c7bae536fc83170d1ed0c1e893ce247eeffaa08ba9b52404b99
|
|
7
|
+
data.tar.gz: 6e46a05de2032c7ef7f745b917bffd29f23034cc3b3e1f09b66f6c3019f9709aa53392c24f6e98e163c04c502aacef32dab37502510b1e123e24cbae3570a7f2
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 R Triple B contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# RBBB Ruby reference engine
|
|
2
|
+
|
|
3
|
+
This directory contains the pure-Ruby reference implementation of the RBBB
|
|
4
|
+
specification.
|
|
5
|
+
|
|
6
|
+
> [!WARNING]
|
|
7
|
+
> The gem is an early RFC 0001 implementation. It is not production ready and
|
|
8
|
+
> must not be used in a live auction.
|
|
9
|
+
|
|
10
|
+
The engine will remain independent of Rails, databases, HTTP, jobs, and
|
|
11
|
+
authentication. Its public decision boundary is:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
decision = engine.decide(current_state, command)
|
|
15
|
+
new_state = engine.apply(current_state, decision.events)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The current implementation covers exact minor-unit money, configurable
|
|
19
|
+
increment tiers, opening bids, proxy competition, earlier-equal priority,
|
|
20
|
+
proxy clipping, challenger minimums, private leader maximum increases, and
|
|
21
|
+
confidential reserve pricing and status. It also enforces authoritative closing
|
|
22
|
+
times, configurable per-unit soft-close extensions, and bidder withdrawal of
|
|
23
|
+
unexecuted proxy authority. Authorized operator bid voiding preserves the
|
|
24
|
+
original bid, deterministically recomputes standing and executed amounts, and
|
|
25
|
+
emits host-facing notification intent. Audited operator commands may also
|
|
26
|
+
change closing time and reserve under the RFC's pre-bid and post-bid
|
|
27
|
+
constraints without unwinding already executed amounts. Explicit ordered
|
|
28
|
+
closing produces `sold`, `no_sale`, or `no_bid` terminal state. Persistence,
|
|
29
|
+
networking, service-level idempotency, and authentication are not implemented.
|
|
30
|
+
|
|
31
|
+
The pure engine records `operator_id`; it does not authenticate or authorize
|
|
32
|
+
that identity. A host must authorize the operator before submitting any
|
|
33
|
+
operator command and must deliver or retry any requested notifications.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
configuration = RBBB::Configuration.new(
|
|
37
|
+
currency: "USD",
|
|
38
|
+
opening_minor_units: 10_000,
|
|
39
|
+
increments: [{from_minor_units: 0, amount_minor_units: 1_000}],
|
|
40
|
+
opens_at: "2026-09-01T12:00:00Z",
|
|
41
|
+
closes_at: "2026-09-01T13:00:00Z",
|
|
42
|
+
extension: {trigger_window_seconds: 300, duration_seconds: 300}
|
|
43
|
+
)
|
|
44
|
+
engine = RBBB::Engine.new(configuration)
|
|
45
|
+
state = engine.initial_state
|
|
46
|
+
|
|
47
|
+
decision = engine.decide(state, {
|
|
48
|
+
command_id: "command-1",
|
|
49
|
+
type: "place_bid",
|
|
50
|
+
bidder_id: "bidder-a",
|
|
51
|
+
maximum_minor_units: 50_000,
|
|
52
|
+
effective_at: "2026-09-01T12:10:00Z"
|
|
53
|
+
})
|
|
54
|
+
state = engine.apply(state, decision.events) if decision.accepted?
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Rejected commands
|
|
58
|
+
|
|
59
|
+
A command that cannot be applied yields `decision.rejected?` and no events.
|
|
60
|
+
`decision.rejection` is a frozen hash holding `command_id`, `reason` (one of
|
|
61
|
+
the enumerated codes in `specification/rejections/rejection.schema.json`), and
|
|
62
|
+
`status: "rejected"`, and at most one reason-specific field:
|
|
63
|
+
`executed_floor_minor_units`, present only with
|
|
64
|
+
`maximum_below_executed_amount`. There is no open-ended details object, and
|
|
65
|
+
the rejection is already the complete document the schema describes; a
|
|
66
|
+
service adapter must transmit it without adding or removing fields. The
|
|
67
|
+
normative rules live in
|
|
68
|
+
`specification/contract.md` under "Service envelope and core inputs" and the
|
|
69
|
+
rejection paragraph under "Events and visibility". A host must return
|
|
70
|
+
`executed_floor_minor_units` only to the bidder who issued the rejected
|
|
71
|
+
command and must never place it in a public projection.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
decision = engine.decide(state, {
|
|
75
|
+
command_id: "command-2",
|
|
76
|
+
type: "reduce_maximum",
|
|
77
|
+
bidder_id: "bidder-a",
|
|
78
|
+
maximum_minor_units: 5_000,
|
|
79
|
+
effective_at: "2026-09-01T12:11:00Z"
|
|
80
|
+
})
|
|
81
|
+
if decision.rejected?
|
|
82
|
+
decision.rejection
|
|
83
|
+
# => {"command_id" => "command-2",
|
|
84
|
+
# "status" => "rejected",
|
|
85
|
+
# "reason" => "maximum_below_executed_amount",
|
|
86
|
+
# "executed_floor_minor_units" => 10_000}
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Snapshots, checkpoints, and the public view
|
|
91
|
+
|
|
92
|
+
`State#to_h` is the full privileged aggregate snapshot. With the host's
|
|
93
|
+
`auction_id`, `bidding_unit_id`, and `currency` added it satisfies
|
|
94
|
+
`specification/state/aggregate.schema.json`, and `RBBB::State.from_h`
|
|
95
|
+
rebuilds a validated state from it. It contains bidder identities, maxima,
|
|
96
|
+
the reserve amount, and audit history, so it must never be published.
|
|
97
|
+
|
|
98
|
+
Every privileged state-transition event already carries that snapshot, so a
|
|
99
|
+
host can checkpoint from the latest transition instead of replaying the
|
|
100
|
+
stream from version 0:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
transition = decision.events.find { |event| event.privileged? && event.type == "maximum_accepted" }
|
|
104
|
+
restored = engine.restore(transition) # or RBBB::State.from_transition(configuration, transition)
|
|
105
|
+
restored.to_h == state.to_h # => true
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`State#public_view` is the public query projection. With the same three host
|
|
109
|
+
fields added it satisfies `specification/state/bidding-unit.schema.json`. It
|
|
110
|
+
never carries a bidder, leader, or winner identity, a maximum, the reserve
|
|
111
|
+
amount, or audit history; serve it rather than hand-rolling a projection
|
|
112
|
+
from the aggregate.
|
|
113
|
+
|
|
114
|
+
## Install the evaluation gem
|
|
115
|
+
|
|
116
|
+
Version `0.1.0.pre.3` is an experimental evaluation package. It has no runtime
|
|
117
|
+
dependencies and supports Ruby 3.2 and newer. Install the exact prerelease from
|
|
118
|
+
RubyGems.org:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
gem install rbbb --version 0.1.0.pre.3
|
|
122
|
+
ruby -rrbbb -e 'puts [RBBB::VERSION, RBBB::SPECIFICATION_VERSION, RBBB::RELEASE_STATUS].join(" ")'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
To build the same version from a reviewed checkout:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
cd ruby/engine
|
|
129
|
+
bundle exec rake package:verify
|
|
130
|
+
gem build rbbb.gemspec
|
|
131
|
+
gem install ./rbbb-0.1.0.pre.3.gem
|
|
132
|
+
ruby -rrbbb -e 'puts [RBBB::VERSION, RBBB::SPECIFICATION_VERSION, RBBB::RELEASE_STATUS].join(" ")'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
For local application evaluation with Bundler:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
gem "rbbb", path: "/path/to/rbbb/ruby/engine"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The [matching GitHub release](https://github.com/willtmc/rbbb/releases) carries
|
|
142
|
+
the same `.gem` artifact and its SHA-256 checksum.
|
|
143
|
+
|
|
144
|
+
The installed package exposes its implementation version, claimed
|
|
145
|
+
specification version, and release status as `RBBB::VERSION`,
|
|
146
|
+
`RBBB::SPECIFICATION_VERSION`, and `RBBB::RELEASE_STATUS`. A package version is
|
|
147
|
+
not, by itself, a production-readiness or compatibility claim.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
bundle install
|
|
153
|
+
bundle exec rake
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The default task includes `package:verify`, which builds the gem in a temporary
|
|
157
|
+
directory, checks the exact file allowlist, installs it into an isolated gem
|
|
158
|
+
home, and runs a bid through the installed artifact. Run `bundle exec rake
|
|
159
|
+
package:verify` when only that check is needed.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Immutable bidding-unit configuration consumed by the pure engine.
|
|
5
|
+
#
|
|
6
|
+
# RFC 0001 defines only timed bidding units, so +opens_at+ and +closes_at+
|
|
7
|
+
# are required alongside currency, opening amount, and increments.
|
|
8
|
+
class Configuration
|
|
9
|
+
attr_reader :currency, :opening_minor_units, :reserve_minor_units,
|
|
10
|
+
:increment_schedule, :opens_at, :closes_at, :extension
|
|
11
|
+
|
|
12
|
+
def self.from_h(attributes)
|
|
13
|
+
values = attributes.transform_keys(&:to_s)
|
|
14
|
+
new(
|
|
15
|
+
currency: values.fetch("currency"),
|
|
16
|
+
opening_minor_units: values.fetch("opening_minor_units"),
|
|
17
|
+
reserve_minor_units: values["reserve_minor_units"],
|
|
18
|
+
increments: values.fetch("increments"),
|
|
19
|
+
opens_at: values.fetch("opens_at"),
|
|
20
|
+
closes_at: values.fetch("closes_at"),
|
|
21
|
+
extension: values["extension"]
|
|
22
|
+
)
|
|
23
|
+
rescue KeyError => e
|
|
24
|
+
raise InvalidConfiguration, "configuration is missing #{e.key}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(currency:, opening_minor_units:, increments:, reserve_minor_units: nil,
|
|
28
|
+
opens_at: nil, closes_at: nil, extension: nil)
|
|
29
|
+
Money.new(currency: currency, minor_units: opening_minor_units)
|
|
30
|
+
unless Money.amount?(opening_minor_units)
|
|
31
|
+
raise InvalidConfiguration, "opening amount must be a non-negative integer no greater than #{Money::MAX_MINOR_UNITS}"
|
|
32
|
+
end
|
|
33
|
+
if !reserve_minor_units.nil? && !Money.amount?(reserve_minor_units)
|
|
34
|
+
raise InvalidConfiguration, "reserve must be a non-negative integer no greater than #{Money::MAX_MINOR_UNITS}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@currency = currency.freeze
|
|
38
|
+
@opening_minor_units = opening_minor_units
|
|
39
|
+
@reserve_minor_units = reserve_minor_units
|
|
40
|
+
@increment_schedule = IncrementSchedule.new(increments)
|
|
41
|
+
@opens_at = parse_timestamp(opens_at, "opens_at")
|
|
42
|
+
@closes_at = parse_timestamp(closes_at, "closes_at")
|
|
43
|
+
if @opens_at >= @closes_at
|
|
44
|
+
raise InvalidConfiguration, "opens_at must be earlier than closes_at"
|
|
45
|
+
end
|
|
46
|
+
@extension = normalize_extension(extension)
|
|
47
|
+
freeze
|
|
48
|
+
rescue InvalidMoney => e
|
|
49
|
+
raise InvalidConfiguration, e.message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def increment_for(minor_units)
|
|
53
|
+
increment_schedule.increment_for(minor_units)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_timestamp(value, field)
|
|
59
|
+
raise InvalidConfiguration, "configuration is missing #{field}" if value.nil?
|
|
60
|
+
|
|
61
|
+
Timestamp.parse(value)
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
raise InvalidConfiguration, "#{field} must be a valid ISO 8601 timestamp"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalize_extension(extension)
|
|
67
|
+
return nil if extension.nil?
|
|
68
|
+
unless extension.respond_to?(:transform_keys)
|
|
69
|
+
raise InvalidConfiguration, "extension must be an object"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
values = extension.transform_keys(&:to_s)
|
|
73
|
+
trigger = values["trigger_window_seconds"]
|
|
74
|
+
duration = values["duration_seconds"]
|
|
75
|
+
# Seconds share the interoperable integer bound so closing-time
|
|
76
|
+
# arithmetic (command time plus duration) stays representable in every
|
|
77
|
+
# implementation and inside the RFC 3339 four-digit-year range.
|
|
78
|
+
unless trigger.is_a?(Integer) && trigger >= 0 && trigger <= MAX_SAFE_INTEGER
|
|
79
|
+
raise InvalidConfiguration,
|
|
80
|
+
"extension trigger window must be a non-negative integer no greater than #{MAX_SAFE_INTEGER}"
|
|
81
|
+
end
|
|
82
|
+
unless duration.is_a?(Integer) && duration.positive? && duration <= MAX_SAFE_INTEGER
|
|
83
|
+
raise InvalidConfiguration,
|
|
84
|
+
"extension duration must be a positive integer no greater than #{MAX_SAFE_INTEGER}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
"trigger_window_seconds" => trigger,
|
|
89
|
+
"duration_seconds" => duration
|
|
90
|
+
}.freeze
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBBB
|
|
4
|
+
# Accepted events or one stable rejection from evaluating a command.
|
|
5
|
+
class Decision
|
|
6
|
+
# The constant status every rejection carries. The pure engine emits the
|
|
7
|
+
# complete rejection document defined by
|
|
8
|
+
# specification/rejections/rejection.schema.json; a service adapter must
|
|
9
|
+
# transmit it without adding or removing fields.
|
|
10
|
+
REJECTED_STATUS = "rejected"
|
|
11
|
+
|
|
12
|
+
# Rejection fields beyond command_id, status, and reason, keyed by the only
|
|
13
|
+
# reason that may carry them. Mirrors the rejection schema; there is
|
|
14
|
+
# deliberately no open-ended details object.
|
|
15
|
+
REASON_SPECIFIC_FIELDS = {
|
|
16
|
+
"maximum_below_executed_amount" => %w[executed_floor_minor_units].freeze
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :events, :rejection
|
|
20
|
+
|
|
21
|
+
def self.accepted(events)
|
|
22
|
+
new(events: events, rejection: nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.rejected(command_id:, reason:, executed_floor_minor_units: nil)
|
|
26
|
+
rejection = {"command_id" => command_id, "status" => REJECTED_STATUS, "reason" => reason}
|
|
27
|
+
allowed = REASON_SPECIFIC_FIELDS.fetch(reason, [])
|
|
28
|
+
|
|
29
|
+
if allowed.include?("executed_floor_minor_units")
|
|
30
|
+
unless executed_floor_minor_units.is_a?(Integer) && executed_floor_minor_units >= 0
|
|
31
|
+
raise ArgumentError, "#{reason} requires a non-negative integer executed_floor_minor_units"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
rejection["executed_floor_minor_units"] = executed_floor_minor_units
|
|
35
|
+
elsif !executed_floor_minor_units.nil?
|
|
36
|
+
raise ArgumentError, "executed_floor_minor_units is only reported for maximum_below_executed_amount"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
new(events: [], rejection: rejection.freeze)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(events:, rejection:)
|
|
43
|
+
@events = events.freeze
|
|
44
|
+
@rejection = rejection
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def accepted?
|
|
49
|
+
rejection.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rejected?
|
|
53
|
+
!accepted?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|