ruby-laya 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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tokenizers"
5
+
6
+ module Laya
7
+ # Thin wrapper over the Hugging Face `tokenizers` gem exposing what sequence construction
8
+ # needs: the special tokens and ids of a checkpoint, `encode_ids` (no special tokens) and a
9
+ # padded batch encoder for the embedding helper.
10
+ class Tokenizer
11
+ SPECIAL = %w[mask cls sep pad].freeze
12
+ CANDIDATES = {
13
+ "mask" => ["[MASK]", "<mask>"],
14
+ "cls" => ["[CLS]", "<s>", "<cls>", "<bos>"],
15
+ "sep" => ["[SEP]", "</s>", "<sep>", "<eos>"],
16
+ "pad" => ["[PAD]", "<pad>"]
17
+ }.freeze
18
+
19
+ attr_reader :inner, :mask_token, :cls_token, :sep_token, :pad_token,
20
+ :mask_token_id, :cls_token_id, :sep_token_id, :pad_token_id
21
+
22
+ # Load `tokenizer.json` plus the special-token names from `tokenizer_config.json` /
23
+ # `special_tokens_map.json` in `dir`.
24
+ def self.from_dir(dir)
25
+ json = File.join(dir, "tokenizer.json")
26
+ raise ModelNotFoundError, "tokenizer.json not found in #{dir}" unless File.exist?(json)
27
+
28
+ specials = {}
29
+ ["special_tokens_map.json", "tokenizer_config.json"].each do |name|
30
+ path = File.join(dir, name)
31
+ next unless File.exist?(path)
32
+
33
+ cfg = JSON.parse(File.read(path))
34
+ SPECIAL.each do |kind|
35
+ value = cfg["#{kind}_token"]
36
+ value = value["content"] if value.is_a?(Hash)
37
+ specials[kind] = value if value.is_a?(String) && !value.empty?
38
+ end
39
+ end
40
+ new(Tokenizers.from_file(json), **specials.transform_keys { |k| :"#{k}_token" })
41
+ end
42
+
43
+ def initialize(inner, mask_token: nil, cls_token: nil, sep_token: nil, pad_token: nil)
44
+ @inner = inner
45
+ @lock = Mutex.new
46
+ @mask_token = resolve("mask", mask_token)
47
+ @cls_token = resolve("cls", cls_token)
48
+ @sep_token = resolve("sep", sep_token)
49
+ @pad_token = resolve("pad", pad_token)
50
+ @mask_token_id = @inner.token_to_id(@mask_token)
51
+ @cls_token_id = @inner.token_to_id(@cls_token)
52
+ @sep_token_id = @inner.token_to_id(@sep_token)
53
+ @pad_token_id = @inner.token_to_id(@pad_token)
54
+ end
55
+
56
+ # Token ids for `text`, without special tokens (what `tok(text, add_special_tokens=False)` gives).
57
+ def encode_ids(text)
58
+ @inner.encode(text, add_special_tokens: false).ids
59
+ end
60
+
61
+ # Tokens for `text`, without special tokens.
62
+ def tokenize(text)
63
+ @inner.encode(text, add_special_tokens: false).tokens
64
+ end
65
+
66
+ def decode(ids, skip_special_tokens: true)
67
+ @inner.decode(ids, skip_special_tokens: skip_special_tokens)
68
+ end
69
+
70
+ def vocab_size
71
+ @inner.vocab_size
72
+ end
73
+
74
+ # Encode several texts with special tokens, truncated to `max_length` and padded to the
75
+ # longest. Returns {"input_ids" => [[...]], "attention_mask" => [[...]]}.
76
+ def encode_batch(texts, max_length: 512)
77
+ @lock.synchronize do
78
+ @inner.enable_truncation(max_length)
79
+ @inner.enable_padding(pad_id: @pad_token_id, pad_token: @pad_token)
80
+ begin
81
+ encodings = @inner.encode_batch(texts, add_special_tokens: true)
82
+ ensure
83
+ @inner.no_truncation
84
+ @inner.no_padding
85
+ end
86
+ { "input_ids" => encodings.map(&:ids), "attention_mask" => encodings.map(&:attention_mask) }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def resolve(kind, explicit)
93
+ candidates = explicit ? [explicit] + CANDIDATES[kind] : CANDIDATES[kind]
94
+ found = candidates.find { |c| !@inner.token_to_id(c).nil? }
95
+ return found if found
96
+
97
+ raise IncompatibleModelError,
98
+ "tokenizer has no #{kind} token (tried #{candidates.inspect}); check tokenizer_config.json"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # The reward and target arithmetic behind Laya's training, ported so a Ruby process can score
5
+ # or audit a checkpoint's calibration. Training itself lives upstream; these are the pure
6
+ # functions, over plain Arrays.
7
+ module Training
8
+ module_function
9
+
10
+ # The strictly proper scoring rule Laya is trained against: log score plus spherical score,
11
+ # minus a ranked probability score for ordinal questions.
12
+ #
13
+ # `reported` and `targets` are Arrays of distributions, `qtypes` the question type per row
14
+ # (a name or its index), and `mask` marks the valid options of each row.
15
+ def proper_reward(reported, targets, qtypes, mask = nil, spherical_weight: 0.5,
16
+ rps_weight: 1.0, log_floor: -9.21)
17
+ reported.each_with_index.map do |row, i|
18
+ valid = if mask
19
+ mask[i].map { |flag| flag ? 1.0 : 0.0 }
20
+ else
21
+ Array.new(row.length, 1.0)
22
+ end
23
+ masked = row.each_with_index.map { |value, j| value * valid[j] }
24
+ target = targets[i]
25
+ reward = log_score(masked, target, log_floor) +
26
+ (spherical_weight * spherical_score(masked, target))
27
+ next reward unless score_question?(qtypes[i])
28
+
29
+ reward - (rps_weight * ranked_probability_score(masked, target, valid))
30
+ end
31
+ end
32
+
33
+ def log_score(reported, target, log_floor)
34
+ reported.each_with_index.sum do |value, i|
35
+ target[i] * [Math.log([value, 1e-12].max), log_floor].max
36
+ end
37
+ end
38
+
39
+ def spherical_score(reported, target)
40
+ norm = Math.sqrt(reported.sum { |value| value * value })
41
+ reported.each_with_index.sum { |value, i| target[i] * value } / [norm, 1e-9].max
42
+ end
43
+
44
+ def ranked_probability_score(reported, target, valid)
45
+ options = [valid.sum, 2.0].max
46
+ reported_cdf = cumulative(reported)
47
+ target_cdf = cumulative(target)
48
+ squared = reported_cdf.each_with_index.sum do |value, i|
49
+ ((value - target_cdf[i])**2) * valid[i]
50
+ end
51
+ squared / (options - 1)
52
+ end
53
+
54
+ def cumulative(values)
55
+ total = 0.0
56
+ values.map { |value| total += value }
57
+ end
58
+
59
+ def score_question?(qtype)
60
+ index = qtype.is_a?(Integer) ? qtype : QTYPES[qtype.to_s]
61
+ index == QTYPES["score"]
62
+ end
63
+
64
+ # TD(lambda) targets for multi-turn conversation trajectories.
65
+ #
66
+ # `batch` holds "target" (a `[false, true]` pair per row) and, for trajectories, "ep_group"
67
+ # and "ep_step". Without groups the targets are returned unchanged.
68
+ def td_lambda_targets(p_true, batch, lam: 1.0)
69
+ targets = Util.get(batch, "target").map(&:dup)
70
+ groups = Util.get(batch, "ep_group")
71
+ return targets if groups.nil?
72
+
73
+ steps = Util.get(batch, "ep_step")
74
+ groups.uniq.select { |group| group >= 0 }.sort.each do |group|
75
+ rows = groups.each_index.select { |i| groups[i] == group }.sort_by { |i| steps[i] }
76
+ discounted = targets[rows.last][1]
77
+ rows.each_index.reverse_each do |position|
78
+ row = rows[position]
79
+ unless position == rows.length - 1
80
+ discounted = ((1 - lam) * p_true[rows[position + 1]]) + (lam * discounted)
81
+ end
82
+ targets[row] = [1 - discounted, discounted]
83
+ end
84
+ end
85
+ targets
86
+ end
87
+ end
88
+ end
data/lib/laya/util.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ # Small helpers shared across modules. Question and state hashes may use string or symbol
5
+ # keys (Ruby callers naturally write `type: :choice`, JSON payloads arrive as strings), so
6
+ # every lookup goes through {get} which accepts both.
7
+ module Util
8
+ module_function
9
+
10
+ # Fetch `key` (a String) from `hash`, also trying its Symbol form.
11
+ def get(hash, key)
12
+ return nil unless hash.is_a?(Hash)
13
+ return hash[key] if hash.key?(key)
14
+
15
+ sym = key.to_sym
16
+ hash.key?(sym) ? hash[sym] : nil
17
+ end
18
+
19
+ # True when `hash` holds `key` as a String or a Symbol.
20
+ def key?(hash, key)
21
+ hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym))
22
+ end
23
+
24
+ # Assign `value` under `key`, keeping whichever key form (String/Symbol) the hash already uses.
25
+ def put(hash, key, value)
26
+ if hash.key?(key.to_sym) && !hash.key?(key)
27
+ hash[key.to_sym] = value
28
+ else
29
+ hash[key] = value
30
+ end
31
+ hash
32
+ end
33
+
34
+ # Deep-convert Symbol keys to Strings (values are left alone, apart from nested containers).
35
+ def stringify_keys(obj)
36
+ case obj
37
+ when Hash then obj.to_h { |k, v| [k.is_a?(Symbol) ? k.to_s : k, stringify_keys(v)] }
38
+ when Array then obj.map { |v| stringify_keys(v) }
39
+ else obj
40
+ end
41
+ end
42
+
43
+ # A callable: a Proc/Method or anything responding to #call.
44
+ def callable?(obj)
45
+ obj.respond_to?(:call)
46
+ end
47
+
48
+ # Convert a strictly positive integer argument, rejecting booleans, floats and strings.
49
+ def positive_int!(value, name)
50
+ unless value.is_a?(Integer) && value >= 1
51
+ raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}"
52
+ end
53
+
54
+ value
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laya
4
+ VERSION = "0.1.0"
5
+
6
+ # The upstream Python release this port tracks, feature for feature.
7
+ UPSTREAM_VERSION = "0.3.7"
8
+ end
data/lib/laya.rb ADDED
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "laya/version"
6
+ require_relative "laya/errors"
7
+ require_relative "laya/util"
8
+ require_relative "laya/py_json"
9
+ require_relative "laya/common"
10
+ require_relative "laya/checkpoints"
11
+ require_relative "laya/lang"
12
+ require_relative "laya/email"
13
+ require_relative "laya/presets"
14
+ require_relative "laya/shortlist"
15
+ require_relative "laya/training"
16
+ require_relative "laya/hub"
17
+ require_relative "laya/router"
18
+ require_relative "laya/configuration"
19
+ require_relative "laya/questions"
20
+
21
+ # Laya: a fast, non-autoregressive System 1 decision engine with calibrated probabilities.
22
+ #
23
+ # agent = Laya.load("convaiinnovations/laya")
24
+ # result = agent.predict({ "message" => "I was charged twice" }, Laya.triage_questions)
25
+ # result[:intent].choice # => "refund"
26
+ # result[:churn_risk].probability # => 0.89
27
+ #
28
+ # Routing, language detection, email cleaning, the presets and the shortlist are pure Ruby and
29
+ # load with the gem. The runtime, which needs ONNX Runtime, is loaded on first use, so a process
30
+ # that only routes never opens a model.
31
+ module Laya
32
+ # Absolute paths, so the runtime still autoloads when the gem was reached by require_relative
33
+ # rather than through the load path.
34
+ { Agent: "agent", RLAgent: "agent", Answer: "result", Ask: "ask", Decision: "decision",
35
+ Question: "question", Result: "result", Runtime: "runtime", Tokenizer: "tokenizer" }.each do |constant, file|
36
+ autoload constant, File.expand_path("laya/#{file}", __dir__)
37
+ end
38
+
39
+ # The shipped question sets, as decision classes:
40
+ #
41
+ # Laya::Guard.decide(prompt).jailbreak?
42
+ # Laya::Triage.decide(message).churn_risk.probability
43
+ #
44
+ # Each is a {Decision}, so it subclasses like any other and its questions are readable with
45
+ # `.questions`. The plain hashes are still there as `Laya.triage_questions` and friends.
46
+ def self.preset(questions)
47
+ Decision.define(questions)
48
+ end
49
+
50
+ class << self
51
+ # Load a checkpoint, by upstream model id or from a directory holding an ONNX export.
52
+ #
53
+ # Laya.load("convaiinnovations/laya") # English
54
+ # Laya.load("convaiinnovations/laya", subfolder: "multilingual") # 100+ languages
55
+ # Laya.load("./my-export", device: "coreml")
56
+ #
57
+ # Given a block, the agent is closed when the block returns.
58
+ def load(model_id_or_path = Checkpoints::BUNDLE_REPO, **, &)
59
+ Agent.load(model_id_or_path, **, &)
60
+ end
61
+
62
+ # Ask questions about `state` without declaring a class for them.
63
+ #
64
+ # Laya.ask(email)
65
+ # .choice(:department, "Which team?", billing: "invoices", technical: "outages")
66
+ # .noul(:refund, "Do they want money back?")
67
+ # .decide
68
+ #
69
+ # Runs on the shared client unless given `client:`; see {Laya.configure}.
70
+ def ask(state, client: nil, **)
71
+ Ask.new(state, client: client, **)
72
+ end
73
+
74
+ # A {Router} that picks a checkpoint per request. Given a block, it is closed afterwards.
75
+ def router(**, &)
76
+ Router.open(**, &)
77
+ end
78
+
79
+ # --- language and script detection
80
+ def detect_language(state) = Lang.analyse(state)
81
+ def detect_script(text) = Lang.detect_script(text)
82
+ def english?(state) = Lang.english?(state)
83
+ def is_english(state) = Lang.english?(state)
84
+
85
+ # --- email
86
+ def clean_email_body(body, max_chars: 3000) = Email.clean_email_body(body, max_chars: max_chars)
87
+
88
+ def email_state(subject, body, sender: nil, clean: true, **extra)
89
+ Email.email_state(subject, body, sender: sender, clean: clean, **extra)
90
+ end
91
+
92
+ # --- question presets
93
+ def email_questions(categories = nil) = Presets.email_questions(categories)
94
+ def guard_questions = Presets.guard_questions
95
+ def moderation_questions = Presets.moderation_questions
96
+ def router_questions = Presets.router_questions
97
+ def triage_questions = Presets.triage_questions
98
+
99
+ # --- shortlist
100
+ def shortlist_choice(state, criteria, embed_fn, k: Shortlist::DEFAULT_SHORTLIST_K, instructions: nil)
101
+ Shortlist.shortlist_choice(state, criteria, embed_fn, k: k, instructions: instructions)
102
+ end
103
+
104
+ def predict_shortlist(agent, state, questions, embed_fn, k: Shortlist::DEFAULT_SHORTLIST_K, **)
105
+ Shortlist.predict_shortlist(agent, state, questions, embed_fn, k: k, **)
106
+ end
107
+
108
+ def embed_fn_from_agent(agent, max_length: nil, batch_size: 32)
109
+ Shortlist.embed_fn_from_agent(agent, max_length: max_length, batch_size: batch_size)
110
+ end
111
+
112
+ # --- rendering and calibration
113
+ def render_options(question) = Common.render_options(question)
114
+ def confidence_from_probs(probabilities, k) = Common.confidence_from_probs(probabilities, k)
115
+ def ece_score(confidences, correct, bins: 15) = Common.ece_score(confidences, correct, bins: bins)
116
+
117
+ # --- training arithmetic
118
+ def proper_reward(...) = Training.proper_reward(...)
119
+ def td_lambda_targets(...) = Training.td_lambda_targets(...)
120
+ end
121
+
122
+ # The shipped question sets, ready to ask. Declared last so {Decision} and {Presets} are both
123
+ # in place; each is a plain subclass, so `Laya::Guard.questions` and subclassing both work.
124
+ Triage = preset(Presets.triage_questions)
125
+ EmailTriage = preset(Presets.email_questions)
126
+ Guard = preset(Presets.guard_questions)
127
+ Moderation = preset(Presets.moderation_questions)
128
+ RequestRouting = preset(Presets.router_questions)
129
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-laya
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Valentino Stoll
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: onnxruntime
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
27
+ - !ruby/object:Gem::Dependency
28
+ name: tokenizers
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0.5'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0.5'
41
+ description: |
42
+ Ruby port of Laya: typed decisions (choice, score, noul) over any state in a single
43
+ forward pass, with calibrated probabilities and a router that picks the right checkpoint
44
+ per request (English, multilingual, typed-decisions). Inference runs on ONNX Runtime
45
+ against exports of the published checkpoints, so installing needs no Python, no LibTorch
46
+ and no compiler. Language and script detection, email cleaning, workflow presets and the
47
+ embedding shortlist are pure Ruby.
48
+ email:
49
+ - v@codenamev.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE
56
+ - NOTICE
57
+ - README.md
58
+ - lib/laya.rb
59
+ - lib/laya/agent.rb
60
+ - lib/laya/ask.rb
61
+ - lib/laya/checkpoints.rb
62
+ - lib/laya/common.rb
63
+ - lib/laya/configuration.rb
64
+ - lib/laya/decision.rb
65
+ - lib/laya/email.rb
66
+ - lib/laya/errors.rb
67
+ - lib/laya/hub.rb
68
+ - lib/laya/lang.rb
69
+ - lib/laya/presets.rb
70
+ - lib/laya/py_json.rb
71
+ - lib/laya/question.rb
72
+ - lib/laya/questions.rb
73
+ - lib/laya/result.rb
74
+ - lib/laya/router.rb
75
+ - lib/laya/runtime.rb
76
+ - lib/laya/shortlist.rb
77
+ - lib/laya/tokenizer.rb
78
+ - lib/laya/training.rb
79
+ - lib/laya/util.rb
80
+ - lib/laya/version.rb
81
+ homepage: https://github.com/codenamev/ruby-laya
82
+ licenses:
83
+ - Apache-2.0
84
+ metadata:
85
+ homepage_uri: https://codenamev.github.io/ruby-laya
86
+ source_code_uri: https://github.com/codenamev/ruby-laya
87
+ changelog_uri: https://github.com/codenamev/ruby-laya/blob/main/CHANGELOG.md
88
+ documentation_uri: https://codenamev.github.io/ruby-laya
89
+ bug_tracker_uri: https://github.com/codenamev/ruby-laya/issues
90
+ rubygems_mfa_required: 'true'
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.3'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.5.22
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: Fast, non-autoregressive System 1 decision engine with calibrated probabilities
110
+ test_files: []