portage-ucp-decision 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: 76adc7a80bc65b5fc181bc69c773cea60c9ad1f11a6bb3028add71d895321683
4
+ data.tar.gz: e6048995c2bbadb78389342bfebe78c25eafefb04a95a158747ba2a97f2120ba
5
+ SHA512:
6
+ metadata.gz: 6b95c193adcc3c78e19bf01ebdc88eb88b681b5414c081af1d84438a93c025be438e78735637a136e791dc7ca5931d6689451421b2fb7d8062c81a99fc7ed2b1
7
+ data.tar.gz: b7d76577b2e68e0cea4f5e8701cdcdf8fad1e799fefcb594668e7764fe6d368676e6278b2ede5115d536b34c2c2bf4d44a0e1c237286ab298c9aa2e379fdc9e0
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
+ pre-1.0, so APIs may still shift between minor versions.
6
+
7
+ ## [Unreleased]
8
+
9
+ - `OfferRanking` and `EscalationPolicy` now delegate to
10
+ `portage-ucp`'s `Support::OfferRanking` and `Support::Escalation`, the
11
+ way `PolicyCheck` already delegated to `PolicyGuard`. Their `Verdict`s
12
+ and signatures are unchanged. Requires `portage-ucp ~> 0.9`.
13
+ - **Removed surface nothing called**, before the first publish (the README's
14
+ "What this gem leaves out" says why for each). **Breaking** against the
15
+ 0.1.0 skeleton:
16
+ - `ConfidenceGate.via_backend` asks a noul only. `type:`, `criteria:` and
17
+ `proceed_on:` are gone. A choice or score answer now raises
18
+ `BackendError` ("carried no probability") rather than gating.
19
+ - `ModelBackends::Answer#probabilities`.
20
+ - `EscalationPolicy`'s `signals: {mismatch:}` and
21
+ `EscalationPolicy::ESCALATING_STATUSES`.
22
+ - `PolicyCheck`'s `risk_signals:` and `Verdict#decision`. The `Verdict`
23
+ is now `allowed`, `reason`.
24
+ - **A backend failure could escape as a raw exception.** Jev let
25
+ `Faraday::Error`, `JSON::ParserError` (a non-JSON 2xx) and `KeyError` (no
26
+ `answers`) through, Laya let `Errno::ENOENT` (a missing
27
+ `LAYA_INFER_COMMAND`) and `KeyError` through, and `ConfidenceGate` raised
28
+ `KeyError` for an unanswered question. All are now `BackendError`, with
29
+ one shared `ModelBackends.parse_answers` for both backends' replies.
30
+ - **Neither backend had a timeout.** Jev could stall a checkout for about two
31
+ minutes on a hung connection, and Laya forever. Jev now gives up after 5s
32
+ to connect and 15s to answer. Laya kills its bridge after `timeout:`
33
+ (default 60s).
34
+ - `LAYA_PYTHON=/opt/venv/bin/python` (a path, not a bare name) was always
35
+ reported as "isn't on PATH", so the backend never counted as configured.
36
+ - `examples/laya_bridge.py` returned `type`/`confidence`/`value`, which the
37
+ Ruby side doesn't read, so every noul through it held the purchase. It
38
+ now emits the per-type keys (`noul`, `choice`, `score`).
39
+ - `Jev#configuration_problem` joins `Laya#configuration_problem` (now
40
+ public): the reason a backend can't answer yet, for a setup check.
41
+
42
+ ## [0.1.0] - 2026-09-23
43
+
44
+ - First skeleton: `OfferRanking`, `EscalationPolicy`, `ConfidenceGate`, and
45
+ `PolicyCheck` as typed decisions, per
46
+ `docs/plans/system-one-decision-layer.md`.
47
+ - `ConfidenceGate.via_backend` gates on what the model answered, not only on
48
+ how sure it was. Checked live against Jev:
49
+ - A noul (the default type) crashed with `NoMethodError` on every call,
50
+ because Jev sends no `confidence` for a noul. A noul now gates on its
51
+ yes-probability.
52
+ - A choice or score proceeded on any confident answer, a confident
53
+ "escalate" included (Jev answered `escalate` at 0.99 for a live
54
+ `requires_escalation` checkout). These now need `proceed_on:` (an
55
+ option, or a Range of score levels), and proceed only on a match.
56
+ - `ModelBackends::Jev` falls back to `TYPESAFE_API_KEY`, the name TypeSafe's
57
+ own docs use, when `JEV_API_KEY` isn't set.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
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,104 @@
1
+ # portage-ucp-decision
2
+
3
+ The System One decision layer for `portage-ucp` — see
4
+ `docs/plans/system-one-decision-layer.md` for why this exists.
5
+
6
+ Sits between the agent loop and the Adapter/client layer, turning judgment
7
+ calls that were scattered across skill instructions, `portage-cli`, and
8
+ `portage-ucp` into typed, inspectable decisions:
9
+
10
+ - `Portage::Ucp::Decision::OfferRanking` — which offer to pick among several.
11
+ A typed wrapper around `Portage::Ucp::Support::OfferRanking`.
12
+ - `Portage::Ucp::Decision::EscalationPolicy` — hand off vs. keep going. A
13
+ typed wrapper around `Portage::Ucp::Support::Escalation`.
14
+ - `Portage::Ucp::Decision::ConfidenceGate` — proceed unattended above a
15
+ threshold.
16
+ - `Portage::Ucp::Decision::PolicyCheck` — a typed wrapper around
17
+ `Portage::Ucp::PolicyGuard.check!`.
18
+
19
+ The ranking, escalation and policy rules live in `portage-ucp` core, and
20
+ these three wrap them as `Verdict`s. `portage-cli` calls the core modules
21
+ directly, so it answers the same way with or without this gem. Only
22
+ `ConfidenceGate` and its model backends are unique to this gem.
23
+
24
+ `ModelBackends::Laya` needs a Python bridge script (it's HuggingFace weights,
25
+ not a hosted API) — see `examples/laya_bridge.py` and set
26
+ `LAYA_BRIDGE_SCRIPT`; a missing or broken bridge raises
27
+ `BackendNotConfiguredError`/`BackendError` with a message naming the exact
28
+ problem (unset, path doesn't exist, `LAYA_PYTHON` not executable, non-zero
29
+ exit, timeout, unreadable reply). The bridge must answer with the same
30
+ `{"answers": {name => answer}}` body Jev returns, each answer keyed by its
31
+ type: `{"type": "noul", "noul": <P(yes)>}`, `{"type": "choice", "choice":
32
+ <option>, "confidence": <c>}`, `{"type": "score", "score": <level>,
33
+ "confidence": <c>}`. `ModelBackends::Jev` reads `JEV_API_KEY` (falling back
34
+ to `TYPESAFE_API_KEY`, TypeSafe's own name for it).
35
+
36
+ Both backends time out (Jev after 5s to connect and 15s to answer, Laya
37
+ after 60s by default, `timeout:`), and every failure — a timeout, a
38
+ connection error, a reply that isn't that shape — raises a
39
+ `Decision::Error` subclass, so one `rescue Portage::Ucp::Decision::Error`
40
+ covers a backend call. `#configuration_problem` returns the reason a backend
41
+ can't answer yet (nil when it can); `portage doctor` reports it for the
42
+ backend `PORTAGE_DECISION_BACKEND` selects, and says nothing when none is
43
+ selected.
44
+
45
+ `EscalationPolicy` branches on the literal `requires_escalation` status plus
46
+ an ambiguous-signal case (`signals: {warnings:}`, any warning escalates).
47
+ `ConfidenceGate` compares a score it's handed (`.call`) or gets one from a
48
+ model backend (`.via_backend`, `ModelBackends::Jev`/`ModelBackends::Laya`).
49
+ See the plan doc's "Open questions" section for what's still unresolved.
50
+
51
+ ## Usage
52
+
53
+ ```ruby
54
+ require "portage/ucp/decision"
55
+
56
+ Portage::Ucp::Decision::EscalationPolicy.call(checkout_status: checkout.status)
57
+ # => #<data Verdict escalate=true, reason=:requires_escalation>
58
+ ```
59
+
60
+ `ConfidenceGate.via_backend` asks one yes/no ("noul") question and gates on
61
+ the probability the backend answered yes, so phrase the question so "yes"
62
+ means "safe to proceed":
63
+
64
+ ```ruby
65
+ jev = Portage::Ucp::Decision::ModelBackends::Jev.new
66
+
67
+ Portage::Ucp::Decision::ConfidenceGate.via_backend(
68
+ backend: jev, state: checkout.to_json, question: "safe_to_complete", threshold: 0.8,
69
+ instructions: "Answer yes only if this checkout is safe to complete without a person reviewing it."
70
+ )
71
+ # => #<data Verdict proceed=false, confidence=0.37, threshold=0.8>
72
+ ```
73
+
74
+ ## What this gem leaves out, and why
75
+
76
+ These were in the first skeleton and were removed before 0.1.0 was
77
+ published, because nothing called them:
78
+
79
+ - **Choice and score questions in `ConfidenceGate`** (`type:`, `criteria:`,
80
+ `proceed_on:`). A choice's or score's `confidence` says how sure the model
81
+ is of whichever answer it gave, so a confident "escalate" clears the
82
+ threshold; `proceed_on:` was needed just to stop that. A noul's
83
+ yes-probability is the one number that means "safe to proceed", and it's
84
+ the only question `portage buy` asks. To use a choice or score, call the
85
+ backend's `#ask` yourself and read the `Answer`.
86
+ - **`ModelBackends::Answer#probabilities`.** Parsed from the reply but read
87
+ by nothing.
88
+ - **`EscalationPolicy`'s `mismatch:` signal.** `warnings:` covers it: pass
89
+ a one-line description of the mismatch.
90
+ - **`EscalationPolicy::ESCALATING_STATUSES`.** It only ever held
91
+ `requires_escalation`, which is `Portage::Ucp::Support::Escalation::STATUS`.
92
+ - **`PolicyCheck`'s `risk_signals:`, and the `decision:` field on its
93
+ `Verdict`.** Nothing computes a risk signal yet (merchant age,
94
+ TLS/manifest signing, escalation rate), and `decision:` repeated
95
+ `allowed`/`reason`. A caller with its own signal can deny before calling
96
+ `PolicyCheck`.
97
+
98
+ ## Development
99
+
100
+ ```
101
+ bundle install
102
+ bundle exec rspec
103
+ bundle exec rubocop
104
+ ```
@@ -0,0 +1,57 @@
1
+ module Portage
2
+ module Ucp
3
+ module Decision
4
+ # docs/plans/system-one-decision-layer.md § Responsibilities 3.
5
+ #
6
+ # Wholly new — no precedent in this repo. `Confirmer` is binary
7
+ # (confirm or don't); this is the graded threshold a caller sits in
8
+ # front of it. Where the `confidence:` score itself comes from was the
9
+ # doc's open question — `.via_backend` below answers it for a model
10
+ # backend (ModelBackends::Jev/Laya); `.call` stays the pure comparator
11
+ # for a caller with its own heuristic score.
12
+ module ConfidenceGate
13
+ Verdict = Data.define(:proceed, :confidence, :threshold)
14
+
15
+ # @param confidence [Float] 0.0..1.0.
16
+ # @param threshold [Float] 0.0..1.0 — the caller's own risk posture,
17
+ # not something this gate defaults or infers.
18
+ def self.call(confidence:, threshold:)
19
+ Verdict.new(proceed: confidence >= threshold, confidence: confidence, threshold: threshold)
20
+ end
21
+
22
+ # Asks a ModelBackends backend one yes/no ("noul") question and gates
23
+ # on the probability it answered yes. Phrase the question so "yes"
24
+ # means "safe to proceed". Jev sends no separate `confidence` for a
25
+ # noul (docs.typesafe.ai/api — confirmed live:
26
+ # `{"type":"noul","noul":0.37}`), and none is needed: the
27
+ # yes-probability is the thing to threshold.
28
+ #
29
+ # Only a noul, on purpose. A choice's or score's `confidence` is how
30
+ # sure the model is of whichever answer it gave, so a confident
31
+ # "escalate" would clear the threshold (Jev did exactly that, at
32
+ # 0.99, for a live `requires_escalation` checkout). A caller that
33
+ # wants a choice or score can ask the backend directly with
34
+ # `#ask` and read the answer itself.
35
+ #
36
+ # @param backend [#ask] a ModelBackends::Jev/Laya instance (or
37
+ # anything answering the same `#ask(state:, questions:)` shape).
38
+ # @param state [String]
39
+ # @param question [String] the key the answer comes back under.
40
+ # @param instructions [String] what the backend should evaluate.
41
+ # @param threshold [Float]
42
+ # @return [Verdict]
43
+ def self.via_backend(backend:, state:, question:, instructions:, threshold:)
44
+ asked = { question => ModelBackends::Question.new(type: "noul", instructions: instructions) }
45
+ answer = backend.ask(state: state, questions: asked).fetch(question) do
46
+ raise Portage::Ucp::Decision::BackendError, "backend returned no answer for #{question.inspect}"
47
+ end
48
+ unless answer.value.is_a?(Numeric)
49
+ raise Portage::Ucp::Decision::BackendError, "noul answer carried no probability: #{answer.to_h}"
50
+ end
51
+
52
+ call(confidence: answer.value, threshold: threshold)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,20 @@
1
+ module Portage
2
+ module Ucp
3
+ module Decision
4
+ class Error < StandardError; end
5
+
6
+ # Raised by a ModelBackend when it's asked to run without whatever it
7
+ # needs to actually reach a model — an API key (Jev) or a local
8
+ # inference command (Laya) — rather than failing further in with a
9
+ # confusing HTTP or ENOENT error.
10
+ class BackendNotConfiguredError < Error; end
11
+
12
+ # Raised by a ModelBackend when the call itself fails — a non-2xx
13
+ # response (Jev) or a non-zero exit status (Laya).
14
+ class BackendError < Error; end
15
+
16
+ # Raised by ModelBackends.resolve for a name not in ModelBackends::REGISTRY.
17
+ class UnknownBackendError < Error; end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Decision
6
+ # docs/plans/system-one-decision-layer.md § Responsibilities 2.
7
+ #
8
+ # A typed wrapper around `Portage::Ucp::Support::Escalation`, the way
9
+ # PolicyCheck wraps `PolicyGuard`. The rule lives in core: the literal
10
+ # `requires_escalation` status wins, then a mismatch the caller found.
11
+ # That covers the doc's "ambiguous signal" case, a merchant surfacing
12
+ # a mismatch that isn't literally `requires_escalation` but should
13
+ # still stop here. `signals: {warnings:}` takes the same
14
+ # `warnings: [String]` array `portage buy` puts on its report.
15
+ module EscalationPolicy
16
+ Verdict = Data.define(:escalate, :reason)
17
+
18
+ # @param checkout_status [String] a Checkout#status value.
19
+ # @param signals [Hash] `warnings:` an Array of human-readable
20
+ # mismatch strings. Any element escalates; none is required.
21
+ def self.call(checkout_status:, signals: {})
22
+ reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status,
23
+ warnings: signals[:warnings])
24
+ Verdict.new(escalate: !reason.nil?, reason: reason)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ require "json"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Decision
6
+ module ModelBackends
7
+ # A single typed question sent to a backend — the wire shape both
8
+ # Jev (api.typesafe.ai/v1/systemone) and the Laya subprocess
9
+ # contract share. `criteria` is a Hash for `type: "choice"`, an
10
+ # Array for `type: "score"`, absent for `type: "noul"` (yes/no).
11
+ Question = Data.define(:type, :instructions, :criteria) do
12
+ def initialize(type:, instructions:, criteria: nil) = super
13
+
14
+ def to_wire_h = { "type" => type, "instructions" => instructions, "criteria" => criteria }.compact
15
+ end
16
+
17
+ # A single typed answer back from a backend. `value` is the answer
18
+ # itself (a choice's option, a score's level, a noul's probability of
19
+ # yes). `confidence` is how sure the model is of a choice or score —
20
+ # Jev sends none for a noul, so ConfidenceGate gates a noul on
21
+ # `value` instead.
22
+ Answer = Data.define(:type, :confidence, :value) do
23
+ def initialize(type:, confidence:, value: nil) = super
24
+ end
25
+
26
+ # The `{"answers": {name => {...}}}` body both backends return, as
27
+ # Answers. Anything that isn't that shape — invalid JSON, no
28
+ # `answers`, an answer that isn't an object — is a BackendError
29
+ # naming `source`, so a caller rescuing Decision::Error sees every
30
+ # bad reply rather than a stray JSON::ParserError or KeyError.
31
+ #
32
+ # @param raw [String] the backend's response body / stdout.
33
+ # @param source [String] who answered, for the error message.
34
+ # @return [Hash{String => Answer}]
35
+ def self.parse_answers(raw, source:)
36
+ JSON.parse(raw).fetch("answers").transform_values do |answer|
37
+ Answer.new(type: answer["type"], confidence: answer["confidence"],
38
+ value: answer["choice"] || answer["score"] || answer["noul"])
39
+ end
40
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError => e
41
+ raise Portage::Ucp::Decision::BackendError,
42
+ "#{source} returned an unreadable answer (#{e.class}: #{e.message}): #{raw.to_s[0, 300].inspect}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,85 @@
1
+ require "json"
2
+ require "faraday"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Decision
7
+ module ModelBackends
8
+ # Client for TypeSafe AI's Jev — the "System One Model" the plan doc's
9
+ # title borrowed its name from (docs.typesafe.ai/introduction/quickstart).
10
+ # A hosted decision API: POST state + typed questions, get back typed
11
+ # answers with a calibrated `confidence` per answer.
12
+ class Jev
13
+ BASE_URL = "https://api.typesafe.ai/v1/systemone".freeze
14
+ DEFAULT_MODEL = "jev-latest".freeze
15
+ # This gem's own name for the credential, matching this backend's
16
+ # name in the registry. TYPESAFE_API_KEY — the name TypeSafe's own
17
+ # docs and SDKs use — is read as a fallback, so a key already set up
18
+ # for TypeSafe works here without being copied to a second name.
19
+ # Seconds. A confidence check sits in front of a checkout the
20
+ # shopper is waiting on; Net::HTTP's defaults would stall it for up
21
+ # to two minutes on a hung connection.
22
+ OPEN_TIMEOUT = 5
23
+ TIMEOUT = 15
24
+ ENV_KEY = "JEV_API_KEY".freeze
25
+ FALLBACK_ENV_KEY = "TYPESAFE_API_KEY".freeze
26
+
27
+ def self.env_api_key
28
+ [ENV_KEY, FALLBACK_ENV_KEY].map { |key| ENV.fetch(key, nil).to_s.strip }.find { |key| !key.empty? }
29
+ end
30
+
31
+ def initialize(api_key: self.class.env_api_key, model: DEFAULT_MODEL, connection: nil)
32
+ @api_key = api_key
33
+ @model = model
34
+ @connection = connection ||
35
+ Faraday.new(url: BASE_URL, request: { open_timeout: OPEN_TIMEOUT, timeout: TIMEOUT })
36
+ end
37
+
38
+ def configured? = configuration_problem.nil?
39
+
40
+ # @return [String, nil] why this backend can't answer yet, or nil
41
+ # when it can — same contract as Laya#configuration_problem.
42
+ def configuration_problem
43
+ return nil unless @api_key.to_s.strip.empty?
44
+
45
+ "#{ENV_KEY} is not set (nor #{FALLBACK_ENV_KEY}) — get a key at https://console.typesafe.ai " \
46
+ "and set #{ENV_KEY}"
47
+ end
48
+
49
+ # @param state [String] the text (or JSON-serialized context) to
50
+ # evaluate — a checkout, an offer list, a merchant signal.
51
+ # @param questions [Hash{String => Question}]
52
+ # @return [Hash{String => Answer}]
53
+ def ask(state:, questions:)
54
+ problem = configuration_problem
55
+ raise Portage::Ucp::Decision::BackendNotConfiguredError, problem if problem
56
+
57
+ response = post(state, questions)
58
+ raise_for_status!(response)
59
+ ModelBackends.parse_answers(response.body, source: "Jev")
60
+ rescue Faraday::Error => e
61
+ raise Portage::Ucp::Decision::BackendError, "Jev request failed: #{e.class}: #{e.message}"
62
+ end
63
+
64
+ private
65
+
66
+ def post(state, questions)
67
+ @connection.post("") do |req|
68
+ req.headers["Authorization"] = "Bearer #{@api_key}"
69
+ req.headers["Content-Type"] = "application/json"
70
+ req.body = JSON.generate(state: state, model: @model,
71
+ questions: questions.transform_values(&:to_wire_h))
72
+ end
73
+ end
74
+
75
+ def raise_for_status!(response)
76
+ return if response.status.between?(200, 299)
77
+
78
+ raise Portage::Ucp::Decision::BackendError,
79
+ "Jev request failed: #{response.status} #{response.body.to_s[0, 300]}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,133 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Decision
7
+ module ModelBackends
8
+ # Client for Laya (huggingface.co/convaiinnovations/laya) — a local,
9
+ # open-weights (Apache 2.0) decision-classification model, not a
10
+ # hosted API. It ships as ModernBERT/mmBERT weights invoked through
11
+ # Python (HuggingFace `transformers`, or the `laya` SDK's
12
+ # `laya.load()`), and this is a Ruby gem, so this backend shells out
13
+ # to a Python "bridge script" instead of embedding the model. The
14
+ # script must speak this gem's own stdin/stdout JSON contract —
15
+ # `{"state", "questions"}` in, `{"answers"}` out, the same shape
16
+ # ModelBackends::Jev uses — not any interface Laya's Python side
17
+ # defines itself. Writing that script is the caller's job (a
18
+ # starting point is at `examples/laya_bridge.py` in this gem); this
19
+ # class only owns the Ruby-side contract and the two failure modes
20
+ # around it not being there yet: no bridge configured at all, and a
21
+ # configured one that's missing or fails at call time.
22
+ class Laya
23
+ DEFAULT_PYTHON = "python3".freeze
24
+ ENV_BRIDGE_SCRIPT_KEY = "LAYA_BRIDGE_SCRIPT".freeze
25
+ ENV_PYTHON_KEY = "LAYA_PYTHON".freeze
26
+ # Escape hatch for a caller whose bridge isn't `python3 <script>` —
27
+ # a container entrypoint, a wrapper shell script, anything already
28
+ # executable on its own. Takes precedence over bridge_script/python
29
+ # since it's a deliberate override, not a default.
30
+ ENV_COMMAND_KEY = "LAYA_INFER_COMMAND".freeze
31
+ # Seconds. Generous, since the bridge loads model weights on every
32
+ # call, but bounded: a stuck bridge must hold the purchase, not
33
+ # hang it.
34
+ DEFAULT_TIMEOUT = 60
35
+
36
+ def initialize(bridge_script: ENV.fetch(ENV_BRIDGE_SCRIPT_KEY, nil),
37
+ python: ENV.fetch(ENV_PYTHON_KEY, DEFAULT_PYTHON),
38
+ command: ENV.fetch(ENV_COMMAND_KEY, nil), timeout: DEFAULT_TIMEOUT)
39
+ @bridge_script = bridge_script
40
+ @python = python
41
+ @custom_command = command
42
+ @timeout = timeout
43
+ end
44
+
45
+ def configured? = configuration_problem.nil?
46
+
47
+ # @param state [String]
48
+ # @param questions [Hash{String => Question}]
49
+ # @return [Hash{String => Answer}]
50
+ def ask(state:, questions:)
51
+ problem = configuration_problem
52
+ raise Portage::Ucp::Decision::BackendNotConfiguredError, problem if problem
53
+
54
+ stdout, stderr, status = run(request_json(state, questions))
55
+ raise Portage::Ucp::Decision::BackendError, "Laya bridge failed: #{stderr}" unless status.success?
56
+
57
+ ModelBackends.parse_answers(stdout, source: "Laya bridge")
58
+ end
59
+
60
+ # nil means "configured" — anything else is the reason it isn't,
61
+ # returned rather than raised so #configured?, #ask and a setup
62
+ # check (portage doctor) share one check without swallowing an
63
+ # exception.
64
+ def configuration_problem
65
+ return nil if @custom_command
66
+
67
+ unless @bridge_script
68
+ return "no Laya bridge configured — set #{ENV_BRIDGE_SCRIPT_KEY} to a Python script implementing " \
69
+ "the state/questions -> answers JSON contract (see examples/laya_bridge.py), or " \
70
+ "#{ENV_COMMAND_KEY} for a fully custom command"
71
+ end
72
+
73
+ return "#{ENV_BRIDGE_SCRIPT_KEY}=#{@bridge_script} does not exist" unless File.file?(@bridge_script)
74
+
75
+ return "#{ENV_PYTHON_KEY}=#{@python} isn't an executable on PATH" unless which(@python)
76
+
77
+ nil
78
+ end
79
+
80
+ private
81
+
82
+ def command = @custom_command ? Array(@custom_command) : [@python, @bridge_script]
83
+
84
+ def request_json(state, questions)
85
+ JSON.generate(state: state, questions: questions.transform_values(&:to_wire_h))
86
+ end
87
+
88
+ # Open3.capture3 with a deadline: the child is killed, not left
89
+ # running, when it overruns. A command that can't start at all
90
+ # (ENOENT, EACCES) is a BackendError like any other bridge failure.
91
+ def run(input)
92
+ Open3.popen3(*command) do |stdin, stdout, stderr, wait|
93
+ readers = [Thread.new { stdout.read }, Thread.new { stderr.read }]
94
+ write_request(stdin, input)
95
+ timed_out = wait.join(@timeout).nil?
96
+ Process.kill("KILL", wait.pid) if timed_out
97
+ # After a kill, a grandchild (a wrapper's python) can still hold
98
+ # the pipes open, so don't wait on the readers indefinitely.
99
+ out, err = readers.map { |reader| reader.join(timed_out ? 1 : nil)&.value.to_s }
100
+ raise Portage::Ucp::Decision::BackendError, "Laya bridge timed out after #{@timeout}s" if timed_out
101
+
102
+ [out, err, wait.value]
103
+ end
104
+ rescue SystemCallError, IOError => e
105
+ raise Portage::Ucp::Decision::BackendError, "Laya bridge couldn't run #{command.first}: #{e.message}"
106
+ end
107
+
108
+ # A bridge that exits without reading its stdin closes the pipe
109
+ # early. Its exit status and stderr say why, so that's reported
110
+ # rather than the EPIPE.
111
+ def write_request(stdin, input)
112
+ stdin.write(input)
113
+ rescue Errno::EPIPE
114
+ nil
115
+ ensure
116
+ stdin.close
117
+ end
118
+
119
+ # A path (LAYA_PYTHON=/opt/venv/bin/python) is checked as-is; a
120
+ # bare name is looked up on PATH.
121
+ def which(command)
122
+ return (executable_file?(command) ? command : nil) if command.include?("/")
123
+
124
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, command) }
125
+ .find { |path| executable_file?(path) }
126
+ end
127
+
128
+ def executable_file?(path) = File.executable?(path) && !File.directory?(path)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "errors"
2
+ require_relative "model_backends/answer"
3
+ require_relative "model_backends/jev"
4
+ require_relative "model_backends/laya"
5
+
6
+ module Portage
7
+ module Ucp
8
+ module Decision
9
+ # docs/plans/system-one-decision-layer.md's open question — "Confidence
10
+ # gating needs a defined scale and source" — resolved here for two
11
+ # concrete, swappable model backends: Jev (TypeSafe AI's hosted
12
+ # "System One Model", the plan doc's own namesake) and Laya (an
13
+ # open-weights local model). Both speak the same `#ask(state:,
14
+ # questions:) -> Hash{String => Answer}` shape, so ConfidenceGate can
15
+ # threshold either one's `confidence` without knowing which is behind
16
+ # it.
17
+ module ModelBackends
18
+ REGISTRY = { "jev" => Jev, "laya" => Laya }.freeze
19
+
20
+ # @param name [String, Symbol] a REGISTRY key.
21
+ # @return a new backend instance.
22
+ def self.resolve(name, **)
23
+ REGISTRY.fetch(name.to_s) do
24
+ raise Portage::Ucp::Decision::UnknownBackendError,
25
+ "unknown decision model backend #{name.inspect} — known: #{REGISTRY.keys.join(', ')}"
26
+ end.new(**)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Decision
6
+ # docs/plans/system-one-decision-layer.md § Responsibilities 1.
7
+ #
8
+ # A typed wrapper around `Portage::Ucp::Support::OfferRanking`, the
9
+ # way PolicyCheck wraps `PolicyGuard`. The rule itself lives in core:
10
+ # buyable candidates first, then cheapest, then unpriced. portage-cli
11
+ # calls core directly, so its ranking can't drift from this one.
12
+ module OfferRanking
13
+ # A caller's candidate, wrapped just enough for ranking to compare
14
+ # it — the caller keeps whatever richer type (Product, a CLI hash,
15
+ # an agent-loop's own struct) it already has on `#offer`.
16
+ Candidate = Data.define(:offer, :buyable, :amount)
17
+
18
+ # @param candidates [Array<Candidate>]
19
+ # @return [Array<Candidate>] the same candidates, stable-sorted
20
+ # buyable-first, cheapest-first, unpriced-last.
21
+ def self.call(candidates)
22
+ Portage::Ucp::Support::OfferRanking.rank(candidates) { |candidate| [candidate.buyable, candidate.amount] }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Decision
6
+ # docs/plans/system-one-decision-layer.md § Responsibilities 4.
7
+ #
8
+ # Closest responsibility to done: `Portage::Ucp::PolicyGuard` already
9
+ # covers per-transaction cap, rolling cap, velocity, and merchant
10
+ # allowlist. This wraps it as a typed `Verdict` instead of a raised
11
+ # exception, so a caller gets the same inspectable shape as the other
12
+ # three decisions in this gem.
13
+ module PolicyCheck
14
+ Verdict = Data.define(:allowed, :reason)
15
+
16
+ # @return [Verdict]
17
+ def self.call(amount:, currency:, merchant:, token_ref:, policy: Portage::Ucp::Policy.load,
18
+ transaction_log: Portage::Ucp::Support::TransactionLog.new)
19
+ Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
20
+ token_ref: token_ref, policy: policy, transaction_log: transaction_log)
21
+ Verdict.new(allowed: true, reason: nil)
22
+ rescue Portage::Ucp::PolicyViolationError => e
23
+ Verdict.new(allowed: false, reason: e.reason)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Decision
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # docs/plans/system-one-decision-layer.md — the typed decision layer that
2
+ # sits between the agent loop and the Adapter/client layer: offer ranking,
3
+ # escalation policy, confidence gating, and policy checks as inspectable
4
+ # data, not control flow scattered across skill instructions and the CLI.
5
+ require_relative "decision/version"
6
+ require_relative "decision/errors"
7
+ require_relative "decision/model_backends"
8
+ require_relative "decision/offer_ranking"
9
+ require_relative "decision/escalation_policy"
10
+ require_relative "decision/confidence_gate"
11
+ require_relative "decision/policy_check"
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-decision
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: portage-ucp
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.9'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.88'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.88'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.24'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.24'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
97
+ description: 'The judgment calls between the agent loop and the Adapter/client layer,
98
+ as typed, inspectable decisions instead of control flow scattered across skill instructions
99
+ and the CLI: which offer to pick (OfferRanking), hand off vs. keep going (EscalationPolicy),
100
+ whether a result is confident enough to act on unattended (ConfidenceGate), and
101
+ a typed wrapper around Portage::Ucp::PolicyGuard (PolicyCheck). See docs/plans/system-one-decision-layer.md.'
102
+ email:
103
+ executables: []
104
+ extensions: []
105
+ extra_rdoc_files: []
106
+ files:
107
+ - CHANGELOG.md
108
+ - LICENSE
109
+ - README.md
110
+ - lib/portage/ucp/decision.rb
111
+ - lib/portage/ucp/decision/confidence_gate.rb
112
+ - lib/portage/ucp/decision/errors.rb
113
+ - lib/portage/ucp/decision/escalation_policy.rb
114
+ - lib/portage/ucp/decision/model_backends.rb
115
+ - lib/portage/ucp/decision/model_backends/answer.rb
116
+ - lib/portage/ucp/decision/model_backends/jev.rb
117
+ - lib/portage/ucp/decision/model_backends/laya.rb
118
+ - lib/portage/ucp/decision/offer_ranking.rb
119
+ - lib/portage/ucp/decision/policy_check.rb
120
+ - lib/portage/ucp/decision/version.rb
121
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-decision
122
+ licenses:
123
+ - MIT
124
+ metadata:
125
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-decision
126
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-decision/CHANGELOG.md
127
+ rubygems_mfa_required: 'true'
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '3.2'
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubygems_version: 3.5.22
144
+ signing_key:
145
+ specification_version: 4
146
+ summary: System One decision layer for portage-ucp — offer ranking, escalation, confidence,
147
+ policy
148
+ test_files: []