arbiter-gem 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1390ac17a355433c6f9e2f9f7a47e6b9b8a8ce166c8c37b1389e1950eb1696a
4
+ data.tar.gz: 7ef97bb093b05948eeed2746e1d30fc3df630b6bd13eea53d2f163898003d4eb
5
+ SHA512:
6
+ metadata.gz: a0a0a9382852eb361ca410cfe5a8aa28db293b2a052d490a7e64da5c34338ff30b33bafa0d3efa8b0d30e2389c0be0ab545d7034f40e6cb55269f9032aa0f108
7
+ data.tar.gz: a52e0d340a0a267cb354c18dccf7a91c59e6b1eaefc861632317d48a7f24676db0a1cdee576d755cf2dae0a58398f91eeb3c0385fb77c5defbf7a8e8dd22eb95
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-11
4
+ - Initial `arbiter-gem` Ruby client for compare and embed.
5
+ - Finite candidate ranking, decision, and opt-in callable dispatch.
6
+ - Explicit 402 error handling; never spends funds automatically.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Actual General Intelligence, Inc.
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,109 @@
1
+ # ARBITER for Ruby
2
+
3
+ **Gem:** `arbiter-gem`
4
+ **Ruby module:** `Arbiter`
5
+ **Concept:** The Last Conditional — measure a finite permitted action field instead of extending contextual branching logic.
6
+
7
+ Deterministic remote ARBITER measurement in ordinary Ruby applications. No native engine, CLI installation, model weights, or Regolith required.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ gem install arbiter-gem
13
+ ```
14
+
15
+ Gemfile:
16
+
17
+ ```ruby
18
+ gem "arbiter-gem"
19
+ ```
20
+
21
+ ## Use
22
+
23
+ ```ruby
24
+ require "arbiter"
25
+
26
+ result = Arbiter.compare(
27
+ query: "Resolve a failed payment",
28
+ candidates: ["Retry", "Escalate", "Notify"],
29
+ top_k: 3
30
+ )
31
+ puts result # raw ARBITER JSON object
32
+ ```
33
+
34
+ The `compare` call returns the raw decoded API JSON object. To obtain a normalized ranking for documented formats, use `Arbiter.rank(...)`. Unknown response formats raise `Arbiter::InvalidResponse` rather than invent a winner.
35
+
36
+ Embeddings:
37
+
38
+ ```ruby
39
+ response = Arbiter.embed(texts: ["one", "two"])
40
+ ```
41
+
42
+ ### The Last Conditional
43
+
44
+ ```ruby
45
+ permitted = ->(name, _action) { name != :retry || payment.retry_allowed? }
46
+
47
+ actions = {
48
+ retry: { description: "Retry the payment", run: -> { retry_payment } },
49
+ escalate: { description: "Escalate to billing", run: -> { escalate_payment } },
50
+ notify: { description: "Notify customer", run: -> { notify_customer } }
51
+ }
52
+
53
+ # Measurement only — never runs Ruby callables:
54
+ choice = Arbiter.decide(intent: "Resolve the payment", actions: actions, permitted: permitted)
55
+ puts choice[:action]
56
+
57
+ # Opt-in execution with permission revalidated immediately before invocation:
58
+ # Arbiter.dispatch(intent: "Resolve the payment", actions: actions, permitted: permitted)
59
+ ```
60
+
61
+ **Authority is external to measurement.** Filter prohibited actions first. Hard policy rules, authorization, cryptographic checks and transactional safety still belong to the application. `dispatch` never executes unknown actions returned by the API. Both the input field and the selected action are checked.
62
+
63
+ ## Configuration
64
+
65
+ ```bash
66
+ export ARBITER_API_KEY="<credit-api-key>"
67
+ ```
68
+
69
+ `GRIP_API_KEY` is also recognized. If the GRIP API uses a different header for credits, adjust the `Client` configuration before publishing; the currently implemented key header is `Authorization: Bearer` and has not been live-verified against the credit-pack gateway.
70
+
71
+ ```ruby
72
+ Arbiter.configure(
73
+ api_key: ENV.fetch("ARBITER_API_KEY"),
74
+ base_url: "https://api.grip.fyi"
75
+ )
76
+ ```
77
+
78
+ Endpoints: `POST /v1/compare` with `query`, `candidates`, optional `top_k`; `POST /v1/embed` with `texts`.
79
+
80
+ ### HTTP 402
81
+
82
+ The ARBITER service may request x402 payment. This client never signs wallet transactions or spends automatically:
83
+
84
+ ```ruby
85
+ begin
86
+ Arbiter.compare(query: "...", candidates: ["A", "B"])
87
+ rescue Arbiter::PaymentRequired => e
88
+ puts "HTTP #{e.status} — payment required"
89
+ # e.challenge contains payment-related response headers, if supplied
90
+ end
91
+ ```
92
+
93
+ Use funded API-key credits if supported by the deployment, or integrate a separately authorized wallet payment mechanism. RubyGems publication credentials **are unrelated** to API credits.
94
+
95
+ ## Testing
96
+
97
+ ```bash
98
+ ruby -Ilib:test test/test_arbiter.rb
99
+ ```
100
+
101
+ ## Publishing
102
+
103
+ Run `./PUBLISH.command` on a machine already authenticated to RubyGems. It performs tests, builds the gem, then calls `gem push` using the existing RubyGems credential store. It never copies, prints, or packages credentials. Two-factor authentication may still require an OTP.
104
+
105
+ ## Scope
106
+
107
+ Version 0.1.0 is a release candidate tested against stubbed HTTP responses. Before public release, validate the live API response schema, credit-key header, and Gem name availability. No x402 wallet signer is bundled.
108
+
109
+ MIT © 2026 Actual General Intelligence, Inc.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Arbiter
7
+ class Client
8
+ DEFAULT_BASE_URL = "https://api.grip.fyi".freeze
9
+ attr_reader :base_url
10
+
11
+ def initialize(api_key: ENV["ARBITER_API_KEY"] || ENV["GRIP_API_KEY"],
12
+ base_url: ENV.fetch("ARBITER_BASE_URL", DEFAULT_BASE_URL),
13
+ open_timeout: 5, read_timeout: 30, transport: nil)
14
+ @api_key = api_key
15
+ @base_url = base_url.to_s.sub(%r{/+$}, "")
16
+ @open_timeout = open_timeout
17
+ @read_timeout = read_timeout
18
+ @transport = transport
19
+ validate_url!
20
+ end
21
+
22
+ def compare(query:, candidates:, top_k: nil)
23
+ unless query.is_a?(String) && !query.strip.empty?
24
+ raise InvalidInput, "query must be a nonempty String"
25
+ end
26
+ unless candidates.is_a?(Array) && candidates.length >= 2 &&
27
+ candidates.all? { |c| c.is_a?(String) && !c.strip.empty? }
28
+ raise InvalidInput, "candidates must be an Array of at least two nonempty Strings"
29
+ end
30
+ payload = { query: query, candidates: candidates }
31
+ unless top_k.nil?
32
+ unless top_k.is_a?(Integer) && top_k.between?(1, candidates.length)
33
+ raise InvalidInput, "top_k must be an Integer between 1 and candidate count"
34
+ end
35
+ payload[:top_k] = top_k
36
+ end
37
+ request("/v1/compare", payload)
38
+ end
39
+
40
+ def embed(texts:)
41
+ unless texts.is_a?(Array) && !texts.empty? &&
42
+ texts.all? { |t| t.is_a?(String) && !t.strip.empty? }
43
+ raise InvalidInput, "texts must be a nonempty Array of nonempty Strings"
44
+ end
45
+ request("/v1/embed", { texts: texts })
46
+ end
47
+
48
+ private
49
+
50
+ def validate_url!
51
+ uri = URI(@base_url)
52
+ ok = uri.scheme == "https" && uri.host && !uri.host.empty? &&
53
+ uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
54
+ # Plain HTTP may be used only for loopback development/test endpoints.
55
+ ok ||= uri.scheme == "http" && ["localhost", "127.0.0.1", "::1"].include?(uri.host) &&
56
+ uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
57
+ raise InvalidInput, "base_url requires HTTPS (or loopback HTTP)" unless ok
58
+ rescue URI::InvalidURIError
59
+ raise InvalidInput, "base_url is invalid"
60
+ end
61
+
62
+ def request(path, payload)
63
+ uri = URI("#{@base_url}#{path}")
64
+ headers = { "content-type" => "application/json", "accept" => "application/json" }
65
+ headers["authorization"] = "Bearer #{@api_key}" if @api_key && !@api_key.empty?
66
+ body = JSON.generate(payload)
67
+ response = if @transport
68
+ @transport.call(uri, headers, body)
69
+ else
70
+ http_post(uri, headers, body)
71
+ end
72
+ status = response.code.to_i
73
+ case status
74
+ when 200..299
75
+ parsed = parse_json(response.body)
76
+ raise InvalidResponse, "ARBITER response must contain a JSON object" unless parsed.is_a?(Hash)
77
+ parsed
78
+ when 402
79
+ # Do not auto-sign or spend funds. Preserve challenge for a caller-controlled payment flow.
80
+ challenge = response.each_header.to_h.select { |key, _| key.match?(/payment|x402/i) }
81
+ raise PaymentRequired.new("ARBITER returned HTTP 402; configure credits or handle the payment challenge", challenge: challenge)
82
+ when 401, 403
83
+ raise Unauthorized, "ARBITER authentication or permission failed (HTTP #{status})"
84
+ else
85
+ raise HTTPError.new(status, "ARBITER HTTP #{status}: #{safe_detail(response.body)}")
86
+ end
87
+ rescue JSON::ParserError
88
+ raise InvalidResponse, "ARBITER returned invalid JSON"
89
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError => e
90
+ raise HTTPError.new(0, "ARBITER transport error (#{e.class})")
91
+ end
92
+
93
+ def http_post(uri, headers, body)
94
+ req = Net::HTTP::Post.new(uri)
95
+ headers.each { |key, value| req[key] = value }
96
+ req.body = body
97
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
98
+ open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
99
+ http.request(req)
100
+ end
101
+ end
102
+
103
+ def parse_json(body)
104
+ JSON.parse(body.to_s)
105
+ end
106
+
107
+ def safe_detail(body)
108
+ obj = JSON.parse(body.to_s)
109
+ return "request failed" unless obj.is_a?(Hash)
110
+ detail = obj["error"] || obj["message"] || "request failed"
111
+ detail.is_a?(String) ? detail[0, 250] : "request failed"
112
+ rescue JSON::ParserError
113
+ "request failed"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ module Arbiter
3
+ class Error < StandardError; end
4
+ class InvalidInput < Error; end
5
+ class InvalidResponse < Error; end
6
+ class Unauthorized < Error; end
7
+ class PaymentRequired < Error
8
+ attr_reader :status, :challenge
9
+ def initialize(message = "ARBITER payment required", challenge: {})
10
+ @status = 402
11
+ @challenge = challenge.freeze
12
+ super(message)
13
+ end
14
+ end
15
+ class HTTPError < Error
16
+ attr_reader :status
17
+ def initialize(status, message)
18
+ @status = status
19
+ super(message)
20
+ end
21
+ end
22
+ class ActionNotPermitted < Error; end
23
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ module Arbiter
3
+ module Flow
4
+ module_function
5
+
6
+ # The caller supplies only authorized actions. A separate recheck runs just
7
+ # before execution to catch changed permissions after measurement.
8
+ #
9
+ # actions: { retry: { description: 'Retry card', run: -> { ... } }, ... }
10
+ # permitted: optional ->(name, action) { ... } policy callback
11
+ def decide(intent:, actions:, client: Arbiter.client, permitted: nil)
12
+ approved = validate_actions(actions, permitted)
13
+ raise ActionNotPermitted, "at least two permitted actions required" if approved.length < 2
14
+ descriptions = approved.values.map { |action| action.fetch(:description) }
15
+ if descriptions.uniq.length != descriptions.length
16
+ raise InvalidInput, "action descriptions must be unique"
17
+ end
18
+ raw = client.compare(query: intent, candidates: descriptions, top_k: approved.length)
19
+ ranking = Ranking.extract(raw, descriptions)
20
+ raise InvalidResponse, "ARBITER did not return a ranked candidate" if ranking.empty?
21
+ id = approved.keys.fetch(ranking.first.fetch(:index))
22
+ { action: id, ranking: ranking, raw: raw, permitted_actions: approved.keys }
23
+ end
24
+
25
+ def dispatch(intent:, actions:, client: Arbiter.client, permitted: nil)
26
+ decision = decide(intent: intent, actions: actions, client: client, permitted: permitted)
27
+ name = decision.fetch(:action)
28
+ action = actions.fetch(name)
29
+ # Revalidation at execution boundary. The caller's policy remains authoritative.
30
+ if permitted && !permitted.call(name, action)
31
+ raise ActionNotPermitted, "selected action is no longer permitted"
32
+ end
33
+ result = action.fetch(:run).call
34
+ decision.merge(executed: true, value: result)
35
+ end
36
+
37
+ def validate_actions(actions, permitted)
38
+ raise InvalidInput, "actions must be a Hash" unless actions.is_a?(Hash) && !actions.empty?
39
+ unless permitted.nil? || permitted.respond_to?(:call)
40
+ raise InvalidInput, "permitted must be a callable"
41
+ end
42
+ approved = {}
43
+ actions.each do |name, action|
44
+ unless (name.is_a?(Symbol) || name.is_a?(String)) && action.is_a?(Hash) &&
45
+ action[:description].is_a?(String) && !action[:description].strip.empty? &&
46
+ action[:run].respond_to?(:call)
47
+ raise InvalidInput, "each action needs an id, description, and callable :run"
48
+ end
49
+ approved[name] = action if !permitted || permitted.call(name, action)
50
+ end
51
+ approved
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ module Arbiter
3
+ # Normalizes only unambiguous score/ranking data. Unknown response formats raise.
4
+ # Raw compare() responses remain available without normalization.
5
+ module Ranking
6
+ module_function
7
+
8
+ def extract(response, candidates)
9
+ raise InvalidResponse, "response must be a Hash" unless response.is_a?(Hash)
10
+ raise InvalidInput, "candidate strings must be unique for ranking" unless candidates.uniq.length == candidates.length
11
+
12
+ # Some integrations wrap the API body in { "result" => {...} }.
13
+ body = response["result"].is_a?(Hash) ? response["result"] : response
14
+
15
+ ["results", "ranking", "ranked", "ranked_candidates", "matches"].each do |field|
16
+ next unless body[field].is_a?(Array)
17
+ ranking = from_items(body[field], candidates)
18
+ return ranking if ranking
19
+ end
20
+ if body["scores"].is_a?(Array) && body["scores"].length == candidates.length &&
21
+ body["scores"].all? { |score| numeric?(score) }
22
+ return candidates.each_with_index.map { |candidate, i| row(candidate, i, Float(body["scores"][i])) }
23
+ .sort_by { |item| [-item[:score], item[:index]] }
24
+ end
25
+ raise InvalidResponse, "unrecognized ARBITER ranking response; inspect raw compare() result"
26
+ end
27
+
28
+ def from_items(items, candidates)
29
+ return nil if items.empty?
30
+ used = {}
31
+ rows = items.map do |item|
32
+ idx, score = case item
33
+ when Hash
34
+ index = item["index"] || item["candidate_index"]
35
+ label = item["candidate"] || item["text"] || item["value"]
36
+ index = candidates.index(label) if index.nil? && label.is_a?(String)
37
+ [index, item["score"] || item["resonance"]]
38
+ when String
39
+ [candidates.index(item), nil]
40
+ when Integer
41
+ [item, nil]
42
+ else
43
+ return nil
44
+ end
45
+ return nil unless idx.is_a?(Integer) && idx.between?(0, candidates.length - 1)
46
+ return nil if used[idx]
47
+ return nil unless score.nil? || numeric?(score)
48
+ used[idx] = true
49
+ row(candidates[idx], idx, score.nil? ? nil : Float(score))
50
+ end
51
+ # Prefer API order if it supplies ordered items; never re-sort returned rankings.
52
+ rows
53
+ end
54
+
55
+ def row(candidate, index, score)
56
+ { candidate: candidate, index: index, score: score }
57
+ end
58
+
59
+ def numeric?(value)
60
+ (value.is_a?(Numeric) || value.is_a?(String)) && Float(value).finite?
61
+ rescue ArgumentError, TypeError
62
+ false
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Arbiter
3
+ VERSION = "0.1.0"
4
+ end
data/lib/arbiter.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ require_relative "arbiter/version"
3
+ require_relative "arbiter/errors"
4
+ require_relative "arbiter/client"
5
+ require_relative "arbiter/ranking"
6
+ require_relative "arbiter/flow"
7
+
8
+ module Arbiter
9
+ class << self
10
+ attr_writer :client
11
+
12
+ def client
13
+ @client ||= Client.new
14
+ end
15
+
16
+ def configure(**settings)
17
+ self.client = Client.new(**settings)
18
+ end
19
+
20
+ def compare(**args)
21
+ client.compare(**args)
22
+ end
23
+
24
+ def embed(**args)
25
+ client.embed(**args)
26
+ end
27
+
28
+ def rank(query:, candidates:, top_k: nil)
29
+ response = compare(query: query, candidates: candidates, top_k: top_k)
30
+ Ranking.extract(response, candidates)
31
+ end
32
+
33
+ def decide(**args)
34
+ Flow.decide(**args)
35
+ end
36
+
37
+ def dispatch(**args)
38
+ Flow.dispatch(**args)
39
+ end
40
+ end
41
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: arbiter-gem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Actual General Intelligence, Inc.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Ruby client for ARBITER compare and embed, plus explicit finite-action
13
+ dispatch. Remote measurement, no local engine or binary.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - CHANGELOG.md
19
+ - LICENSE
20
+ - README.md
21
+ - lib/arbiter.rb
22
+ - lib/arbiter/client.rb
23
+ - lib/arbiter/errors.rb
24
+ - lib/arbiter/flow.rb
25
+ - lib/arbiter/ranking.rb
26
+ - lib/arbiter/version.rb
27
+ homepage: https://arbiter.grip.fyi
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://arbiter.grip.fyi
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 4.0.3
47
+ specification_version: 4
48
+ summary: ARBITER deterministic measurement for Ruby decisions and control flow
49
+ test_files: []