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
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'assessor'
3
4
  require_relative 'errors'
4
5
  require_relative 'rail'
5
6
  require_relative 'result'
6
7
  require_relative 'result_cache'
8
+ require_relative 'screening'
7
9
 
8
10
  module Vangrail
9
11
  # Runs ordered rails over text and reports one Result.
@@ -21,6 +23,9 @@ module Vangrail
21
23
  # about: a redaction rail that runs before a policy rail should have the
22
24
  # policy rail judge the redacted text, not the original.
23
25
  class Engine
26
+ Screening = Vangrail::Screening
27
+ Triage = Vangrail::Triage
28
+
24
29
  attr_reader :input_rails, :context_rails, :output_rails, :on_error, :cache
25
30
 
26
31
  def initialize(input: [], context: [], output: [], on_error: :allow, cache: true)
@@ -46,57 +51,18 @@ module Vangrail
46
51
  run(:context, context_rails, text, context)
47
52
  end
48
53
 
49
- # Screens a set of retrieved documents and reports what survived.
50
- #
51
- # A document that fails is dropped rather than failing the whole turn. One
52
- # poisoned wiki page should cost a reader that page, not their answer, and
53
- # an application that refuses outright teaches its readers that the
54
- # guardrail is the problem.
55
- def screen(documents, **context)
56
- kept = []
57
- rejected = []
58
- uncertain = nil
54
+ def screen(...) = Screening.run(self, ...)
59
55
 
60
- Array(documents).each_with_index do |document, index|
61
- result = check_context(text_of(document), **context, document: document, index: index)
62
- uncertain ||= result unless result.certain?
63
- if result.blocked?
64
- rejected << { document: document, result: result }
65
- else
66
- kept << (result.modified? ? replace_text(document, result.content) : document)
67
- end
68
- end
69
-
70
- Screening.new(kept: kept, rejected: rejected, certain: uncertain.nil?, reason: uncertain&.reason)
71
- end
56
+ def assess(...) = Assessor.new(self).assess(...)
72
57
 
73
- # What screen returns. `certain` means what it means on a Result: false says
74
- # a rail did not reach a decision about some document, so "nothing was
75
- # rejected" is not evidence that nothing was wrong.
76
- Screening = Struct.new(:kept, :rejected, :certain, :reason, keyword_init: true) do
77
- def certain?
78
- certain
79
- end
80
-
81
- def rejected?
82
- !rejected.empty?
83
- end
84
-
85
- def to_h
86
- {
87
- 'kept' => kept.size,
88
- 'rejected' => rejected.map { |r| r[:result].to_h },
89
- 'certain' => certain?,
90
- 'reason' => reason
91
- }.compact
92
- end
93
- end
58
+ def triage(...) = Assessor.new(self).triage(...)
94
59
 
95
60
  def rails(side)
96
61
  case side.to_sym
97
62
  when :input then input_rails
98
63
  when :context then context_rails
99
- else output_rails
64
+ when :output then output_rails
65
+ else raise ArgumentError, "unknown side: #{side}"
100
66
  end
101
67
  end
102
68
 
@@ -122,7 +88,7 @@ module Vangrail
122
88
  'output' => rail_names(:output),
123
89
  'on_error' => on_error.to_s,
124
90
  'offline' => offline?,
125
- 'cache' => cache&.to_h
91
+ 'cache' => cache&.to_h,
126
92
  }.compact
127
93
  end
128
94
 
@@ -138,28 +104,18 @@ module Vangrail
138
104
  parts.join(' ')
139
105
  end
140
106
 
141
- private
142
-
143
- def text_of(document)
144
- return document.to_s unless document.is_a?(Hash)
145
-
146
- (document['text'] || document[:text]).to_s
107
+ # Public so Assessor can run one rail without going through #run.
108
+ def invoke(rail, text, ctx)
109
+ memoized(rail, text, ctx) { call_rail(rail, text, ctx) }
147
110
  end
148
111
 
149
- # A context rail may rewrite a document rather than reject it, so the
150
- # replacement has to go back into the shape the caller passed in.
151
- def replace_text(document, content)
152
- return content.to_s unless document.is_a?(Hash)
153
-
154
- key = document.key?('text') ? 'text' : :text
155
- document.merge(key => content.to_s)
156
- end
112
+ private
157
113
 
158
114
  def run(side, rails, text, context)
159
115
  return Result.unchecked(rail: side, reason: "no #{side} rails configured") if rails.empty?
160
116
 
161
117
  ctx = context.merge(side: side)
162
- current = text.to_s
118
+ current = text
163
119
  modified_by = nil
164
120
  uncertain = nil
165
121
  unbuilt = nil
@@ -176,7 +132,7 @@ module Vangrail
176
132
  end
177
133
  next if result.certain?
178
134
 
179
- # A rail that ran and could not decide says more than one that was
135
+ # A rail that ran and could not decide says more than a rail that was
180
136
  # never built, so its reason is the one the caller sees. Without this,
181
137
  # a placeholder earlier in the list reports "no endpoint was resolved"
182
138
  # over the rail that actually tried and had the connection refused.
@@ -200,10 +156,6 @@ module Vangrail
200
156
  Result.passed(rail: side)
201
157
  end
202
158
 
203
- def invoke(rail, text, ctx)
204
- memoized(rail, text, ctx) { call_rail(rail, text, ctx) }
205
- end
206
-
207
159
  def call_rail(rail, text, ctx)
208
160
  result = rail.call(text, ctx)
209
161
  return result if result.is_a?(Result)
@@ -211,6 +163,11 @@ module Vangrail
211
163
  raise ProtocolError, "#{rail.name} returned #{result.class}, expected Vangrail::Result"
212
164
  rescue Error => e
213
165
  failed(rail, e)
166
+ rescue ArgumentError, EncodingError => e
167
+ # A rail that could not read the bytes is a rail that did not answer.
168
+ # Rail#call scrubs first; a rail doing its own decoding can still get
169
+ # here, and it must not take the turn with it.
170
+ failed(rail, e)
214
171
  end
215
172
 
216
173
  # A rail that raised did not answer. Which way that falls is the operator's
@@ -228,13 +185,15 @@ module Vangrail
228
185
  # Cache keys carry everything the decision depends on. A rail says what that
229
186
  # is through `cache_key`; nil means the rail is not memoizable, which is the
230
187
  # right answer for anything reading passages or history.
231
- def memoized(rail, text, ctx, &block)
232
- return block.call unless cache
188
+ def memoized(rail, text, ctx, &)
189
+ return yield unless cache
233
190
 
234
- key = rail.respond_to?(:cache_key) ? rail.cache_key(text, ctx) : nil
235
- return block.call if key.nil?
191
+ # Key the readable form. Raw bytes can carry a NUL or a wrong tag
192
+ # and would store the same decision under two keys.
193
+ key = rail.cache_key(Rail.usable(text), ctx)
194
+ return yield if key.nil?
236
195
 
237
- cache.fetch(ctx[:side], rail.name, key, &block)
196
+ cache.fetch(ctx[:side], rail.name, key, &)
238
197
  end
239
198
  end
240
199
  end
@@ -40,9 +40,14 @@ module Vangrail
40
40
  # report checks it is not performing.
41
41
  class ColangError < Error; end
42
42
 
43
- # A flow referred to an action or a bot message that nothing defines.
43
+ # A flow called an action that is not in the registry. Raised at execute.
44
44
  class UnknownAction < ColangError; end
45
45
 
46
46
  # The configuration folder is missing something the rails it declares need.
47
47
  class ConfigError < Error; end
48
+
49
+ # A span was offered to a slot its origin cannot occupy: data in the
50
+ # instruction, or a privileged cell in a passage fence. The cut is
51
+ # the type system; this is what raising looks like when it is crossed.
52
+ class PrivilegeError < Error; end
48
53
  end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'beta'
4
+
5
+ module Vangrail
6
+ # One rail's measured operating point, read as evidence rather than as a
7
+ # verdict.
8
+ #
9
+ # Every rail in this gem answers a yes-or-no question, and the engine combines
10
+ # those answers by taking the first "yes". That is what the published defences
11
+ # do too, and it throws away almost everything the rails know. It cannot say
12
+ # how much a hit is worth, cannot add up three near misses, and cannot tell an
13
+ # operator what a block actually means about the text.
14
+ #
15
+ # What a hit is worth is a ratio, and it is measurable: how much likelier this
16
+ # rail is to fire on an attack than on ordinary documentation. That number,
17
+ # the likelihood ratio, is all a rail needs to contribute to a shared
18
+ # judgement, and it is exactly what the corpora in this repository already
19
+ # measure. Every entry here comes from running a rail over the same attack and
20
+ # benign sets as every other rail, which is what makes the numbers comparable
21
+ # in the first place: a detection rate measured on one paper's corpus and a
22
+ # false-positive rate measured on another's cannot be combined at all.
23
+ #
24
+ # Rates are smoothed with the Jeffreys prior, (hits + 1/2) / (n + 1), for a
25
+ # reason that is not decoration. A rail that caught 60 of 60 has an unsmoothed
26
+ # detection rate of exactly 1, an unsmoothed likelihood ratio of infinity, and
27
+ # would single-handedly decide every judgement it appears in on the strength
28
+ # of a sixty-item corpus. Smoothing keeps the evidence finite and proportional
29
+ # to how much was actually measured.
30
+ Evidence = Struct.new(:rail, :attacks_caught, :attacks, :benign_flagged, :benign, :group,
31
+ keyword_init: true) do
32
+ # Probability the rail fires given the text is an attack.
33
+ def detection
34
+ (attacks_caught + 0.5) / (attacks + 1.0)
35
+ end
36
+
37
+ # Probability it fires given the text is ordinary.
38
+ def false_alarm
39
+ (benign_flagged + 0.5) / (benign + 1.0)
40
+ end
41
+
42
+ # How much likelier a hit is on an attack than on ordinary text.
43
+ def ratio_fired
44
+ detection / false_alarm
45
+ end
46
+
47
+ # And how much likelier silence is on ordinary text than on an attack. This
48
+ # is the half that OR-combination cannot express at all: a sensitive rail
49
+ # staying quiet is evidence too, and it points the other way.
50
+ def ratio_silent
51
+ (1 - detection) / (1 - false_alarm)
52
+ end
53
+
54
+ # The rates a corpus this size can actually defend, rather than the ones it
55
+ # happens to have produced.
56
+ #
57
+ # A rail that fired on none of 48 benign texts has a point estimate of one
58
+ # in a hundred and a 95% upper bound of one in twenty-six. The difference is
59
+ # two bits of evidence that nobody measured, and reporting the point
60
+ # estimate spends them.
61
+ #
62
+ # Pessimistic on both sides at once: detection at the low end of its
63
+ # posterior and false alarms at the high end. That single operating point is
64
+ # conservative for a hit and for silence alike, because both ratios move the
65
+ # same way under it.
66
+ # Memoised, because the bound is a function of counts that never change and
67
+ # the inverse of an incomplete beta is sixty bisections of a continued
68
+ # fraction. Computed once per entry per confidence level; the rails call it
69
+ # on every check.
70
+ def detection_bound(confidence)
71
+ bounds[[:detection, confidence]] ||=
72
+ Beta.quantile(1 - confidence, attacks_caught + 0.5, attacks - attacks_caught + 0.5)
73
+ end
74
+
75
+ def false_alarm_bound(confidence)
76
+ bounds[[:false_alarm, confidence]] ||=
77
+ Beta.quantile(confidence, benign_flagged + 0.5, benign - benign_flagged + 0.5)
78
+ end
79
+
80
+ def bounds
81
+ @bounds ||= {}
82
+ end
83
+
84
+ # Evidence in bits, positive towards attack. Bits rather than nats because
85
+ # an operator has to read these: one bit is a doubling of the odds, and
86
+ # "this rail is worth four bits" is a sentence somebody can act on.
87
+ #
88
+ # With a confidence, the bits are what the corpus can defend at that level
89
+ # rather than what it measured. A table built from a few hundred texts
90
+ # should be read this way; the point estimate is what it would say if the
91
+ # corpus were the world.
92
+ def bits(fired, confidence: nil)
93
+ return Math.log2(fired ? ratio_fired : ratio_silent) if confidence.nil?
94
+
95
+ detection = detection_bound(confidence)
96
+ false_alarm = false_alarm_bound(confidence)
97
+ Math.log2(fired ? detection / false_alarm : (1 - detection) / (1 - false_alarm))
98
+ end
99
+
100
+ # How much of the question this rail actually answers, at a given base rate.
101
+ #
102
+ # Detection and false-alarm rates describe a rail; they do not describe what
103
+ # it is worth in a deployment, because they say nothing about how often the
104
+ # thing being detected happens. The intrusion-detection literature settled
105
+ # this with an information-theoretic measure: the fraction of the
106
+ # uncertainty about "is this an attack" that the rail's verdict removes.
107
+ #
108
+ # Measured on the shipped table, the base rate costs every rail roughly two
109
+ # fifths of its capability between a balanced corpus and one attack in ten
110
+ # thousand: paraphrase falls from 0.52 to 0.28, and every other rail sits
111
+ # under 0.1 at both. many_shot manages 0.001, which is the honest reading of
112
+ # a rail that caught six of 270 because the corpus is mostly not its attack.
113
+ #
114
+ # Ranking rails by this rather than by detection rate is the point. It is
115
+ # the only number here that changes when the deployment does.
116
+ def capability(prior:)
117
+ return 0.0 unless measured?
118
+
119
+ mutual_information(prior) / entropy(prior)
120
+ end
121
+
122
+ def to_bits_h(prior:, confidence: nil)
123
+ {
124
+ 'rail' => rail,
125
+ 'bits_if_fired' => bits(true, confidence: confidence).round(2),
126
+ 'bits_if_silent' => bits(false, confidence: confidence).round(2),
127
+ 'capability' => capability(prior: prior).round(4),
128
+ }
129
+ end
130
+
131
+ # A rail that never fired on either corpus has measured nothing, whatever
132
+ # its detection rate looks like after smoothing.
133
+ def measured?
134
+ attacks.positive? && benign.positive?
135
+ end
136
+
137
+ def to_h
138
+ {
139
+ 'rail' => rail, 'group' => group,
140
+ 'attacks_caught' => attacks_caught, 'attacks' => attacks,
141
+ 'benign_flagged' => benign_flagged, 'benign' => benign,
142
+ 'detection' => detection.round(4), 'false_alarm' => false_alarm.round(4),
143
+ 'bits_if_fired' => bits(true).round(2), 'bits_if_silent' => bits(false).round(2)
144
+ }
145
+ end
146
+
147
+ private
148
+
149
+ def entropy(prior)
150
+ -((prior * Math.log2(prior)) + ((1 - prior) * Math.log2(1 - prior)))
151
+ end
152
+
153
+ # I(X;Y) over the two-by-two table of truth against verdict.
154
+ def mutual_information(prior)
155
+ joint = [[prior * detection, prior * (1 - detection)],
156
+ [(1 - prior) * false_alarm, (1 - prior) * (1 - false_alarm)]]
157
+ fires = joint[0][0] + joint[1][0]
158
+ quiet = joint[0][1] + joint[1][1]
159
+ marginals = [[prior * fires, prior * quiet], [(1 - prior) * fires, (1 - prior) * quiet]]
160
+
161
+ joint.flatten.zip(marginals.flatten).sum do |cell, marginal|
162
+ next 0.0 if cell <= 0 || marginal <= 0
163
+
164
+ cell * Math.log2(cell / marginal)
165
+ end
166
+ end
167
+ end
168
+
169
+ # Combines rail evidence into a posterior probability that the text is an
170
+ # attack.
171
+ #
172
+ # The arithmetic is one line: odds after = odds before times every likelihood
173
+ # ratio. In bits it is addition, which is why the contributions of individual
174
+ # rails can be printed and read.
175
+ #
176
+ # Three things make this more than a formality, and all three are things the
177
+ # published defences leave on the floor.
178
+ #
179
+ # The prior is the deployment's, and it dominates. Detector papers evaluate on
180
+ # balanced corpora, where half the traffic is an attack; a documentation desk
181
+ # sees maybe one poisoned page in ten thousand. At that base rate a rail with
182
+ # a one percent false-alarm rate is wrong far more often than it is right when
183
+ # it fires, and no amount of detection rate fixes it. That is not a criticism
184
+ # of the rails: it is the arithmetic every operator inherits and almost none
185
+ # is shown.
186
+ #
187
+ # Abstention is evidence of nothing, which is different from evidence against.
188
+ # A rail that was off, unreachable, or undecided contributes no term at all,
189
+ # and this gem is unusual in knowing which rails those were: `certain?` is
190
+ # exactly that fact, and here it finally has arithmetic to feed.
191
+ #
192
+ # Correlated rails do not each get a vote. Three rails that fire on the same
193
+ # sentence for the same reason are one observation reported three times, and
194
+ # summing them is how naive Bayes talks itself into certainty. Rails measured
195
+ # to agree are grouped, and a group contributes once.
196
+ module Posterior
197
+ module_function
198
+
199
+ # Combines and returns [posterior, contributions].
200
+ #
201
+ # `observations` maps a rail name to true (fired), false (ran and did not
202
+ # fire), or nil (did not run). The nils are the point.
203
+ # `direct` carries rails that computed their own log-likelihood ratio rather
204
+ # than answering yes or no. A rail that can say how sure it is should not be
205
+ # flattened to one bit on the way in, and nothing about the arithmetic
206
+ # changes: bits are bits, whoever produced them.
207
+ # Defensible by default. The point estimate is what a corpus happened to
208
+ # produce and it is unreadable at the edges: a rail that caught none of the
209
+ # published attacks and fired on none of eighteen thousand documents scores
210
+ # +7 bits on the point estimate, from two smoothing constants dividing each
211
+ # other, and -2.7 on the bound. The bound is the number that survives being
212
+ # measured against somebody else's corpus, so it is the one that runs.
213
+ DEFAULT_CONFIDENCE = 0.95
214
+
215
+ def combine(prior:, observations:, evidence: EvidenceData::TABLE, confidence: DEFAULT_CONFIDENCE,
216
+ direct: {})
217
+ raise ArgumentError, 'prior must be strictly between 0 and 1' unless prior.positive? && prior < 1
218
+
219
+ contributions = weigh(observations, evidence, confidence) + quantified(direct)
220
+ total = contributions.sum { |c| c[:bits] }
221
+ [from_odds(to_odds(prior) * (2**total)), contributions]
222
+ end
223
+
224
+ # One term per group rather than one per rail.
225
+ #
226
+ # Within a group, the firing rail with the most evidence speaks for the
227
+ # group; if none fired, the most sensitive member's silence speaks for it.
228
+ # Both rules pick the single most informative member, which is the
229
+ # conservative reading of a set of observations that are not independent.
230
+ def weigh(observations, evidence, confidence = nil)
231
+ seen = observations.filter_map do |rail, fired|
232
+ next if fired.nil?
233
+
234
+ entry = evidence[rail.to_s]
235
+ next unless entry&.measured?
236
+
237
+ { rail: rail.to_s, group: entry.group || rail.to_s, fired: fired, entry: entry }
238
+ end
239
+
240
+ seen.group_by { |o| o[:group] }.map { |group, members| speak_for(group, members, confidence) }
241
+ end
242
+
243
+ def speak_for(group, members, confidence = nil)
244
+ fired = members.select { |m| m[:fired] }
245
+ chosen = if fired.empty?
246
+ members.max_by { |m| m[:entry].detection }
247
+ else
248
+ fired.max_by { |m| m[:entry].bits(true, confidence: confidence) }
249
+ end
250
+
251
+ {
252
+ group: group,
253
+ rail: chosen[:rail],
254
+ fired: chosen[:fired],
255
+ bits: chosen[:entry].bits(chosen[:fired], confidence: confidence),
256
+ spoke_for: members.map { |m| m[:rail] },
257
+ }
258
+ end
259
+
260
+ # How many bits it takes to get from a base rate to a target confidence.
261
+ #
262
+ # This is the number the whole design turns on, and it is worth being able
263
+ # to compute rather than assert. Reaching an even-money posterior from one
264
+ # attack in ten thousand takes about 13.3 bits, and no rail in this gem is
265
+ # worth half that, which is a statement about what a single detector can
266
+ # honestly justify rather than about these particular rails.
267
+ def required_bits(prior:, target: 0.5)
268
+ Math.log2(to_odds(target) / to_odds(prior))
269
+ end
270
+
271
+ # The false-alarm rate a single rail would need to carry a block on its own.
272
+ #
273
+ # Rearranged from the same identity: at base rate `prior`, one rail with
274
+ # detection `detection` reaches `target` only if it almost never fires on
275
+ # ordinary text. The answers come out in the region of one in ten thousand,
276
+ # which is below what any hand-built benign corpus can demonstrate: showing
277
+ # a rate that low needs tens of thousands of clean documents on which the
278
+ # rail stayed silent.
279
+ #
280
+ # That is the practical case for combining rails rather than trusting one,
281
+ # and it is an argument about evidence rather than about taste.
282
+ def false_alarm_needed(prior:, detection: 0.75, target: 0.5)
283
+ detection / (to_odds(target) / to_odds(prior))
284
+ end
285
+
286
+ def quantified(direct)
287
+ direct.map do |rail, bits|
288
+ { group: rail.to_s, rail: rail.to_s, fired: bits.positive?, bits: bits.to_f,
289
+ spoke_for: [rail.to_s], quantified: true }
290
+ end
291
+ end
292
+
293
+ def to_odds(probability)
294
+ probability / (1 - probability)
295
+ end
296
+
297
+ def from_odds(odds)
298
+ return 1.0 if odds.infinite?
299
+
300
+ odds / (1 + odds)
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'evidence'
4
+
5
+ module Vangrail
6
+ # What each rail is worth as evidence, measured on corpora written by other
7
+ # people.
8
+ #
9
+ # GENERATED FILE. Do not edit by hand; rerun script/measure_evidence_external.rb.
10
+ # The arithmetic that reads this table lives in evidence.rb, which is
11
+ # hand-written and survives regeneration.
12
+ #
13
+ # One benign source per side; the attack population is stated.
14
+ #
15
+ # Context: 125 published BIPIA injections spliced into installed
16
+ # documentation, plus 120 attacks from this repository's own corpus, against
17
+ # 18258 real documents from the same machine. Two attack families on
18
+ # purpose: BIPIA's off-task instructions, which no deterministic rail here
19
+ # catches, and the override-and-disclosure family the rails were built for,
20
+ # which the shipped corpus covers and which nobody else's benchmark does.
21
+ # The ratio is 125:120 and a deployment whose traffic is not that mix
22
+ # should reweight it and rerun the script.
23
+ #
24
+ # Input: 1405 in-the-wild jailbreak prompts against the
25
+ # 13735 ordinary prompts collected beside them.
26
+ #
27
+ # Read the context numbers before trusting anything built on them. Every
28
+ # deterministic rail catches none of the published injections, because BIPIA's
29
+ # attacks are off-task instructions carrying no override, no disclosure, and
30
+ # no concealment, and those three are all these rails know how to find. A
31
+ # rail that fires on a document under this measurement is reporting a false
32
+ # alarm more often than an attack, and the sign of its evidence says so.
33
+ #
34
+ # That is a statement about a threat model, not a verdict on the rails: the
35
+ # corpus this repository wrote scores them at 60 of 60, because it was
36
+ # written out of the same idea of an attack. Both numbers are real and
37
+ # neither is the answer on its own.
38
+ module EvidenceData
39
+ CONTEXT = [
40
+ Evidence.new(rail: "injected_instructions", group: "injected_instructions",
41
+ attacks_caught: 58, attacks: 245,
42
+ benign_flagged: 48, benign: 18258),
43
+ Evidence.new(rail: "jailbreak", group: "jailbreak",
44
+ attacks_caught: 10, attacks: 245,
45
+ benign_flagged: 20, benign: 18258),
46
+ Evidence.new(rail: "paraphrase", group: "paraphrase",
47
+ attacks_caught: 61, attacks: 245,
48
+ benign_flagged: 236, benign: 18258),
49
+ Evidence.new(rail: "alignment", group: "alignment",
50
+ attacks_caught: 12, attacks: 245,
51
+ benign_flagged: 24, benign: 18258),
52
+ Evidence.new(rail: "similarity", group: "similarity",
53
+ attacks_caught: 12, attacks: 245,
54
+ benign_flagged: 0, benign: 18258),
55
+ Evidence.new(rail: "many_shot", group: "many_shot",
56
+ attacks_caught: 0, attacks: 245,
57
+ benign_flagged: 2, benign: 18258),
58
+ Evidence.new(rail: "obfuscation", group: "obfuscation",
59
+ attacks_caught: 60, attacks: 245,
60
+ benign_flagged: 59, benign: 18258),
61
+ Evidence.new(rail: "hidden", group: "hidden",
62
+ attacks_caught: 5, attacks: 245,
63
+ benign_flagged: 0, benign: 18258),
64
+ Evidence.new(rail: "bayes", group: "bayes",
65
+ attacks_caught: 100, attacks: 245,
66
+ benign_flagged: 1617, benign: 18258)
67
+ ].freeze
68
+
69
+ INPUT = [
70
+ Evidence.new(rail: "injection_patterns", group: "injection_patterns",
71
+ attacks_caught: 35, attacks: 1405,
72
+ benign_flagged: 730, benign: 13735),
73
+ Evidence.new(rail: "jailbreak", group: "jailbreak",
74
+ attacks_caught: 190, attacks: 1405,
75
+ benign_flagged: 134, benign: 13735),
76
+ Evidence.new(rail: "paraphrase", group: "paraphrase",
77
+ attacks_caught: 486, attacks: 1405,
78
+ benign_flagged: 1666, benign: 13735),
79
+ Evidence.new(rail: "alignment", group: "alignment",
80
+ attacks_caught: 160, attacks: 1405,
81
+ benign_flagged: 998, benign: 13735),
82
+ Evidence.new(rail: "similarity", group: "similarity",
83
+ attacks_caught: 127, attacks: 1405,
84
+ benign_flagged: 857, benign: 13735),
85
+ Evidence.new(rail: "many_shot", group: "many_shot",
86
+ attacks_caught: 10, attacks: 1405,
87
+ benign_flagged: 34, benign: 13735),
88
+ Evidence.new(rail: "obfuscation", group: "obfuscation",
89
+ attacks_caught: 248, attacks: 1405,
90
+ benign_flagged: 440, benign: 13735),
91
+ Evidence.new(rail: "bayes", group: "bayes",
92
+ attacks_caught: 340, attacks: 1405,
93
+ benign_flagged: 1715, benign: 13735)
94
+ ].freeze
95
+
96
+ ENTRIES = (CONTEXT + INPUT).freeze
97
+
98
+ BY_SIDE = {
99
+ context: CONTEXT.to_h { |entry| [entry.rail, entry] }.freeze,
100
+ input: INPUT.to_h { |entry| [entry.rail, entry] }.freeze
101
+ }.freeze
102
+
103
+ # A rail's operating point depends on which side it runs on, by a lot: the
104
+ # same rail is worth different evidence reading a retrieved document and
105
+ # reading a question. The default is the input table because that is the
106
+ # side with a detection measurement worth having.
107
+ TABLE = BY_SIDE[:input]
108
+
109
+ def self.for_side(side)
110
+ BY_SIDE[side.to_sym] || TABLE
111
+ end
112
+ end
113
+ end
data/lib/vangrail/http.rb CHANGED
@@ -12,8 +12,22 @@ module Vangrail
12
12
  DEFAULT_OPEN_TIMEOUT = 5
13
13
  DEFAULT_READ_TIMEOUT = 30
14
14
 
15
+ # retries is a switch, not a count: 0 means no retry, any positive value
16
+ # retries TransportError once. HTTPError (including 429) is never retried
17
+ # and never slept on; a rail that waits is a rail that hangs the request.
15
18
  attr_reader :base_url, :open_timeout, :read_timeout, :retries
16
19
 
20
+ # Chat, Embeddings, Completion, and Client all take an HTTP or the
21
+ # arguments that build one. One helper so those constructors stay thin.
22
+ def self.build(http: nil, base_url: nil, api_key: nil,
23
+ open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
24
+ missing: 'a client needs a base_url or an http client')
25
+ return http if http
26
+ raise ArgumentError, missing if base_url.to_s.strip.empty?
27
+
28
+ new(base_url: base_url, api_key: api_key, open_timeout: open_timeout, read_timeout: read_timeout)
29
+ end
30
+
17
31
  def initialize(base_url:, api_key: nil, open_timeout: DEFAULT_OPEN_TIMEOUT,
18
32
  read_timeout: DEFAULT_READ_TIMEOUT, retries: 1, headers: {})
19
33
  @base_url = base_url.to_s.sub(/\/+\z/, '')
@@ -49,20 +63,11 @@ module Vangrail
49
63
 
50
64
  def request(klass, path, payload)
51
65
  uri = URI.join("#{base_url}/", path.to_s.sub(/\A\/+/, ''))
52
- attempt = 0
53
- begin
54
- attempt += 1
55
- perform(klass, uri, payload)
56
- rescue TransportError, HTTPError => e
57
- raise unless attempt <= retries && retryable?(e)
58
-
59
- sleep(0.25 * attempt)
60
- retry
61
- end
62
- end
66
+ perform(klass, uri, payload)
67
+ rescue TransportError
68
+ raise if retries < 1
63
69
 
64
- def retryable?(error)
65
- error.is_a?(TransportError) || error.retryable?
70
+ perform(klass, uri, payload)
66
71
  end
67
72
 
68
73
  def perform(klass, uri, payload)