vangrail 0.1.0 → 0.3.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 +4 -4
- data/README.md +689 -46
- data/exe/vangrail +6 -0
- data/lib/vangrail/actions.rb +10 -3
- data/lib/vangrail/assessor.rb +249 -0
- data/lib/vangrail/bayes_data.rb +340 -0
- data/lib/vangrail/beta.rb +102 -0
- data/lib/vangrail/builder.rb +358 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/cli.rb +135 -0
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +29 -19
- data/lib/vangrail/colang/ast.rb +29 -3
- data/lib/vangrail/colang/interpreter.rb +55 -31
- data/lib/vangrail/colang/parser.rb +19 -61
- data/lib/vangrail/colang/value_parser.rb +161 -0
- data/lib/vangrail/completion.rb +86 -0
- data/lib/vangrail/config.rb +35 -15
- data/lib/vangrail/conversation.rb +242 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +34 -71
- data/lib/vangrail/errors.rb +6 -1
- data/lib/vangrail/evidence.rb +303 -0
- data/lib/vangrail/evidence_data.rb +113 -0
- data/lib/vangrail/front.rb +103 -0
- data/lib/vangrail/http.rb +18 -13
- data/lib/vangrail/judgement.rb +151 -0
- data/lib/vangrail/known_attacks.rb +45 -0
- data/lib/vangrail/linear_model.rb +156 -0
- data/lib/vangrail/native.rb +23 -0
- data/lib/vangrail/nlp.rb +605 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +116 -0
- data/lib/vangrail/prompt.rb +14 -3
- data/lib/vangrail/provider.rb +106 -75
- data/lib/vangrail/providers/gateway.rb +15 -14
- data/lib/vangrail/providers/llmlite.rb +25 -10
- data/lib/vangrail/providers.rb +6 -8
- data/lib/vangrail/rail.rb +46 -8
- data/lib/vangrail/rails/alignment.rb +91 -0
- data/lib/vangrail/rails/bayes.rb +115 -0
- data/lib/vangrail/rails/budget.rb +2 -2
- data/lib/vangrail/rails/canary.rb +2 -2
- data/lib/vangrail/rails/colang_flow.rb +9 -1
- data/lib/vangrail/rails/escalation.rb +15 -8
- data/lib/vangrail/rails/exfiltration.rb +2 -2
- data/lib/vangrail/rails/grounding.rb +8 -5
- data/lib/vangrail/rails/guard_model.rb +7 -4
- data/lib/vangrail/rails/hidden.rb +59 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +4 -7
- data/lib/vangrail/rails/known_answer.rb +6 -2
- data/lib/vangrail/rails/language.rb +87 -0
- data/lib/vangrail/rails/linear.rb +80 -0
- data/lib/vangrail/rails/many_shot.rb +16 -8
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +83 -13
- data/lib/vangrail/rails/paraphrase.rb +189 -0
- data/lib/vangrail/rails/pattern.rb +2 -6
- data/lib/vangrail/rails/perplexity.rb +100 -0
- data/lib/vangrail/rails/personal_data.rb +41 -9
- data/lib/vangrail/rails/prompt_leak.rb +132 -0
- data/lib/vangrail/rails/remote.rb +5 -1
- data/lib/vangrail/rails/secrets.rb +2 -2
- data/lib/vangrail/rails/self_check.rb +9 -6
- data/lib/vangrail/rails/semantic.rb +132 -0
- data/lib/vangrail/rails/similarity.rb +96 -0
- data/lib/vangrail/rails/trajectory.rb +10 -5
- data/lib/vangrail/result.rb +10 -9
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/server.rb +140 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +9 -7
- data/lib/vangrail/tools.rb +62 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +42 -258
- metadata +42 -7
data/lib/vangrail/client.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'client/
|
|
3
|
+
require_relative 'client/turn'
|
|
4
4
|
require_relative 'errors'
|
|
5
5
|
require_relative 'http'
|
|
6
6
|
require_relative 'result'
|
|
@@ -23,7 +23,11 @@ module Vangrail
|
|
|
23
23
|
COMPLETIONS_PATH = '/v1/chat/completions'
|
|
24
24
|
PROTOCOLS = %i[auto nested flat].freeze
|
|
25
25
|
|
|
26
|
-
RAIL_VARS = [
|
|
26
|
+
RAIL_VARS = [Turn::INPUT_RAIL_VAR, Turn::OUTPUT_RAIL_VAR].freeze
|
|
27
|
+
|
|
28
|
+
# One guarded completion's arguments, so the wire methods take one value.
|
|
29
|
+
Request = Struct.new(:messages, :config_id, :config_ids, :options, :context,
|
|
30
|
+
:thread_id, :model, :extra, keyword_init: true)
|
|
27
31
|
|
|
28
32
|
attr_reader :config_id, :model, :protocol, :http
|
|
29
33
|
|
|
@@ -31,9 +35,12 @@ module Vangrail
|
|
|
31
35
|
# of those happens, so a caller can report "not yet known" honestly.
|
|
32
36
|
attr_reader :checks_supported
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
# The 0.1.0 name. Prefer Turn in new code.
|
|
39
|
+
Completion = Turn
|
|
40
|
+
|
|
41
|
+
def initialize(http: nil, base_url: nil, config_id: nil, model: nil, api_key: nil,
|
|
42
|
+
protocol: :auto, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
43
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
|
|
37
44
|
unless PROTOCOLS.include?(protocol)
|
|
38
45
|
raise ArgumentError,
|
|
39
46
|
"protocol must be one of #{PROTOCOLS.join(', ')}"
|
|
@@ -43,10 +50,9 @@ module Vangrail
|
|
|
43
50
|
@model = model
|
|
44
51
|
@protocol = protocol
|
|
45
52
|
@checks_supported = nil
|
|
46
|
-
@http = http
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
53
|
+
@http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
|
|
54
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
55
|
+
missing: 'a Client needs a base_url or an http client')
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
def base_url
|
|
@@ -93,10 +99,13 @@ module Vangrail
|
|
|
93
99
|
# answer as well as checking it.
|
|
94
100
|
def chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil,
|
|
95
101
|
thread_id: nil, model: nil, **extra)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
request = Request.new(messages: messages, config_id: config_id, config_ids: config_ids,
|
|
103
|
+
options: options, context: context, thread_id: thread_id,
|
|
104
|
+
model: model, extra: extra)
|
|
105
|
+
opts = merge_options(request.options)
|
|
106
|
+
body = request.extra.merge(messages: normalize(request.messages))
|
|
107
|
+
chosen = { config_id: request.config_id || @config_id, config_ids: request.config_ids }
|
|
108
|
+
Turn.new(send_payload(body, chosen, opts, request.context, request.thread_id, request.model))
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
private
|
|
@@ -115,16 +124,17 @@ module Vangrail
|
|
|
115
124
|
raise ProtocolError, "/v1/checks answered status #{status.inspect}"
|
|
116
125
|
end
|
|
117
126
|
|
|
127
|
+
certain = body.key?('certain') ? body['certain'] : true
|
|
118
128
|
Result.new(status: status.to_sym, rail: body['rail'] || rail.to_s,
|
|
119
|
-
content: body['content'], raw: body)
|
|
129
|
+
content: body['content'], raw: body, certain: certain)
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
def from_completion(
|
|
123
|
-
return Result.passed(rail: rail.to_s, raw:
|
|
132
|
+
def from_completion(turn, rail)
|
|
133
|
+
return Result.passed(rail: rail.to_s, raw: turn.raw) if turn.allowed?
|
|
124
134
|
|
|
125
|
-
reason =
|
|
126
|
-
Result.blocked(rail: reason || rail.to_s, content:
|
|
127
|
-
raw:
|
|
135
|
+
reason = turn.triggered_rail || turn.stopped_rails.first&.dig('name')
|
|
136
|
+
Result.blocked(rail: reason || rail.to_s, content: turn.content, reason: reason,
|
|
137
|
+
raw: turn.raw)
|
|
128
138
|
end
|
|
129
139
|
|
|
130
140
|
def check_options(rail)
|
data/lib/vangrail/colang/ast.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
3
5
|
module Vangrail
|
|
4
6
|
module Colang
|
|
5
7
|
# The whole grammar this gem executes, as data. Everything the parser can
|
|
@@ -23,14 +25,38 @@ module Vangrail
|
|
|
23
25
|
Program.new(
|
|
24
26
|
flows: flows.merge(other.flows),
|
|
25
27
|
bot_messages: bot_messages.merge(other.bot_messages),
|
|
26
|
-
user_messages: user_messages.merge(other.user_messages)
|
|
28
|
+
user_messages: user_messages.merge(other.user_messages),
|
|
27
29
|
)
|
|
28
30
|
end
|
|
31
|
+
|
|
32
|
+
# Every `bot <name>` in every flow has a `define bot`. The walk covers
|
|
33
|
+
# unreached `if` branches and flows no config names: a half-loaded
|
|
34
|
+
# guardrail must not load, so a dead-branch bot in a merged `.co` file
|
|
35
|
+
# fails the folder. Called after parse/merge, not during parse, so a
|
|
36
|
+
# bot defined in another file is visible once the programs are joined.
|
|
37
|
+
def check!
|
|
38
|
+
flows.each { |name, flow| check_body(flow.body, name) }
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check_body(body, flow_name)
|
|
43
|
+
Array(body).each do |node|
|
|
44
|
+
case node
|
|
45
|
+
when Bot
|
|
46
|
+
next if bot_message(node.message)
|
|
47
|
+
|
|
48
|
+
raise ColangError, "no `define bot #{node.message}` for flow #{flow_name.inspect}"
|
|
49
|
+
when If
|
|
50
|
+
check_body(node.then_body, flow_name)
|
|
51
|
+
check_body(node.else_body, flow_name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
29
55
|
end
|
|
30
56
|
|
|
31
57
|
Flow = Struct.new(:name, :body, :subflow, keyword_init: true)
|
|
32
58
|
|
|
33
|
-
# $var =
|
|
59
|
+
# $var = <value>
|
|
34
60
|
Assign = Struct.new(:variable, :expression, keyword_init: true)
|
|
35
61
|
|
|
36
62
|
# execute action(key="value")
|
|
@@ -44,7 +70,7 @@ module Vangrail
|
|
|
44
70
|
|
|
45
71
|
If = Struct.new(:condition, :then_body, :else_body, keyword_init: true)
|
|
46
72
|
|
|
47
|
-
#
|
|
73
|
+
# Values
|
|
48
74
|
Var = Struct.new(:name, keyword_init: true)
|
|
49
75
|
Not = Struct.new(:expression, keyword_init: true)
|
|
50
76
|
Compare = Struct.new(:left, :operator, :right, keyword_init: true)
|
|
@@ -10,62 +10,55 @@ module Vangrail
|
|
|
10
10
|
# The mapping onto rail statuses is the whole design:
|
|
11
11
|
#
|
|
12
12
|
# `bot <message>` then `stop` -> blocked, with the message as content
|
|
13
|
-
#
|
|
14
|
-
# $bot_message -> modified, with the new value
|
|
13
|
+
# a change to a content binding -> modified, with the new value
|
|
15
14
|
# falls off the end -> passed
|
|
16
15
|
#
|
|
16
|
+
# `$user_input` is a rewrite alias of `$user_message`; `$bot_response` is
|
|
17
|
+
# a rewrite alias of `$bot_message`. Input and context seed the user pair
|
|
18
|
+
# from the turn text; output seeds the bot pair. The other pair is left
|
|
19
|
+
# unset so an output flow that reads `$user_message` is not reading the
|
|
20
|
+
# answer unless the caller put it there.
|
|
21
|
+
#
|
|
17
22
|
# A `stop` without a preceding `bot` still blocks; it just has no refusal
|
|
18
23
|
# text to show. Actions are plain Ruby callables, so the flow decides the
|
|
19
24
|
# control shape and Ruby does the work.
|
|
20
25
|
class Interpreter
|
|
21
|
-
#
|
|
22
|
-
#
|
|
26
|
+
# The four names a flow assigns to when it rewrites the turn. Order is
|
|
27
|
+
# the last-write winner when more than one of them changed.
|
|
23
28
|
CONTENT_VARS = %w[user_message bot_message user_input bot_response].freeze
|
|
24
29
|
|
|
25
30
|
Outcome = Struct.new(:status, :content, :reason, :variables, keyword_init: true)
|
|
26
31
|
|
|
27
|
-
# Raised through the interpreter to unwind a flow on `stop`.
|
|
28
|
-
class Stopped < StandardError
|
|
29
|
-
attr_reader :message_text
|
|
30
|
-
|
|
31
|
-
def initialize(message_text)
|
|
32
|
-
@message_text = message_text
|
|
33
|
-
super('flow stopped')
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
32
|
attr_reader :program, :actions
|
|
38
33
|
|
|
39
34
|
def initialize(program:, actions:)
|
|
40
|
-
@program = program
|
|
35
|
+
@program = program.check!
|
|
41
36
|
@actions = actions
|
|
42
37
|
end
|
|
43
38
|
|
|
44
39
|
def run(flow_name, context = {})
|
|
45
40
|
flow = program.flow(flow_name)
|
|
46
|
-
raise
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
rescue Stopped => e
|
|
53
|
-
return Outcome.new(status: :blocked, content: e.message_text, reason: flow_name, variables: state)
|
|
54
|
-
end
|
|
41
|
+
raise ColangError, "no flow named #{flow_name.inspect}" unless flow
|
|
42
|
+
|
|
43
|
+
seeds = seed_bindings(context)
|
|
44
|
+
state = { 'context' => context }.merge(seeds)
|
|
45
|
+
result = execute(flow.body, state, context)
|
|
46
|
+
return stop_outcome(result.last, flow_name, state) if stop_tag?(result)
|
|
55
47
|
|
|
56
|
-
rewrite =
|
|
48
|
+
rewrite = rewritten_content(state, seeds)
|
|
57
49
|
if rewrite
|
|
58
50
|
return Outcome.new(status: :modified, content: rewrite.to_s, reason: flow_name,
|
|
59
51
|
variables: state)
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
Outcome.new(status: :passed, content:
|
|
54
|
+
Outcome.new(status: :passed, content: result, reason: nil, variables: state)
|
|
63
55
|
end
|
|
64
56
|
|
|
65
57
|
private
|
|
66
58
|
|
|
67
59
|
# Returns the text of the last `bot` statement reached, so a flow that
|
|
68
|
-
# says something without stopping still hands that text back.
|
|
60
|
+
# says something without stopping still hands that text back. `stop`
|
|
61
|
+
# returns `[:stop, said]` rather than raising.
|
|
69
62
|
def execute(body, state, context)
|
|
70
63
|
said = nil
|
|
71
64
|
body.each do |node|
|
|
@@ -73,14 +66,45 @@ module Vangrail
|
|
|
73
66
|
when Assign then state[node.variable] = evaluate(node.expression, state, context)
|
|
74
67
|
when Execute then evaluate(node, state, context)
|
|
75
68
|
when Bot then said = bot_text(node.message)
|
|
76
|
-
when Stop then
|
|
77
|
-
when If
|
|
69
|
+
when Stop then return [:stop, said]
|
|
70
|
+
when If
|
|
71
|
+
result = branch(node, state, context)
|
|
72
|
+
return result if stop_tag?(result)
|
|
73
|
+
|
|
74
|
+
said = result || said
|
|
78
75
|
else raise ColangError, "cannot execute #{node.class}"
|
|
79
76
|
end
|
|
80
77
|
end
|
|
81
78
|
said
|
|
82
79
|
end
|
|
83
80
|
|
|
81
|
+
def stop_tag?(result)
|
|
82
|
+
result.is_a?(Array) && result.first == :stop
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop_outcome(content, flow_name, state)
|
|
86
|
+
Outcome.new(status: :blocked, content: content, reason: flow_name, variables: state)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def seed_bindings(context)
|
|
90
|
+
text = context[:text]
|
|
91
|
+
if context[:side] == :output
|
|
92
|
+
{ 'bot_message' => text, 'bot_response' => text }
|
|
93
|
+
else
|
|
94
|
+
{ 'user_message' => text, 'user_input' => text }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rewritten_content(state, seeds)
|
|
99
|
+
rewritten = CONTENT_VARS.filter_map do |name|
|
|
100
|
+
next unless state.key?(name)
|
|
101
|
+
next if seeds.key?(name) && state[name] == seeds[name]
|
|
102
|
+
|
|
103
|
+
state[name]
|
|
104
|
+
end
|
|
105
|
+
rewritten.last
|
|
106
|
+
end
|
|
107
|
+
|
|
84
108
|
def branch(node, state, context)
|
|
85
109
|
taken = truthy?(evaluate(node.condition, state, context)) ? node.then_body : node.else_body
|
|
86
110
|
execute(Array(taken), state, context)
|
|
@@ -107,13 +131,13 @@ module Vangrail
|
|
|
107
131
|
action = actions[node.action]
|
|
108
132
|
raise UnknownAction, "no action registered for #{node.action.inspect}" unless action
|
|
109
133
|
|
|
110
|
-
args = node.arguments.transform_values { |v| v
|
|
134
|
+
args = node.arguments.transform_values { |v| evaluate(v, state, context) }
|
|
111
135
|
action.call(args, context)
|
|
112
136
|
end
|
|
113
137
|
|
|
114
138
|
def bot_text(message)
|
|
115
139
|
alternatives = program.bot_message(message)
|
|
116
|
-
raise
|
|
140
|
+
raise ColangError, "no `define bot #{message}` for this flow" unless alternatives
|
|
117
141
|
|
|
118
142
|
# Deterministic: a guardrail refusal that varies between runs makes an
|
|
119
143
|
# incident report harder to read for no benefit.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../errors'
|
|
4
4
|
require_relative 'ast'
|
|
5
|
+
require_relative 'value_parser'
|
|
5
6
|
|
|
6
7
|
module Vangrail
|
|
7
8
|
module Colang
|
|
@@ -22,8 +23,13 @@ module Vangrail
|
|
|
22
23
|
# matching quietly missing: a guardrail that half-loads is a guardrail that
|
|
23
24
|
# reports checks it is not running.
|
|
24
25
|
#
|
|
25
|
-
# Indentation defines blocks.
|
|
26
|
-
# and spaces would otherwise parse into a
|
|
26
|
+
# Indentation defines blocks. A tab anywhere in the indent run is refused,
|
|
27
|
+
# because a file mixing tabs and spaces would otherwise parse into a
|
|
28
|
+
# different program than it looks.
|
|
29
|
+
#
|
|
30
|
+
# Assignments, `if` conditions, and action arguments share one value
|
|
31
|
+
# grammar: string, int, and bool literals, `$vars`, `execute`, `not`, and
|
|
32
|
+
# `==` / `!=`.
|
|
27
33
|
class Parser
|
|
28
34
|
Line = Struct.new(:indent, :text, :number, keyword_init: true)
|
|
29
35
|
|
|
@@ -51,7 +57,8 @@ module Vangrail
|
|
|
51
57
|
def significant_lines
|
|
52
58
|
@source.lines.each_with_index.filter_map do |raw, i|
|
|
53
59
|
number = i + 1
|
|
54
|
-
|
|
60
|
+
indent_run = raw[/\A[ \t]*/]
|
|
61
|
+
raise error('tabs are not allowed in Colang indentation', number) if indent_run.include?("\t")
|
|
55
62
|
|
|
56
63
|
text = raw.rstrip
|
|
57
64
|
stripped = text.strip
|
|
@@ -107,10 +114,13 @@ module Vangrail
|
|
|
107
114
|
line = lines[index]
|
|
108
115
|
case line.text
|
|
109
116
|
when /\A\$(\w+)\s*=\s*(.+)\z/
|
|
110
|
-
[Assign.new(variable: Regexp.last_match(1), expression:
|
|
117
|
+
[Assign.new(variable: Regexp.last_match(1), expression: value(Regexp.last_match(2), line)),
|
|
111
118
|
index + 1]
|
|
112
|
-
when /\Aexecute\s
|
|
113
|
-
|
|
119
|
+
when /\Aexecute\s+/
|
|
120
|
+
node = value(line.text, line)
|
|
121
|
+
raise error("unsupported statement #{line.text.inspect}", line.number) unless node.is_a?(Execute)
|
|
122
|
+
|
|
123
|
+
[node, index + 1]
|
|
114
124
|
when /\Abot\s+(.+)\z/
|
|
115
125
|
[Bot.new(message: Regexp.last_match(1).strip), index + 1]
|
|
116
126
|
when /\Astop\z/
|
|
@@ -127,7 +137,7 @@ module Vangrail
|
|
|
127
137
|
end
|
|
128
138
|
|
|
129
139
|
def conditional(lines, index, line, condition_text)
|
|
130
|
-
condition =
|
|
140
|
+
condition = value(condition_text, line)
|
|
131
141
|
then_lines, index = block(lines, index + 1, line.indent)
|
|
132
142
|
else_lines = []
|
|
133
143
|
if index < lines.length && lines[index].text == 'else' && lines[index].indent == line.indent
|
|
@@ -137,60 +147,8 @@ module Vangrail
|
|
|
137
147
|
index]
|
|
138
148
|
end
|
|
139
149
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
return Literal.new(value: unquote(text)) if quoted?(text)
|
|
143
|
-
return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
|
|
144
|
-
|
|
145
|
-
raise error("unsupported expression #{text.inspect}", line.number)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def action_call(text, line)
|
|
149
|
-
name, args = text.match(/\A([\w.]+)\s*(?:\((.*)\))?\s*\z/)&.captures
|
|
150
|
-
raise error("unsupported action call #{text.inspect}", line.number) unless name
|
|
151
|
-
|
|
152
|
-
Execute.new(action: name, arguments: arguments(args, line))
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# key="value", key=$var, key=42. Positional arguments are refused: an
|
|
156
|
-
# action here is a Ruby method taking a keyword hash.
|
|
157
|
-
def arguments(text, line)
|
|
158
|
-
return {} if text.nil? || text.strip.empty?
|
|
159
|
-
|
|
160
|
-
text.split(/,(?=(?:[^"]*"[^"]*")*[^"]*\z)/).to_h do |pair|
|
|
161
|
-
key, value = pair.split('=', 2).map { |s| s.to_s.strip }
|
|
162
|
-
raise error("argument #{pair.inspect} needs a name", line.number) if value.nil? || key.empty?
|
|
163
|
-
|
|
164
|
-
[key, argument_value(value, line)]
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def argument_value(value, line)
|
|
169
|
-
return unquote(value) if quoted?(value)
|
|
170
|
-
return Var.new(name: Regexp.last_match(1)) if value =~ /\A\$(\w+)\z/
|
|
171
|
-
return value.to_i if value.match?(/\A-?\d+\z/)
|
|
172
|
-
return true if %w[True true].include?(value)
|
|
173
|
-
return false if %w[False false].include?(value)
|
|
174
|
-
|
|
175
|
-
raise error("unsupported argument value #{value.inspect}", line.number)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def condition(text, line)
|
|
179
|
-
text = text.strip
|
|
180
|
-
return Not.new(expression: condition(Regexp.last_match(1), line)) if text =~ /\Anot\s+(.+)\z/
|
|
181
|
-
if text =~ /\A(.+?)\s*(==|!=)\s*(.+)\z/
|
|
182
|
-
return Compare.new(
|
|
183
|
-
left: condition(Regexp.last_match(1), line),
|
|
184
|
-
operator: Regexp.last_match(2),
|
|
185
|
-
right: condition(Regexp.last_match(3), line)
|
|
186
|
-
)
|
|
187
|
-
end
|
|
188
|
-
return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
|
|
189
|
-
return Literal.new(value: unquote(text)) if quoted?(text)
|
|
190
|
-
return Literal.new(value: true) if %w[True true].include?(text)
|
|
191
|
-
return Literal.new(value: false) if %w[False false].include?(text)
|
|
192
|
-
|
|
193
|
-
raise error("unsupported condition #{text.inspect}", line.number)
|
|
150
|
+
def value(text, line)
|
|
151
|
+
ValueParser.parse(text, line, method(:error))
|
|
194
152
|
end
|
|
195
153
|
|
|
196
154
|
def strings(lines)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ast'
|
|
4
|
+
|
|
5
|
+
module Vangrail
|
|
6
|
+
module Colang
|
|
7
|
+
# One grammar for assign RHS, `if` conditions, and action arguments.
|
|
8
|
+
#
|
|
9
|
+
# Strings are tokenized before `==` / `!=` is considered, so
|
|
10
|
+
# `"a == b" == $x` is a compare of a literal against a variable.
|
|
11
|
+
class ValueParser
|
|
12
|
+
def self.parse(text, line, error)
|
|
13
|
+
new(text, line, error).parse
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(text, line, error)
|
|
17
|
+
@text = text
|
|
18
|
+
@line = line
|
|
19
|
+
@error = error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse
|
|
23
|
+
tokens = tokenize
|
|
24
|
+
node, index = parse_value(tokens, 0)
|
|
25
|
+
raise fail_value(@text) unless tokens[index][0] == :eof
|
|
26
|
+
|
|
27
|
+
node
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def tokenize
|
|
33
|
+
tokens = []
|
|
34
|
+
index = 0
|
|
35
|
+
while index < @text.length
|
|
36
|
+
if @text[index] == ' '
|
|
37
|
+
index += 1
|
|
38
|
+
else
|
|
39
|
+
token, index = next_token(index)
|
|
40
|
+
tokens << token
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
tokens << [:eof]
|
|
44
|
+
tokens
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def next_token(index)
|
|
48
|
+
char = @text[index]
|
|
49
|
+
if char == '"' || char == "'"
|
|
50
|
+
read_string(index)
|
|
51
|
+
elsif char == '$' && (name = @text[(index + 1)..][/\A\w+/])
|
|
52
|
+
[[:var, name], index + 1 + name.length]
|
|
53
|
+
elsif @text[index, 2] == '=='
|
|
54
|
+
[[:eqeq, '=='], index + 2]
|
|
55
|
+
elsif @text[index, 2] == '!='
|
|
56
|
+
[[:neq, '!='], index + 2]
|
|
57
|
+
elsif char == '='
|
|
58
|
+
[[:eq, '='], index + 1]
|
|
59
|
+
elsif char == '('
|
|
60
|
+
[[:lparen], index + 1]
|
|
61
|
+
elsif char == ')'
|
|
62
|
+
[[:rparen], index + 1]
|
|
63
|
+
elsif char == ','
|
|
64
|
+
[[:comma], index + 1]
|
|
65
|
+
elsif (num = @text[index..][/\A-?\d+/])
|
|
66
|
+
[[:int, num.to_i], index + num.length]
|
|
67
|
+
elsif (word = @text[index..][/\A[\w.]+/])
|
|
68
|
+
[[:ident, word], index + word.length]
|
|
69
|
+
else
|
|
70
|
+
raise fail_value(@text)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_string(start)
|
|
75
|
+
quote = @text[start]
|
|
76
|
+
index = start + 1
|
|
77
|
+
while index < @text.length
|
|
78
|
+
return [[:string, unquote(@text[start..index])], index + 1] if @text[index] == quote
|
|
79
|
+
|
|
80
|
+
index += @text[index] == '\\' && index + 1 < @text.length ? 2 : 1
|
|
81
|
+
end
|
|
82
|
+
raise @error.call("unterminated string in #{@text.inspect}", @line.number)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_value(tokens, index)
|
|
86
|
+
tok = tokens[index]
|
|
87
|
+
if tok[0] == :ident && tok[1] == 'not'
|
|
88
|
+
expr, index = parse_value(tokens, index + 1)
|
|
89
|
+
return [Not.new(expression: expr), index]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
left, index = parse_atom(tokens, index)
|
|
93
|
+
op = tokens[index]
|
|
94
|
+
return [left, index] unless %i[eqeq neq].include?(op[0])
|
|
95
|
+
|
|
96
|
+
right, index = parse_value(tokens, index + 1)
|
|
97
|
+
[Compare.new(left: left, operator: op[1], right: right), index]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_atom(tokens, index)
|
|
101
|
+
tok = tokens[index]
|
|
102
|
+
case tok[0]
|
|
103
|
+
when :string then [Literal.new(value: tok[1]), index + 1]
|
|
104
|
+
when :int then [Literal.new(value: tok[1]), index + 1]
|
|
105
|
+
when :var then [Var.new(name: tok[1]), index + 1]
|
|
106
|
+
when :ident
|
|
107
|
+
word = tok[1]
|
|
108
|
+
return parse_execute(tokens, index + 1) if word == 'execute'
|
|
109
|
+
return [Literal.new(value: true), index + 1] if %w[True true].include?(word)
|
|
110
|
+
return [Literal.new(value: false), index + 1] if %w[False false].include?(word)
|
|
111
|
+
|
|
112
|
+
raise fail_value(word)
|
|
113
|
+
else
|
|
114
|
+
raise fail_value(tok)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# execute name / execute name(key=value, ...). Positional arguments
|
|
119
|
+
# are refused: an action here is a Ruby method taking a keyword hash.
|
|
120
|
+
def parse_execute(tokens, index)
|
|
121
|
+
name_tok = tokens[index]
|
|
122
|
+
raise fail_value(name_tok) unless name_tok[0] == :ident
|
|
123
|
+
|
|
124
|
+
name = name_tok[1]
|
|
125
|
+
index += 1
|
|
126
|
+
return [Execute.new(action: name, arguments: {}), index] unless tokens[index][0] == :lparen
|
|
127
|
+
|
|
128
|
+
args, index = parse_arguments(tokens, index + 1)
|
|
129
|
+
[Execute.new(action: name, arguments: args), index]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_arguments(tokens, index)
|
|
133
|
+
return [{}, index + 1] if tokens[index][0] == :rparen
|
|
134
|
+
|
|
135
|
+
args = {}
|
|
136
|
+
loop do
|
|
137
|
+
key = tokens[index]
|
|
138
|
+
unless key[0] == :ident && tokens[index + 1][0] == :eq
|
|
139
|
+
raise @error.call("argument #{key[1].inspect} needs a name", @line.number)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
val, index = parse_value(tokens, index + 2)
|
|
143
|
+
args[key[1]] = val
|
|
144
|
+
case tokens[index][0]
|
|
145
|
+
when :comma then index += 1
|
|
146
|
+
when :rparen then return [args, index + 1]
|
|
147
|
+
else raise fail_value(@text)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def unquote(text)
|
|
153
|
+
text[1..-2].gsub('\\"', '"').gsub("\\'", "'")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def fail_value(what)
|
|
157
|
+
@error.call("unsupported value #{what.inspect}", @line.number)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'http'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
# The other OpenAI-compatible endpoint: /completions, asked to score text
|
|
8
|
+
# rather than to write any.
|
|
9
|
+
#
|
|
10
|
+
# It exists for one job. A chat endpoint returns what a model would say next
|
|
11
|
+
# and never says how surprising the text it was given was, and that number is
|
|
12
|
+
# what the published perplexity detectors are built on. The legacy completions
|
|
13
|
+
# endpoint answers it directly: echo the prompt, generate nothing, and return
|
|
14
|
+
# the log probability the model assigned each token of it.
|
|
15
|
+
#
|
|
16
|
+
# Support is genuinely uneven. Local servers built on llama.cpp and vLLM
|
|
17
|
+
# answer it; several hosted APIs removed the endpoint, and a proxy in front of
|
|
18
|
+
# a chat-only model cannot synthesise it. So `supported?` is a question worth
|
|
19
|
+
# asking rather than an assumption, and everything here raises ProtocolError
|
|
20
|
+
# rather than inventing a score when the fields are missing. A rail reading
|
|
21
|
+
# this turns that into an uncertain result, which is the truth: the check did
|
|
22
|
+
# not run.
|
|
23
|
+
class Completion
|
|
24
|
+
PATH = '/completions'
|
|
25
|
+
|
|
26
|
+
attr_reader :model, :http
|
|
27
|
+
|
|
28
|
+
def initialize(model:, http: nil, base_url: nil, api_key: nil,
|
|
29
|
+
open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
30
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
|
|
31
|
+
@model = model
|
|
32
|
+
@http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
|
|
33
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
34
|
+
missing: 'a Completion needs a base_url or an http client')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The log probability of each token of `text`, under this model.
|
|
38
|
+
#
|
|
39
|
+
# The first token has none, by construction: nothing preceded it. It is
|
|
40
|
+
# dropped rather than counted as zero, because zero is a log probability of
|
|
41
|
+
# one and would report the opening word as perfectly predicted.
|
|
42
|
+
def token_logprobs(text)
|
|
43
|
+
body = http.post_json(PATH, {
|
|
44
|
+
'model' => model,
|
|
45
|
+
'prompt' => text.to_s,
|
|
46
|
+
'max_tokens' => 0,
|
|
47
|
+
'echo' => true,
|
|
48
|
+
'logprobs' => 0,
|
|
49
|
+
'temperature' => 0,
|
|
50
|
+
})
|
|
51
|
+
values = logprobs_in(body)
|
|
52
|
+
raise ProtocolError, 'completions endpoint returned no token logprobs' if values.empty?
|
|
53
|
+
|
|
54
|
+
values.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether this endpoint can score at all. ProtocolError and HTTP 404 mean
|
|
58
|
+
# it cannot; TransportError means it is down, which is a different
|
|
59
|
+
# question. Asked once and kept, so a rail does not spend a scoring
|
|
60
|
+
# request on a boolean every check.
|
|
61
|
+
def supported?
|
|
62
|
+
return @supported unless @supported.nil?
|
|
63
|
+
|
|
64
|
+
@supported = token_logprobs('the quick brown fox').size > 1
|
|
65
|
+
rescue ProtocolError
|
|
66
|
+
@supported = false
|
|
67
|
+
rescue HTTPError => e
|
|
68
|
+
raise unless e.status == 404
|
|
69
|
+
|
|
70
|
+
@supported = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def logprobs_in(body)
|
|
76
|
+
choices = body.is_a?(Hash) ? body['choices'] : nil
|
|
77
|
+
logprobs = choices.is_a?(Array) ? choices.dig(0, 'logprobs') : nil
|
|
78
|
+
raise ProtocolError, 'completions endpoint returned no logprobs field' unless logprobs.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
values = logprobs['token_logprobs']
|
|
81
|
+
raise ProtocolError, 'completions endpoint returned no token_logprobs array' unless values.is_a?(Array)
|
|
82
|
+
|
|
83
|
+
values.map { |value| value&.to_f }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|