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.
- checksums.yaml +4 -4
- data/README.md +655 -43
- 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 +354 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +27 -18
- 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 +240 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +29 -70
- 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/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 +124 -0
- data/lib/vangrail/nlp.rb +596 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +114 -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 +52 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +2 -6
- 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 +2 -6
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +81 -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 +3 -3
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +8 -6
- data/lib/vangrail/tools.rb +58 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +39 -258
- metadata +34 -5
data/lib/vangrail/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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, &
|
|
232
|
-
return
|
|
188
|
+
def memoized(rail, text, ctx, &)
|
|
189
|
+
return yield unless cache
|
|
233
190
|
|
|
234
|
-
|
|
235
|
-
|
|
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, &
|
|
196
|
+
cache.fetch(ctx[:side], rail.name, key, &)
|
|
238
197
|
end
|
|
239
198
|
end
|
|
240
199
|
end
|
data/lib/vangrail/errors.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
65
|
-
error.is_a?(TransportError) || error.retryable?
|
|
70
|
+
perform(klass, uri, payload)
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def perform(klass, uri, payload)
|