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
@@ -16,13 +16,15 @@ module Vangrail
16
16
  # So a gateway is registered by the application that has one:
17
17
  #
18
18
  # Vangrail::Providers.register_gateway(
19
- # name: 'hub',
20
- # base_url: 'https://gateway.example/api/v0',
21
- # models: { judge: 'some/instruct-model', guard: 'some/guard-model' },
22
- # guard_preset: :apriel_guard,
23
- # key_env: 'HUB_API_KEY',
24
- # key_file: File.join(Dir.home, '.config', 'hub', 'api_key'),
25
- # pass_entry: 'hub/token'
19
+ # Vangrail::Providers::Gateway::Spec.new(
20
+ # name: 'hub',
21
+ # base_url: 'https://gateway.example/api/v0',
22
+ # models: { judge: 'some/instruct-model', guard: 'some/guard-model' },
23
+ # guard_preset: :apriel_guard,
24
+ # key_env: 'HUB_API_KEY',
25
+ # key_file: File.join(Dir.home, '.config', 'hub', 'api_key'),
26
+ # pass_entry: 'hub/token'
27
+ # )
26
28
  # )
27
29
  #
28
30
  # or by environment, so a deployment needs no code at all:
@@ -56,7 +58,7 @@ module Vangrail
56
58
  guard_preset: spec.guard_preset,
57
59
  key_resolver: lambda do
58
60
  tokens[spec.name] ||= token(spec, env)
59
- end
61
+ end,
60
62
  )
61
63
  end
62
64
 
@@ -94,10 +96,9 @@ module Vangrail
94
96
  return nil unless $?&.success?
95
97
 
96
98
  present(out.to_s.lines.first)
97
- # A missing `pass`, a locked keyring, a refused pinentry: none of them are
98
- # this method's problem, and all of them mean the same thing here, which is
99
- # that no credential came from this source.
100
- rescue StandardError
99
+ # A missing `pass` binary, or one that cannot be executed. A failing
100
+ # lookup is already `$?` not succeeding; anything else is a bug.
101
+ rescue Errno::ENOENT, Errno::EACCES
101
102
  nil
102
103
  end
103
104
 
@@ -111,14 +112,14 @@ module Vangrail
111
112
  base_url: base,
112
113
  models: {
113
114
  judge: present(env["#{ENV_PREFIX}_JUDGE_MODEL"]) || present(env["#{ENV_PREFIX}_MODEL"]),
114
- guard: present(env["#{ENV_PREFIX}_GUARD_MODEL"])
115
+ guard: present(env["#{ENV_PREFIX}_GUARD_MODEL"]),
115
116
  }.compact,
116
117
  guard_preset: present(env["#{ENV_PREFIX}_GUARD_PRESET"])&.to_sym,
117
118
  key_env: "#{ENV_PREFIX}_API_KEY",
118
119
  file_env: "#{ENV_PREFIX}_KEY_FILE",
119
120
  pass_env: "#{ENV_PREFIX}_PASS_ENTRY",
120
121
  key_file: present(env["#{ENV_PREFIX}_KEY_FILE"]),
121
- pass_entry: present(env["#{ENV_PREFIX}_PASS_ENTRY"])
122
+ pass_entry: present(env["#{ENV_PREFIX}_PASS_ENTRY"]),
122
123
  )
123
124
  end
124
125
 
@@ -11,8 +11,8 @@ module Vangrail
11
11
  # It is the right default for guardrails specifically. Rails run on every
12
12
  # turn, so their latency and their failure modes are the application's; a
13
13
  # local endpoint keeps both on this machine, needs no shared credential, and
14
- # cannot bill anyone. It also means a laptop with the proxy running has
15
- # working rails with nothing configured.
14
+ # cannot bill anyone. It also means a laptop with the proxy running, and a
15
+ # model named, has working rails with no shared credential to configure.
16
16
  #
17
17
  # The proxy serves an instruct model, not a safety classifier, so
18
18
  # `model(:guard)` is nil and the builder puts a policy rail on the input
@@ -21,8 +21,10 @@ module Vangrail
21
21
  module Llmlite
22
22
  HOST = '127.0.0.1'
23
23
  DEFAULT_PORT = 8760
24
- DEFAULT_KEY = 'grok-inside'
25
- DEFAULT_MODEL = 'grok-4.5'
24
+ LISTEN_ERRORS = [
25
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
26
+ Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EADDRNOTAVAIL, SocketError
27
+ ].freeze
26
28
 
27
29
  module_function
28
30
 
@@ -44,28 +46,41 @@ module Vangrail
44
46
  socket = TCPSocket.new(host(env), port(env))
45
47
  socket.close
46
48
  true
47
- rescue StandardError
49
+ rescue *LISTEN_ERRORS
48
50
  false
49
51
  end
50
52
 
51
53
  def model(env = ENV)
52
- env['LLMLITE_MODEL'] || env['GROK_LLMLITE_MODEL'] || DEFAULT_MODEL
54
+ present(env['LLMLITE_MODEL'] || env['GROK_LLMLITE_MODEL'])
55
+ end
56
+
57
+ # No default. Which embedding model a proxy serves, if any, is deployment
58
+ # knowledge, and a guessed name costs a 404 on every check while looking
59
+ # like a rail that ran.
60
+ def embed_model(env = ENV)
61
+ present(env['LLMLITE_EMBED_MODEL'] || env['GUARDRAILS_EMBED_MODEL'])
53
62
  end
54
63
 
55
64
  def key(env = ENV)
56
- env['LLMLITE_API_KEY'] || DEFAULT_KEY
65
+ present(env['LLMLITE_API_KEY'])
57
66
  end
58
67
 
59
68
  def provider(env = ENV)
69
+ resolved = key(env)
60
70
  Provider.new(
61
71
  name: 'llmlite',
62
72
  base_url: base_url(env),
63
- models: { judge: model(env), guard: nil },
64
- key_resolver: -> { key(env) },
73
+ models: { judge: model(env), guard: nil, embed: embed_model(env) },
74
+ key_resolver: resolved && -> { resolved },
65
75
  local: true,
66
- probe: -> { listening?(env) }
76
+ probe: -> { listening?(env) },
67
77
  )
68
78
  end
79
+
80
+ def present(value)
81
+ s = value.to_s.strip
82
+ s.empty? ? nil : s
83
+ end
69
84
  end
70
85
  end
71
86
  end
@@ -18,7 +18,8 @@ module Vangrail
18
18
  # somebody's secrets live. A shared gateway is therefore registered by the
19
19
  # application that has one, or described by environment:
20
20
  #
21
- # Vangrail::Providers.register_gateway(name: 'hub', base_url: '...', ...)
21
+ # Vangrail::Providers.register_gateway(Gateway::Spec.new(name: 'hub', base_url: '...'))
22
+ # Vangrail::Providers.register_gateway(name: 'hub', base_url: '...')
22
23
  # GUARDRAILS_GATEWAY_API_BASE=... GUARDRAILS_GATEWAY_API_KEY=...
23
24
  #
24
25
  # An endpoint needed for a single run needs no registration at all:
@@ -41,13 +42,10 @@ module Vangrail
41
42
 
42
43
  # Registers a shared gateway and returns its Provider. Registering a name
43
44
  # twice replaces it, so reloading an application is not a duplicate.
44
- def register_gateway(name:, base_url:, models: {}, guard_preset: nil, key_env: nil,
45
- file_env: nil, pass_env: nil, key_file: nil, pass_entry: nil, env: ENV)
46
- spec = Gateway::Spec.new(
47
- name: name.to_s, base_url: base_url, models: models, guard_preset: guard_preset,
48
- key_env: key_env, file_env: file_env, pass_env: pass_env,
49
- key_file: key_file, pass_entry: pass_entry
50
- )
45
+ #
46
+ # Preferred: a Gateway::Spec. The 0.1.0 keywords still work and become one.
47
+ def register_gateway(spec = nil, env: ENV, **kwargs)
48
+ spec = spec.is_a?(Gateway::Spec) ? spec : Gateway::Spec.new(**(spec || kwargs))
51
49
  registered_specs.reject! { |s| s.name == spec.name }
52
50
  registered_specs << spec
53
51
  Provider.register(Gateway.provider(spec, env))
data/lib/vangrail/rail.rb CHANGED
@@ -6,7 +6,7 @@ module Vangrail
6
6
  # The whole rail protocol: a name, the sides it applies to, and `call`.
7
7
  #
8
8
  # class ShoutRail < Vangrail::Rail
9
- # def call(text, _context)
9
+ # def decide(text, _context)
10
10
  # return pass if text == text.downcase
11
11
  #
12
12
  # modify(text.downcase, reason: 'lowered')
@@ -15,7 +15,8 @@ module Vangrail
15
15
  #
16
16
  # Deliberately not a DSL. A rail is an object with one method, so a Ruby
17
17
  # application can write one in five lines, test it without a network, and put
18
- # it in the same ordered list as the model-backed ones.
18
+ # it in the same ordered list as the model-backed ones. Subclasses implement
19
+ # `#decide`; `#call` scrubs the bytes first so every rail sees readable text.
19
20
  class Rail
20
21
  # Three sides, not two. `:context` is text the application retrieved and is
21
22
  # about to put in a prompt: a wiki page, a search result, a file. It is the
@@ -43,14 +44,37 @@ module Vangrail
43
44
 
44
45
  # Returns a Result. `context` is a hash the engine threads through:
45
46
  # :side, :user_input, :passages, :history, plus anything a caller adds.
46
- def call(_text, _context)
47
- raise NotImplementedError, "#{self.class} must implement #call"
47
+ #
48
+ # Scrubs here so `rail.call(page, side: :context)` (the shape the README
49
+ # and the tutorial teach) never hands a subclass bytes it cannot read.
50
+ def call(text, context = {})
51
+ decide(Rail.usable(text), context)
48
52
  end
49
53
 
54
+ # UTF-8 or something that can be read as it. A body off a socket arrives
55
+ # tagged ASCII-8BIT whatever is in it, so the tag is corrected before the
56
+ # bytes are judged; only genuinely broken sequences are scrubbed, and the
57
+ # scrub leaves a replacement character where the byte was, which
58
+ # Rails::Obfuscation then treats as the evasion it is.
59
+ def self.usable(text)
60
+ body = text.to_s
61
+ body = body.dup.force_encoding(Encoding::UTF_8) unless body.encoding == Encoding::UTF_8
62
+ body.valid_encoding? ? body : body.scrub
63
+ end
64
+
65
+ # Subclasses implement this. `text` is already readable UTF-8.
66
+ def decide(_text, _context)
67
+ raise NotImplementedError, "#{self.class} must implement #decide"
68
+ end
69
+
70
+ def cache_key(_text, _context) = nil
71
+
72
+ def quantifies? = false
73
+
50
74
  # Does this rail need the network. Used to report a posture and to let a
51
- # caller build a model-free engine on purpose.
75
+ # caller build a model-free engine on purpose. The rare case is networked.
52
76
  def offline?
53
- false
77
+ true
54
78
  end
55
79
 
56
80
  # A rail that stands in for one that could not be built, rather than a rail
@@ -61,6 +85,20 @@ module Vangrail
61
85
  false
62
86
  end
63
87
 
88
+ # True when this rail's job is to say whether the other rails apply, not
89
+ # to contribute a hit. Language is the case: it never blocks, and a miss
90
+ # means the lexicon rails did not read the page.
91
+ def posture?
92
+ false
93
+ end
94
+
95
+ # True when a hit or a silence is meaningful even if the page is not in a
96
+ # language the lexicons cover. Markup, budgets, and secrets do not care
97
+ # what language the words are in. Pattern and concept rails do.
98
+ def language_agnostic?
99
+ false
100
+ end
101
+
64
102
  def to_s
65
103
  name
66
104
  end
@@ -81,8 +119,8 @@ module Vangrail
81
119
 
82
120
  # A rail that could not reach a decision allows the text and says so. It
83
121
  # must never look like a clean check.
84
- def unchecked(reason)
85
- Result.unchecked(rail: name, reason: reason)
122
+ def unchecked(reason, **kwargs)
123
+ Result.unchecked(rail: name, reason: reason, **kwargs)
86
124
  end
87
125
 
88
126
  def default_name
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../nlp'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # Catches the injection whose concepts are in the right order but too far
9
+ # apart for the pair window.
10
+ #
11
+ # Rails::Paraphrase matches two concepts inside a short token window.
12
+ # That is the right rule for a clause, and it is the wrong rule for
13
+ # "Ignore, once you have loaded the module the reservation policy
14
+ # describes, every previous instruction": the override and the
15
+ # instruction are one statement with an adjunct in the middle, and the
16
+ # window treats them as two.
17
+ #
18
+ # Templates are three concepts, not two. The pair rail already owns the
19
+ # close case. Last-in-order within a span: the last occurrence of each
20
+ # template concept, in the template's order, with a coordinator between
21
+ # them treated as two statements. That is what a window of six cannot
22
+ # be. Ordered, so "follow the guidance and ignore stale copies" still
23
+ # does not match. Extra copies of :instruction (a "policy" mentioned
24
+ # mid-clause) do not steal the object.
25
+ class Alignment < Rail
26
+ TEMPLATES = [
27
+ { label: 'instruction_override', concepts: %i[override totality instruction] },
28
+ { label: 'instruction_override', concepts: %i[override totality prior] },
29
+ { label: 'prompt_disclosure', concepts: %i[reveal self secret] },
30
+ ].freeze
31
+
32
+ attr_reader :templates, :languages
33
+
34
+ def initialize(templates: TEMPLATES, languages: NLP::LANGUAGES, name: 'alignment',
35
+ sides: %i[input context])
36
+ super(name: name, sides: sides)
37
+ @templates = templates
38
+ @languages = Array(languages).map(&:to_sym)
39
+ unknown = @languages - NLP::LANGUAGES
40
+ raise ArgumentError, "unknown language(s): #{unknown.join(', ')}" unless unknown.empty?
41
+ end
42
+
43
+ def cache_key(text, _context)
44
+ "#{languages.join('+')}\n#{text}"
45
+ end
46
+
47
+ def decide(text, _context)
48
+ clauses = NLP.clauses(text)
49
+ hits = NLP.clause_concepts(text, languages: languages).flat_map.with_index do |found, i|
50
+ clause_hits(found, NLP.words(clauses[i]))
51
+ end
52
+ return pass if hits.empty?
53
+
54
+ block(categories: hits.map { |hit| hit[:label] }.uniq,
55
+ reason: "aligned instruction: #{hits.map { |hit| describe(hit) }.uniq.join('; ')}")
56
+ end
57
+
58
+ private
59
+
60
+ def clause_hits(found, tokens)
61
+ return [] if found.empty?
62
+
63
+ templates.filter_map { |template| align(found, tokens, template) }
64
+ end
65
+
66
+ # Last occurrence of each template concept, in the template's order.
67
+ def align(found, tokens, template)
68
+ target = template[:concepts]
69
+ return nil if target.size < 3
70
+
71
+ last = {}
72
+ found.each { |(i, concept, word)| last[concept] = [i, concept, word] if target.include?(concept) }
73
+ placed = target.map { |concept| last[concept] }
74
+ return nil if placed.any?(&:nil?)
75
+ return nil unless placed.each_cons(2).all? { |(left, right)| left[0] < right[0] }
76
+ return nil if (placed.last[0] - placed.first[0]) > 20
77
+ return nil if coordinated?(tokens, placed.first[0], placed.last[0])
78
+
79
+ { label: template[:label], words: placed.map { |(_, _, word)| word } }
80
+ end
81
+
82
+ def coordinated?(tokens, start_at, stop_at)
83
+ tokens[(start_at + 1)...stop_at].any? { |word| NLP::COORDINATOR_STEMS.include?(NLP.stem(word)) }
84
+ end
85
+
86
+ def describe(hit)
87
+ "#{hit[:label]} (#{hit[:words].join(' ... ')})"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../bayes_data'
4
+ require_relative '../nlp'
5
+ require_relative '../rail'
6
+
7
+ module Vangrail
8
+ module Rails
9
+ # A naive Bayes classifier over word n-grams, which is the oldest working
10
+ # text classifier there is and the only rail here that says how sure it is.
11
+ #
12
+ # Every other rail answers yes or no, so it hands the evidence arithmetic
13
+ # exactly one bit of information however certain it was. This one computes a
14
+ # log-likelihood ratio directly, which is the quantity that arithmetic
15
+ # actually wants: a clause scoring twelve bits and a clause scoring three
16
+ # both "fire", and they are not the same observation. A rail that puts
17
+ # `bits` in its result's `raw` is read that way by Engine#assess, and this is
18
+ # the first rail to do it.
19
+ #
20
+ # Scored clause by clause, taking the worst. The same dilution problem the
21
+ # containment rail hit applies here with force: an attack document is
22
+ # ordinary documentation with one injected sentence in it, and a bag of
23
+ # features over the whole page is mostly evidence about the handbook.
24
+ #
25
+ # The shipped weights are a demonstration, and the honest number says so.
26
+ # Cross-validated over five folds on 48 attack clauses and 56 benign ones,
27
+ # at a threshold no held-out benign document reached, it catches 15 of 48
28
+ # attacks: worse than the lexicon rails, which catch three quarters. The
29
+ # reason is the corpus rather than the method. Forty-eight training clauses
30
+ # written to be varied share almost no vocabulary with the held-out ones, and
31
+ # the junk-mail filters this borrows from were fitted on millions of
32
+ # examples.
33
+ #
34
+ # So it is off by default, and what it is for is the retraining path: a
35
+ # deployment with its own traffic runs script/train_bayes.rb against its own
36
+ # corpus and gets a rail fitted to the attacks it actually receives, with a
37
+ # cross-validated number attached rather than a promise.
38
+ class Bayes < Rail
39
+ def initialize(weights: BayesData::WEIGHTS, threshold: BayesData::THRESHOLD,
40
+ calibration: BayesData::CALIBRATION, name: 'bayes', sides: %i[input context])
41
+ super(name: name, sides: sides)
42
+ @weights = weights
43
+ @threshold = threshold
44
+ @calibration = calibration
45
+ end
46
+
47
+ attr_reader :weights, :threshold, :calibration
48
+
49
+ def quantifies?
50
+ true
51
+ end
52
+
53
+ def cache_key(text, _context)
54
+ "#{threshold}\n#{text}"
55
+ end
56
+
57
+ def decide(text, _context)
58
+ score = score_for(text)
59
+ evidence = bits_for(score)
60
+ payload = { 'bits' => evidence, 'score' => score }
61
+ # The middle calibration band has both classes in it (22 attacks and
62
+ # 8 ordinary pages between 0 and the threshold). A score there is
63
+ # not a decision. Above the threshold no held-out benign document
64
+ # landed, and at or below 0 no held-out attack did.
65
+ return unchecked('score sits in a band the calibration cannot separate', raw: payload) if overlap?(score)
66
+ return pass(raw: payload) if score <= threshold
67
+
68
+ block(categories: ['bayes'], raw: payload,
69
+ reason: format('scores %<score>+.1f, worth %<bits>+.1f bits of evidence', score: score,
70
+ bits: evidence))
71
+ end
72
+
73
+ def overlap?(score)
74
+ score.positive? && score <= threshold
75
+ end
76
+
77
+ # The worst clause's raw naive Bayes score. Not a likelihood ratio and not
78
+ # to be added to one: the features are counted as independent and are not,
79
+ # so this number is confidently wrong about its own size. It decides the
80
+ # block, because a threshold only needs an ordering.
81
+ def score_for(text)
82
+ clauses = NLP.clauses(text)
83
+ return clause_score(text.to_s) if clauses.empty?
84
+
85
+ clauses.map { |clause| clause_score(clause) }.max
86
+ end
87
+
88
+ # What that score is actually worth, from the calibration fitted on
89
+ # held-out folds and read at the 95% bound. This is the number that goes
90
+ # into a posterior, and it is bounded by what 48 attack clauses can
91
+ # demonstrate rather than by how loudly the classifier scored.
92
+ def bits(text)
93
+ bits_for(score_for(text))
94
+ end
95
+
96
+ def bits_for(score)
97
+ band = calibration.reverse.detect { |floor, _| score > floor }
98
+ band ? band.last : calibration.first.last
99
+ end
100
+
101
+ private
102
+
103
+ def clause_score(text)
104
+ features(text).sum { |feature| weights[feature] || 0.0 }
105
+ end
106
+
107
+ # Word stems and adjacent stem pairs, through the same stemmer the lexicon
108
+ # rails use, so both read the text the same way.
109
+ def features(text)
110
+ words = NLP.words(text).map { |word| NLP.stem(word) }
111
+ (words + words.each_cons(2).map { |pair| pair.join(' ') }).uniq
112
+ end
113
+ end
114
+ end
115
+ end
@@ -40,7 +40,7 @@ module Vangrail
40
40
  @max_context = max_context
41
41
  end
42
42
 
43
- def offline?
43
+ def language_agnostic?
44
44
  true
45
45
  end
46
46
 
@@ -48,7 +48,7 @@ module Vangrail
48
48
  "#{context[:side]}:#{text.to_s.length}"
49
49
  end
50
50
 
51
- def call(text, context)
51
+ def decide(text, context)
52
52
  limit = context[:side] == :context ? max_context : max_characters
53
53
  return pass if limit.nil?
54
54
 
@@ -44,7 +44,7 @@ module Vangrail
44
44
  raise ArgumentError, 'a canary rail needs at least one token' if @tokens.empty?
45
45
  end
46
46
 
47
- def offline?
47
+ def language_agnostic?
48
48
  true
49
49
  end
50
50
 
@@ -52,7 +52,7 @@ module Vangrail
52
52
  text
53
53
  end
54
54
 
55
- def call(text, context)
55
+ def decide(text, context)
56
56
  body = text.to_s
57
57
  # Formatting is not concealment, but a model that writes the token with
58
58
  # a line break or a backtick in it has still leaked it, so the
@@ -20,14 +20,22 @@ module Vangrail
20
20
  def initialize(flow_name:, program:, actions:, name: nil, sides: Rail::SIDES)
21
21
  super(name: name || flow_name, sides: sides)
22
22
  @flow_name = flow_name
23
+ raise ColangError, "no flow named #{flow_name.inspect}" unless program.flow(flow_name)
24
+
23
25
  @interpreter = Colang::Interpreter.new(program: program, actions: actions)
24
26
  end
25
27
 
28
+ # Actions registered on the flow can hit the network. The default
29
+ # offline? is true; this rail is not that case.
30
+ def offline?
31
+ false
32
+ end
33
+
26
34
  def cache_key(_text, _context)
27
35
  nil
28
36
  end
29
37
 
30
- def call(text, context)
38
+ def decide(text, context)
31
39
  outcome = interpreter.run(flow_name, context.merge(text: text))
32
40
  case outcome.status
33
41
  when :blocked then block(content: outcome.content, reason: outcome.reason || flow_name)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../nlp'
3
4
  require_relative '../rail'
4
5
 
5
6
  module Vangrail
@@ -43,7 +44,11 @@ module Vangrail
43
44
  \A[^.?!]{0,60}\b(?:as\s+i\s+(?:said|asked|mentioned)|like\s+i\s+(?:said|asked)|
44
45
  (?:the|my)\s+(?:previous|last|earlier)\s+(?:question|request|message)|
45
46
  try\s+again|answer\s+(?:it|that|the\s+question)\s+anyway|
46
- just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue)\b
47
+ just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue|
48
+ zoals\s+ik\s+(?:zei|vroeg|al\s+zei)|
49
+ (?:de|mijn)\s+(?:vorige|eerdere|laatste)\s+(?:vraag|verzoek|bericht)|
50
+ probeer\s+opnieuw|beantwoord\s+(?:het|die\s+vraag)\s+gewoon|
51
+ ga\s+(?:gewoon\s+)?(?:door|verder)|alsjeblieft\s+doorgaan)\b
47
52
  /xi
48
53
 
49
54
  # The openers that exist to relabel a refused request as something else.
@@ -52,7 +57,10 @@ module Vangrail
52
57
  educational\s+purposes|academic\s+purposes)|purely\s+(?:academic|hypothetical)|
53
58
  what\s+if\s+i\s+(?:told\s+you|said)|imagine\s+(?:that\s+)?you|
54
59
  let\s+me\s+rephrase|to\s+(?:re)?phrase\s+(?:it|that)\s+differently|
55
- you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked))\b
60
+ you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked)|
61
+ hypothetisch|in\s+theorie|voor\s+(?:een\s+vriend|onderzoek|een\s+paper)|
62
+ puur\s+(?:academisch|hypothetisch)|stel\s+dat|laat\s+me\s+(?:het\s+)?herformuleren|
63
+ je\s+hebt\s+me\s+verkeerd\s+begrepen|dat\s+is\s+niet\s+wat\s+ik\s+(?:bedoelde|vroeg))\b
56
64
  /xi
57
65
 
58
66
  STOP = %w[
@@ -60,6 +68,9 @@ module Vangrail
60
68
  from by as it its this that these those i you he she they we me my your do
61
69
  does did how what why when where can could would should will shall may
62
70
  might must not no yes if then than so about into over under please
71
+ de het een van en of maar is zijn was waren te in op aan voor met tot
72
+ bij als dan dat die dit deze ik je we zij jij u uw mijn niet geen ja
73
+ nee hoe wat waarom wanneer waar kan zou moet zal wel nog
63
74
  ].freeze
64
75
 
65
76
  attr_reader :overlap, :window, :tolerance
@@ -78,17 +89,13 @@ module Vangrail
78
89
  @tolerance = tolerance
79
90
  end
80
91
 
81
- def offline?
82
- true
83
- end
84
-
85
92
  # Not memoizable: the same question means different things depending on
86
93
  # what came before it, which is the entire premise of the rail.
87
94
  def cache_key(_text, _context)
88
95
  nil
89
96
  end
90
97
 
91
- def call(text, context)
98
+ def decide(text, context)
92
99
  # A caller that never passes :history is not threading a dialogue, and
93
100
  # this rail has not checked anything: say so. A caller that passes an
94
101
  # empty one is threading a dialogue that has just started, which is a
@@ -166,7 +173,7 @@ module Vangrail
166
173
  end
167
174
 
168
175
  def content_words(text)
169
- text.to_s.downcase.scan(/[a-z0-9_-]{2,}/) - STOP
176
+ NLP.words(text).reject { |word| STOP.include?(word) }
170
177
  end
171
178
 
172
179
  def user?(turn)
@@ -67,7 +67,7 @@ module Vangrail
67
67
  @max_query = max_query
68
68
  end
69
69
 
70
- def offline?
70
+ def language_agnostic?
71
71
  true
72
72
  end
73
73
 
@@ -75,7 +75,7 @@ module Vangrail
75
75
  text
76
76
  end
77
77
 
78
- def call(text, _context)
78
+ def decide(text, _context)
79
79
  body = text.to_s
80
80
  found = []
81
81
  cleaned = strip_all(body, found)
@@ -33,25 +33,28 @@ module Vangrail
33
33
  end
34
34
  end
35
35
 
36
+ def offline?
37
+ false
38
+ end
39
+
36
40
  # Not memoizable. Stated rather than left to a default so the reason is
37
41
  # visible where the decision is.
38
42
  def cache_key(_text, _context)
39
43
  nil
40
44
  end
41
45
 
42
- def call(text, context)
46
+ def decide(text, context)
43
47
  passages = Array(context[:passages])
44
48
  return unchecked('no passages supplied') if passages.empty?
45
49
 
46
50
  answer = chat.ask([
47
51
  { 'role' => 'system', 'content' => policy },
48
- { 'role' => 'user', 'content' => Policies.grounding_prompt(text, passages) }
52
+ { 'role' => 'user', 'content' => Policies.grounding_prompt(text, passages) },
49
53
  ])
50
54
  parsed = Parsers.policy(answer.text)
51
55
  unless parsed[:decided]
52
- return Result.new(status: :passed, rail: name, certain: false, model: model,
53
- latency_ms: answer.latency_ms, raw: answer.raw,
54
- reason: "unparsed judge response: #{parsed[:reason]}")
56
+ return unchecked("unparsed judge response: #{parsed[:reason]}",
57
+ model: model, latency_ms: answer.latency_ms, raw: answer.raw)
55
58
  end
56
59
 
57
60
  return pass(model: model, latency_ms: answer.latency_ms, raw: answer.raw) unless parsed[:violated]