Class: Vangrail::Engine

Inherits:
Object
  • Object
show all
Defined in:
lib/vangrail/engine.rb

Overview

Runs ordered rails over text and reports one Result.

The rules are short enough to state in full:

  • Rails run in the order given. The first :blocked ends the pass.
  • A :modified result replaces the text for every rail after it, and the engine reports :modified unless something later blocks.
  • A rail that raises is not a rail that passed. on_error: :allow (the default) keeps going and marks the pass uncertain; :block stops.
  • An empty rail list returns :passed with certain false. Nothing ran.

Threading rewrites through later rails is the part worth being explicit about: a redaction rail that runs before a policy rail should have the policy rail judge the redacted text, not the original.

Defined Under Namespace

Classes: Screening, Triage

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(input: [], context: [], output: [], on_error: :allow, cache: true) ⇒ Engine

Returns a new instance of Engine.

Raises:

  • (ArgumentError)


30
31
32
33
34
35
36
37
38
# File 'lib/vangrail/engine.rb', line 30

def initialize(input: [], context: [], output: [], on_error: :allow, cache: true)
  @input_rails = Array(input)
  @context_rails = Array(context)
  @output_rails = Array(output)
  @on_error = on_error.to_sym
  raise ArgumentError, 'on_error must be :allow or :block' unless %i[allow block].include?(@on_error)

  @cache = cache.is_a?(ResultCache) ? cache : (ResultCache.new if cache)
end

Instance Attribute Details

#cacheObject (readonly)

Returns the value of attribute cache.



28
29
30
# File 'lib/vangrail/engine.rb', line 28

def cache
  @cache
end

#context_railsObject (readonly)

Returns the value of attribute context_rails.



28
29
30
# File 'lib/vangrail/engine.rb', line 28

def context_rails
  @context_rails
end

#input_railsObject (readonly)

Returns the value of attribute input_rails.



28
29
30
# File 'lib/vangrail/engine.rb', line 28

def input_rails
  @input_rails
end

#on_errorObject (readonly)

Returns the value of attribute on_error.



28
29
30
# File 'lib/vangrail/engine.rb', line 28

def on_error
  @on_error
end

#output_railsObject (readonly)

Returns the value of attribute output_rails.



28
29
30
# File 'lib/vangrail/engine.rb', line 28

def output_rails
  @output_rails
end

Instance Method Details

#assess(text, side: :input, prior: nil, policy: Policy::DEFAULT, evidence: EvidenceData::TABLE, escalate: false, confidence: nil, origin: nil, **context) ⇒ Object

How likely is it that this text is an attack, given everything that ran.

check_input and friends answer a different question. They run the rails in order, stop at the first block, and report a decision; that is the right shape for a request path and it is what every published defence does. It also throws away most of what was measured. A rail that fired tells you nothing about how much that hit is worth, three rails that nearly fired tell you nothing at all, and a block carries no number an operator can set a policy against.

This runs every rail that has a measured operating point, treats each verdict as evidence, and combines it with the deployment's base rate. What comes back is a probability, the bits each rail contributed, and an action under a stated policy.

The prior is not optional and has no sensible default. Detector papers report their numbers on balanced corpora, where an attack is half the traffic; a documentation desk over an editable wiki might see one poisoned page in ten thousand. Those two worlds disagree about what a hit means by four orders of magnitude, and only the deployment knows which one it is in. Guessing on its behalf would be the whole error this method exists to expose.

judgement = engine.assess(page, side: :context, prior: 1e-4)
judgement.posterior    # => 0.0073
judgement.action       # => :review
judgement.fired        # => [{rail: "paraphrase", bits: 6.2, ...}]

Costs more than a check, because nothing short-circuits: every rail with an entry in the table runs, including the ones a block would have skipped.

Raises:

  • (ArgumentError)


133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/vangrail/engine.rb', line 133

def assess(text, side: :input, prior: nil, policy: Policy::DEFAULT, evidence: EvidenceData::TABLE,
           escalate: false, confidence: nil, origin: nil, **context)
  raise ArgumentError, prior_message if prior.nil?

  origin = Origin.coerce(origin || Origin.default_for(side))
  observed = observe(text, side, context, evidence, escalate ? { prior: prior, policy: policy } : nil)
  observations, direct, certain, skipped = observed
  posterior, contributions = Posterior.combine(prior: prior, observations: observations,
                                               evidence: evidence, direct: direct,
                                               confidence: confidence)
  action = policy.action_for(posterior)
  # A confidence bound is what this corpus can defend. If the point
  # estimate and the bound disagree about the action, the action is
  # not identified: reporting it as a certain decision would spend
  # the unmeasured tail of 48 benign pages.
  if confidence
    point, = Posterior.combine(prior: prior, observations: observations,
                               evidence: evidence, direct: direct)
    certain &&= policy.action_for(point) == action
  end
  Judgement.new(posterior: posterior, prior: prior, bits: contributions.sum { |c| c[:bits] },
                contributions: contributions, certain: certain, side: side.to_sym,
                skipped: skipped, action: action, origin: origin)
end

#check_context(text, **context) ⇒ Object

One retrieved document, before it goes anywhere near a prompt.



49
50
51
# File 'lib/vangrail/engine.rb', line 49

def check_context(text, **context)
  run(:context, context_rails, text, context)
end

#check_input(text, context = {}) ⇒ Object



40
41
42
# File 'lib/vangrail/engine.rb', line 40

def check_input(text, context = {})
  run(:input, input_rails, text, context)
end

#check_output(text, user_input: nil, passages: nil, **context) ⇒ Object



44
45
46
# File 'lib/vangrail/engine.rb', line 44

def check_output(text, user_input: nil, passages: nil, **context)
  run(:output, output_rails, text, context.merge(user_input: user_input, passages: passages))
end

#describeObject



246
247
248
249
250
251
252
253
254
255
256
# File 'lib/vangrail/engine.rb', line 246

def describe
  return 'no rails' if empty?

  parts = []
  parts << "input=#{rail_names(:input).join('+')}" unless input_rails.empty?
  parts << "context=#{rail_names(:context).join('+')}" unless context_rails.empty?
  parts << "output=#{rail_names(:output).join('+')}" unless output_rails.empty?
  parts << "on_error=#{on_error}"
  parts << 'offline' if offline?
  parts.join(' ')
end

#empty?Boolean

Returns:

  • (Boolean)


231
232
233
# File 'lib/vangrail/engine.rb', line 231

def empty?
  input_rails.empty? && context_rails.empty? && output_rails.empty?
end

#offline?Boolean

True when every configured rail decides without a network call, which is the only case where an unreachable endpoint cannot weaken the check.

Returns:

  • (Boolean)


226
227
228
229
# File 'lib/vangrail/engine.rb', line 226

def offline?
  all = input_rails + context_rails + output_rails
  !all.empty? && all.all?(&:offline?)
end

#rail_names(side) ⇒ Object



220
221
222
# File 'lib/vangrail/engine.rb', line 220

def rail_names(side)
  rails(side).map(&:name)
end

#rails(side) ⇒ Object



212
213
214
215
216
217
218
# File 'lib/vangrail/engine.rb', line 212

def rails(side)
  case side.to_sym
  when :input then input_rails
  when :context then context_rails
  else output_rails
  end
end

#screen(documents, **context) ⇒ Object

Screens a set of retrieved documents and reports what survived.

A document that fails is dropped rather than failing the whole turn. One poisoned wiki page should cost a reader that page, not their answer, and an application that refuses outright teaches its readers that the guardrail is the problem.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/vangrail/engine.rb', line 59

def screen(documents, **context)
  kept = []
  rejected = []
  uncertain = nil

  Array(documents).each_with_index do |document, index|
    result = check_context(text_of(document), **context, document: document, index: index)
    uncertain ||= result unless result.certain?
    if result.blocked?
      rejected << { document: document, result: result }
    else
      kept << (result.modified? ? replace_text(document, result.content) : document)
    end
  end

  Screening.new(kept: kept, rejected: rejected, certain: uncertain.nil?, reason: uncertain&.reason)
end

#to_hObject



235
236
237
238
239
240
241
242
243
244
# File 'lib/vangrail/engine.rb', line 235

def to_h
  {
    'input' => rail_names(:input),
    'context' => (rail_names(:context) unless context_rails.empty?),
    'output' => rail_names(:output),
    'on_error' => on_error.to_s,
    'offline' => offline?,
    'cache' => cache&.to_h,
  }.compact
end

#triage(documents, prior:, policy: Policy::DEFAULT, escalate: false, **context) ⇒ Object

Screening, with the documents ranked by how suspicious they are rather than partitioned by whether one rail objected.

screen drops a document the moment a rail blocks it, which is the right shape when a rail is a switch. Given a posterior there is a better answer available: rank the set, drop what the policy says to drop, hand what it says to review to whoever reviews, and keep the rest. A page that trips one pattern at a base rate of one in ten thousand is not a page worth taking away from a reader, and it is worth putting at the bottom of the passage list.

triage = engine.triage(documents, prior: 1e-4)
triage.keep       # documents, least suspicious first
triage.review     # [{document:, judgement:}]
triage.dropped    # [{document:, judgement:}]


173
174
175
176
177
178
179
180
# File 'lib/vangrail/engine.rb', line 173

def triage(documents, prior:, policy: Policy::DEFAULT, escalate: false, **context)
  judged = Array(documents).each_with_index.map do |document, index|
    judgement = assess(text_of(document), side: :context, prior: prior, policy: policy,
                                          escalate: escalate, **context, document: document, index: index)
    { document: document, judgement: judgement }
  end
  Triage.new(judged: judged.sort_by { |row| -row[:judgement].posterior })
end