ruby_decision_model 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40caeb94a3a1a8365e7719753591173b6a8b89f8b0431579ac1df3f6b731caeb
4
+ data.tar.gz: 3fd04d44ed5083947c79921aa2fe2c05e9616294104e351ffbea3a47448e7dd7
5
+ SHA512:
6
+ metadata.gz: dc8ba08eaf6fa210ef58809c7b3e8e585e835dc4bbaa09b7d65956b39d2168cd47d4812dabe7a6c72420fa93ebc0d9f2716e9d33a17d076f0a709ab5a573bd0c
7
+ data.tar.gz: 47260e6b68ae8bf8dafec39aae852460281d4423285116f33f8a097da0556e2f0a52327503964310018561e69a3f3cd66fc9b9cb5d51f2dbca6c1bd8c5f9a7c1
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 - 2026-09-18
4
+
5
+ Initial release. Client and question builders for OpenRouter's `/decisions`
6
+ endpoint with Typesafe Jev as the first backend. Supports noul, choice, and
7
+ score questions, normalized answers, retry on transient errors, and a typed
8
+ error hierarchy.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Obie Fernandez
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,49 @@
1
+ # ruby_decision_model
2
+
3
+ Decision models answer typed questions about a state with calibrated probabilities,
4
+ instead of generating text. This gem is a dependency-free Ruby client for them,
5
+ starting with OpenRouter's `/decisions` endpoint and Typesafe Jev.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ gem "ruby_decision_model"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "ruby_decision_model"
17
+
18
+ client = RubyDecisionModel::Client.new(api_key: ENV["OPENROUTER_API_KEY"])
19
+
20
+ response = client.ask(
21
+ state: { title: "Server returns 500 on checkout", reporter: "support" },
22
+ questions: {
23
+ "urgent" => RubyDecisionModel::Questions.noul("Is this urgent?"),
24
+ "severity" => RubyDecisionModel::Questions.score(
25
+ "How severe is this issue?",
26
+ criteria: ["cosmetic", "minor", "major", "critical"]
27
+ )
28
+ }
29
+ )
30
+
31
+ response["urgent"].noul # => 0.87
32
+ response["severity"].score # => 2.4
33
+ response.usage.cost # => 0.0012
34
+ ```
35
+
36
+ ## Errors
37
+
38
+ | Error | Meaning |
39
+ | --- | --- |
40
+ | `ConfigurationError` | Missing api_key, model, or base_url |
41
+ | `RequestError` | Questions hash was empty |
42
+ | `TransportError` (`TimeoutError`) | Network or timeout failure |
43
+ | `ApiError` (`Unauthorized`, `PayloadTooLarge`, `RateLimited`) | Non-2xx response, carries `#status` and `#body` |
44
+ | `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed |
45
+ | `MissingAnswers` | One or more question ids came back missing or wrong-typed, carries `#missing` |
46
+
47
+ Status: 0.0.1, API may change.
48
+
49
+ The companion gem `ruby_dm` builds decisions and verdicts on top of this client.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ module Answers
5
+ Noul = Data.define(:noul, :probabilities) do
6
+ def type
7
+ "noul"
8
+ end
9
+
10
+ def probability
11
+ noul
12
+ end
13
+ end
14
+
15
+ Choice = Data.define(:choice, :confidence, :probabilities) do
16
+ def type
17
+ "choice"
18
+ end
19
+ end
20
+
21
+ Score = Data.define(:score, :confidence, :probabilities, :legend) do
22
+ def type
23
+ "score"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module RubyDecisionModel
8
+ class Client
9
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/alpha"
10
+ DEFAULT_MODEL = "typesafe/jev-1.13"
11
+ MAX_ATTEMPTS = 2
12
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504, 524, 529].freeze
13
+ RETRYABLE_EXCEPTIONS = [
14
+ Net::OpenTimeout,
15
+ Net::ReadTimeout,
16
+ Errno::ECONNRESET,
17
+ Errno::ECONNREFUSED,
18
+ Errno::EPIPE,
19
+ SocketError,
20
+ IOError
21
+ ].freeze
22
+
23
+ def initialize(api_key:, model: DEFAULT_MODEL, base_url: DEFAULT_BASE_URL, timeout: 5,
24
+ transport: nil, sleeper: ->(seconds) { sleep(seconds) })
25
+ raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
26
+ raise ConfigurationError, "model is required" if model.nil? || model.to_s.strip.empty?
27
+ raise ConfigurationError, "base_url is required" if base_url.nil? || base_url.to_s.strip.empty?
28
+
29
+ @api_key = api_key
30
+ @model = model
31
+ @base_url = base_url.to_s.chomp("/")
32
+ @timeout = timeout
33
+ @transport = transport || default_transport
34
+ @sleeper = sleeper
35
+ end
36
+
37
+ def ask(state:, questions:)
38
+ raise RequestError, "questions must not be empty" if questions.nil? || questions.empty?
39
+
40
+ body = JSON.generate({ "model" => @model, "state" => state, "questions" => questions })
41
+ headers = {
42
+ "Authorization" => "Bearer #{@api_key}",
43
+ "Content-Type" => "application/json",
44
+ "Accept" => "application/json"
45
+ }
46
+
47
+ status, response_body = perform_with_retry(url: "#{@base_url}/decisions", headers: headers, body: body)
48
+ handle_response(status, response_body, questions)
49
+ end
50
+
51
+ private
52
+
53
+ def default_transport
54
+ lambda do |url:, headers:, body:|
55
+ uri = URI.parse(url)
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.use_ssl = uri.scheme == "https"
58
+ http.open_timeout = @timeout
59
+ http.read_timeout = @timeout
60
+
61
+ request = Net::HTTP::Post.new(uri.request_uri)
62
+ headers.each { |k, v| request[k] = v }
63
+ request.body = body
64
+
65
+ response = http.request(request)
66
+ [response.code.to_i, response.body]
67
+ end
68
+ end
69
+
70
+ def perform_with_retry(url:, headers:, body:)
71
+ attempts = 0
72
+
73
+ loop do
74
+ attempts += 1
75
+ begin
76
+ status, response_body = @transport.call(url: url, headers: headers, body: body)
77
+ rescue *RETRYABLE_EXCEPTIONS => e
78
+ raise_transport_error(e) if attempts >= MAX_ATTEMPTS
79
+
80
+ @sleeper.call(backoff_seconds)
81
+ next
82
+ rescue Error
83
+ raise
84
+ rescue StandardError => e
85
+ raise_transport_error(e)
86
+ end
87
+
88
+ return [status, response_body] unless RETRYABLE_STATUSES.include?(status) && attempts < MAX_ATTEMPTS
89
+
90
+ @sleeper.call(backoff_seconds)
91
+ end
92
+ end
93
+
94
+ def backoff_seconds
95
+ 0.5 + (rand * 0.25)
96
+ end
97
+
98
+ def raise_transport_error(exception)
99
+ if exception.is_a?(Net::OpenTimeout) || exception.is_a?(Net::ReadTimeout)
100
+ raise TimeoutError.new("request timed out: #{exception.message}", cause_error: exception)
101
+ end
102
+
103
+ raise TransportError.new("transport error: #{exception.message}", cause_error: exception)
104
+ end
105
+
106
+ def handle_response(status, response_body, questions)
107
+ case status
108
+ when 200..299
109
+ parse_success(response_body, questions)
110
+ when 401
111
+ raise Unauthorized.new("unauthorized", status: status, body: response_body)
112
+ when 413
113
+ raise PayloadTooLarge.new("payload too large", status: status, body: response_body)
114
+ when 429
115
+ raise RateLimited.new("rate limited", status: status, body: response_body)
116
+ else
117
+ raise ApiError.new("api error (status #{status})", status: status, body: response_body)
118
+ end
119
+ end
120
+
121
+ def parse_success(response_body, questions)
122
+ parsed = begin
123
+ JSON.parse(response_body)
124
+ rescue JSON::ParserError => e
125
+ raise InvalidResponse, "response body was not valid JSON: #{e.message}"
126
+ end
127
+
128
+ raise InvalidResponse, "response body was not a JSON object" unless parsed.is_a?(Hash)
129
+
130
+ raw_answers = parsed["answers"]
131
+ raw_answers = {} unless raw_answers.is_a?(Hash)
132
+
133
+ normalized = {}
134
+ malformed = []
135
+ missing = []
136
+
137
+ questions.each do |raw_id, question|
138
+ id = raw_id.to_s
139
+ answer_hash = raw_answers[id]
140
+ expected_type = question_type(question)
141
+
142
+ if answer_hash.is_a?(Hash) && answer_hash["type"] == expected_type
143
+ begin
144
+ normalized[id] = normalize_answer(expected_type, answer_hash)
145
+ rescue MalformedAnswer
146
+ malformed << id
147
+ end
148
+ else
149
+ missing << id
150
+ end
151
+ end
152
+
153
+ if malformed.any?
154
+ raise InvalidResponse.new(
155
+ "malformed answer fields for: #{malformed.join(', ')}",
156
+ answers: normalized
157
+ )
158
+ end
159
+
160
+ if missing.any?
161
+ raise MissingAnswers.new(
162
+ "missing or wrong-type answers for: #{missing.join(', ')}",
163
+ answers: normalized,
164
+ missing: missing
165
+ )
166
+ end
167
+
168
+ Response.new(
169
+ answers: normalized,
170
+ usage: normalize_usage(parsed["usage"]),
171
+ model: parsed["model"],
172
+ id: parsed["id"],
173
+ raw: parsed
174
+ )
175
+ end
176
+
177
+ class MalformedAnswer < StandardError; end
178
+
179
+ def normalize_answer(type, hash)
180
+ case type
181
+ when "noul"
182
+ noul = hash["noul"]
183
+ raise MalformedAnswer unless noul.is_a?(Numeric)
184
+
185
+ Answers::Noul.new(noul: noul.to_f, probabilities: hash_or_empty(hash["probabilities"]))
186
+ when "choice"
187
+ choice = hash["choice"]
188
+ confidence = hash["confidence"]
189
+ raise MalformedAnswer unless choice.is_a?(String) && confidence.is_a?(Numeric)
190
+
191
+ Answers::Choice.new(
192
+ choice: choice,
193
+ confidence: confidence.to_f,
194
+ probabilities: hash_or_empty(hash["probabilities"])
195
+ )
196
+ when "score"
197
+ score = hash["score"]
198
+ confidence = hash["confidence"]
199
+ raise MalformedAnswer unless score.is_a?(Numeric) && confidence.is_a?(Numeric)
200
+
201
+ Answers::Score.new(
202
+ score: score.to_f,
203
+ confidence: confidence.to_f,
204
+ probabilities: hash_or_empty(hash["probabilities"]),
205
+ legend: hash_or_empty(hash["legend"])
206
+ )
207
+ else
208
+ raise MalformedAnswer
209
+ end
210
+ end
211
+
212
+ def question_type(question)
213
+ return nil unless question.is_a?(Hash)
214
+
215
+ question["type"] || question[:type]
216
+ end
217
+
218
+ def hash_or_empty(value)
219
+ value.is_a?(Hash) ? value : {}
220
+ end
221
+
222
+ def normalize_usage(usage)
223
+ usage = {} unless usage.is_a?(Hash)
224
+
225
+ Response::Usage.new(
226
+ input_tokens: Integer(usage["input_tokens"], exception: false),
227
+ output_tokens: Integer(usage["output_tokens"], exception: false),
228
+ cost: Float(usage["cost"], exception: false)
229
+ )
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class RequestError < Error; end
9
+
10
+ class TransportError < Error
11
+ attr_reader :cause_error
12
+
13
+ def initialize(message, cause_error: nil)
14
+ super(message)
15
+ @cause_error = cause_error
16
+ end
17
+ end
18
+
19
+ class TimeoutError < TransportError; end
20
+
21
+ class ApiError < Error
22
+ attr_reader :status, :body
23
+
24
+ def initialize(message, status:, body:)
25
+ super(message)
26
+ @status = status
27
+ @body = body
28
+ end
29
+ end
30
+
31
+ class Unauthorized < ApiError; end
32
+
33
+ class PayloadTooLarge < ApiError; end
34
+
35
+ class RateLimited < ApiError; end
36
+
37
+ class InvalidResponse < Error
38
+ attr_reader :answers
39
+
40
+ def initialize(message, answers: {})
41
+ super(message)
42
+ @answers = answers
43
+ end
44
+ end
45
+
46
+ class MissingAnswers < InvalidResponse
47
+ attr_reader :missing
48
+
49
+ def initialize(message, answers: {}, missing: [])
50
+ super(message, answers: answers)
51
+ @missing = missing
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ # Pure builders for question hashes. No I/O.
5
+ module Questions
6
+ module_function
7
+
8
+ def noul(instructions, criteria: nil)
9
+ validate_instructions!(instructions)
10
+
11
+ question = { "type" => "noul", "instructions" => instructions }
12
+ question["criteria"] = criteria unless criteria.nil?
13
+ question
14
+ end
15
+
16
+ def choice(instructions, criteria:)
17
+ validate_instructions!(instructions)
18
+ unless criteria.is_a?(Hash) && criteria.size.between?(1, 255)
19
+ raise ArgumentError, "criteria must be a Hash with 1..255 entries"
20
+ end
21
+
22
+ stringified = {}
23
+ criteria.each { |k, v| stringified[k.to_s] = v }
24
+
25
+ { "type" => "choice", "instructions" => instructions, "criteria" => stringified }
26
+ end
27
+
28
+ def score(instructions, criteria:)
29
+ validate_instructions!(instructions)
30
+ unless criteria.is_a?(Array) && criteria.size.between?(2, 10)
31
+ raise ArgumentError, "criteria must be an Array with 2..10 entries"
32
+ end
33
+
34
+ { "type" => "score", "instructions" => instructions, "criteria" => criteria }
35
+ end
36
+
37
+ def validate_instructions!(instructions)
38
+ case instructions
39
+ when String
40
+ raise ArgumentError, "instructions must not be empty" if instructions.empty?
41
+ when Hash, Array
42
+ raise ArgumentError, "instructions must not be empty" if instructions.empty?
43
+ else
44
+ raise ArgumentError, "instructions must be a String, Hash, or Array"
45
+ end
46
+ end
47
+ private_class_method :validate_instructions!
48
+ end
49
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ class Response
5
+ Usage = Data.define(:input_tokens, :output_tokens, :cost)
6
+
7
+ attr_reader :answers, :usage, :model, :id, :raw
8
+
9
+ def initialize(answers:, usage:, model:, id:, raw:)
10
+ @answers = answers
11
+ @usage = usage
12
+ @model = model
13
+ @id = id
14
+ @raw = raw
15
+ end
16
+
17
+ def [](id)
18
+ answers[id]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruby_decision_model/version"
4
+ require_relative "ruby_decision_model/errors"
5
+ require_relative "ruby_decision_model/questions"
6
+ require_relative "ruby_decision_model/answers"
7
+ require_relative "ruby_decision_model/response"
8
+ require_relative "ruby_decision_model/client"
9
+
10
+ module RubyDecisionModel
11
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_decision_model
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Decision models answer typed questions about a state instead of generating
42
+ text. ruby_decision_model builds noul (yes/no probability), choice, and score questions,
43
+ posts them with a state to a decisions endpoint (OpenRouter's /decisions with Typesafe
44
+ Jev to start), and returns normalized answers with probabilities, confidence, legends,
45
+ and usage.
46
+ email:
47
+ - obiefernandez@gmail.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - CHANGELOG.md
53
+ - LICENSE.txt
54
+ - README.md
55
+ - lib/ruby_decision_model.rb
56
+ - lib/ruby_decision_model/answers.rb
57
+ - lib/ruby_decision_model/client.rb
58
+ - lib/ruby_decision_model/errors.rb
59
+ - lib/ruby_decision_model/questions.rb
60
+ - lib/ruby_decision_model/response.rb
61
+ - lib/ruby_decision_model/version.rb
62
+ homepage: https://github.com/obie/ruby_decision_model
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ source_code_uri: https://github.com/obie/ruby_decision_model
67
+ changelog_uri: https://github.com/obie/ruby_decision_model/blob/main/CHANGELOG.md
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.2'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.11
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Ruby client for decision models such as Typesafe Jev
87
+ test_files: []