vangrail 0.1.0 → 0.2.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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +655 -43
  3. data/lib/vangrail/actions.rb +10 -3
  4. data/lib/vangrail/assessor.rb +249 -0
  5. data/lib/vangrail/bayes_data.rb +340 -0
  6. data/lib/vangrail/beta.rb +102 -0
  7. data/lib/vangrail/builder.rb +354 -0
  8. data/lib/vangrail/chat.rb +17 -15
  9. data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
  10. data/lib/vangrail/client.rb +27 -18
  11. data/lib/vangrail/colang/ast.rb +29 -3
  12. data/lib/vangrail/colang/interpreter.rb +55 -31
  13. data/lib/vangrail/colang/parser.rb +19 -61
  14. data/lib/vangrail/colang/value_parser.rb +161 -0
  15. data/lib/vangrail/completion.rb +86 -0
  16. data/lib/vangrail/config.rb +35 -15
  17. data/lib/vangrail/conversation.rb +240 -11
  18. data/lib/vangrail/dojo.rb +126 -0
  19. data/lib/vangrail/embeddings.rb +87 -0
  20. data/lib/vangrail/engine.rb +29 -70
  21. data/lib/vangrail/errors.rb +6 -1
  22. data/lib/vangrail/evidence.rb +303 -0
  23. data/lib/vangrail/evidence_data.rb +113 -0
  24. data/lib/vangrail/http.rb +18 -13
  25. data/lib/vangrail/judgement.rb +151 -0
  26. data/lib/vangrail/known_attacks.rb +45 -0
  27. data/lib/vangrail/linear_model.rb +124 -0
  28. data/lib/vangrail/nlp.rb +596 -0
  29. data/lib/vangrail/origin.rb +249 -0
  30. data/lib/vangrail/parsers.rb +5 -5
  31. data/lib/vangrail/profile.rb +114 -0
  32. data/lib/vangrail/prompt.rb +14 -3
  33. data/lib/vangrail/provider.rb +106 -75
  34. data/lib/vangrail/providers/gateway.rb +15 -14
  35. data/lib/vangrail/providers/llmlite.rb +25 -10
  36. data/lib/vangrail/providers.rb +6 -8
  37. data/lib/vangrail/rail.rb +46 -8
  38. data/lib/vangrail/rails/alignment.rb +91 -0
  39. data/lib/vangrail/rails/bayes.rb +115 -0
  40. data/lib/vangrail/rails/budget.rb +2 -2
  41. data/lib/vangrail/rails/canary.rb +2 -2
  42. data/lib/vangrail/rails/colang_flow.rb +9 -1
  43. data/lib/vangrail/rails/escalation.rb +15 -8
  44. data/lib/vangrail/rails/exfiltration.rb +2 -2
  45. data/lib/vangrail/rails/grounding.rb +8 -5
  46. data/lib/vangrail/rails/guard_model.rb +7 -4
  47. data/lib/vangrail/rails/hidden.rb +52 -9
  48. data/lib/vangrail/rails/injected_instructions.rb +29 -9
  49. data/lib/vangrail/rails/jailbreak.rb +2 -6
  50. data/lib/vangrail/rails/known_answer.rb +6 -2
  51. data/lib/vangrail/rails/language.rb +87 -0
  52. data/lib/vangrail/rails/linear.rb +80 -0
  53. data/lib/vangrail/rails/many_shot.rb +2 -6
  54. data/lib/vangrail/rails/markup.rb +3 -3
  55. data/lib/vangrail/rails/missing.rb +1 -5
  56. data/lib/vangrail/rails/obfuscation.rb +81 -13
  57. data/lib/vangrail/rails/paraphrase.rb +189 -0
  58. data/lib/vangrail/rails/pattern.rb +2 -6
  59. data/lib/vangrail/rails/perplexity.rb +100 -0
  60. data/lib/vangrail/rails/personal_data.rb +41 -9
  61. data/lib/vangrail/rails/prompt_leak.rb +132 -0
  62. data/lib/vangrail/rails/remote.rb +5 -1
  63. data/lib/vangrail/rails/secrets.rb +2 -2
  64. data/lib/vangrail/rails/self_check.rb +9 -6
  65. data/lib/vangrail/rails/semantic.rb +132 -0
  66. data/lib/vangrail/rails/similarity.rb +96 -0
  67. data/lib/vangrail/rails/trajectory.rb +10 -5
  68. data/lib/vangrail/result.rb +3 -3
  69. data/lib/vangrail/result_cache.rb +0 -0
  70. data/lib/vangrail/screening.rb +68 -0
  71. data/lib/vangrail/session.rb +365 -0
  72. data/lib/vangrail/spotlight.rb +48 -8
  73. data/lib/vangrail/stream_guard.rb +8 -6
  74. data/lib/vangrail/tools.rb +58 -0
  75. data/lib/vangrail/version.rb +1 -1
  76. data/lib/vangrail.rb +39 -258
  77. metadata +34 -5
@@ -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
- # assignment to $user_message or
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
- # Variables a flow assigns to when it rewrites the turn rather than
22
- # refusing it, matching the names the toolkit's own flows use.
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 UnknownAction, "no flow named #{flow_name.inspect}" unless flow
47
-
48
- state = { 'context' => context }
49
- last_bot = nil
50
- begin
51
- last_bot = execute(flow.body, state, context)
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 = CONTENT_VARS.filter_map { |v| state[v] if state.key?(v) }.last
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: last_bot, reason: nil, variables: state)
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 raise Stopped, said
77
- when If then said = branch(node, state, context) || said
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.is_a?(Var) ? state[v.name] : 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 UnknownAction, "no `define bot #{message}` for this flow" unless alternatives
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. Tabs are refused, because a file mixing tabs
26
- # and spaces would otherwise parse into a different program than it looks.
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
- raise error('tabs are not allowed in Colang indentation', number) if raw.start_with?("\t")
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: expression(Regexp.last_match(2), line)),
117
+ [Assign.new(variable: Regexp.last_match(1), expression: value(Regexp.last_match(2), line)),
111
118
  index + 1]
112
- when /\Aexecute\s+(.+)\z/
113
- [action_call(Regexp.last_match(1), line), index + 1]
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 = condition(condition_text, line)
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 expression(text, line)
141
- return action_call(Regexp.last_match(1), line) if text =~ /\Aexecute\s+(.+)\z/
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
@@ -4,6 +4,7 @@ require 'fileutils'
4
4
  require 'yaml'
5
5
  require_relative 'actions'
6
6
  require_relative 'chat'
7
+ require_relative 'conversation'
7
8
  require_relative 'colang/library'
8
9
  require_relative 'colang/parser'
9
10
  require_relative 'engine'
@@ -68,7 +69,7 @@ module Vangrail
68
69
  flows: flows,
69
70
  instructions: yaml['instructions'],
70
71
  sample_conversation: yaml['sample_conversation'],
71
- path: dir
72
+ path: dir,
72
73
  )
73
74
  end
74
75
 
@@ -85,7 +86,7 @@ module Vangrail
85
86
  def program
86
87
  @program ||= flows.reduce(Colang::Library.program) do |acc, (file, source)|
87
88
  acc.merge(Colang::Parser.parse(source, filename: "#{file}.co"))
88
- end
89
+ end.tap(&:check!)
89
90
  end
90
91
 
91
92
  def flow_names(side)
@@ -96,12 +97,12 @@ module Vangrail
96
97
  end
97
98
 
98
99
  def prompt_for(task)
99
- entry = prompts.find { |p| p['task'].to_s == task.to_s }
100
+ entry = prompts.detect { |p| p['task'].to_s == task.to_s }
100
101
  entry && entry['content'].to_s
101
102
  end
102
103
 
103
104
  def model_for(type)
104
- models.find { |m| m['type'].to_s == type.to_s }
105
+ models.detect { |m| m['type'].to_s == type.to_s }
105
106
  end
106
107
 
107
108
  # Builds the engine this configuration describes.
@@ -109,17 +110,30 @@ module Vangrail
109
110
  # `chat:` overrides where model-backed actions call, which is what tests and
110
111
  # a caller with its own client pass. `actions:` adds or replaces actions by
111
112
  # name, so a team's own check joins the built-ins without touching the gem.
112
- def engine(provider: nil, chat: nil, actions: {}, on_error: :allow, cache: true)
113
+ #
114
+ # `stdlib:` prepends the deterministic input and context rails this gem
115
+ # ships (patterns, paraphrase, language posture). Off by default so a
116
+ # folder still describes one set of rails on either runtime; on so a
117
+ # folder whose judge is down still refuses a reworded injection and
118
+ # still refuses to call an unread language a clean pass.
119
+ def engine(provider: nil, chat: nil, actions: {}, on_error: :allow, cache: true, stdlib: false)
113
120
  registry = self_check_actions(provider, chat).merge(actions)
114
121
  Engine.new(
115
- input: rails_for(:input, registry),
116
- context: rails_for(:context, registry),
122
+ input: compose(:input, rails_for(:input, registry), stdlib),
123
+ context: compose(:context, rails_for(:context, registry), stdlib),
117
124
  output: rails_for(:output, registry),
118
125
  on_error: on_error,
119
- cache: cache
126
+ cache: cache,
120
127
  )
121
128
  end
122
129
 
130
+ # Same engine, already carrying a session and an admission gate, so a
131
+ # retrieved page enters as data rather than as a raw string a caller
132
+ # might paste into the question.
133
+ def conversation(prior:, stdlib: true, **kwargs)
134
+ Conversation.new(engine(stdlib: stdlib), prior: prior, **kwargs)
135
+ end
136
+
123
137
  def rails_for(side, registry)
124
138
  flow_names(side).map do |flow_name|
125
139
  unless program.flow(flow_name)
@@ -134,6 +148,12 @@ module Vangrail
134
148
 
135
149
  private
136
150
 
151
+ def compose(side, flows, stdlib)
152
+ return flows unless stdlib
153
+
154
+ Builder.deterministic(side) + flows
155
+ end
156
+
137
157
  # The three tasks a stock configuration expects, each backed by a rail this
138
158
  # gem implements. A configuration that names none of them gets none of them.
139
159
  def self_check_actions(provider, chat)
@@ -153,7 +173,7 @@ module Vangrail
153
173
  policy: prompt_for(task),
154
174
  model: entry['model'],
155
175
  chat: chat,
156
- provider: provider_for(entry, provider)
176
+ provider: provider_for(entry, provider),
157
177
  )
158
178
  end
159
179
 
@@ -195,24 +215,24 @@ module Vangrail
195
215
  models: [
196
216
  model_entry('main', main_model, base_url),
197
217
  model_entry('self_check_input', judge_model, base_url),
198
- model_entry('self_check_output', judge_model, base_url)
218
+ model_entry('self_check_output', judge_model, base_url),
199
219
  ],
200
220
  rails: {
201
221
  'input' => { 'flows' => ['self check input'] },
202
- 'output' => { 'flows' => ['self check output'] }
222
+ 'output' => { 'flows' => ['self check output'] },
203
223
  },
204
224
  prompts: [
205
225
  { 'task' => 'self_check_input', 'content' => self_check_prompt(:input, subject) },
206
- { 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) }
226
+ { 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) },
207
227
  ],
208
228
  instructions: [
209
229
  {
210
230
  'type' => 'general',
211
231
  'content' => "You answer questions about #{subject}. Every factual clause " \
212
232
  'comes from a supplied passage. Where the passages do not cover ' \
213
- 'the question, say so.'
214
- }
215
- ]
233
+ 'the question, say so.',
234
+ },
235
+ ],
216
236
  )
217
237
  end
218
238