Class: Vangrail::Rails::Escalation

Inherits:
Vangrail::Rail show all
Defined in:
lib/vangrail/rails/escalation.rb

Overview

Watches what happens after a refusal.

The multi-turn attacks work because the guardrail forgets. A request is refused, the next message is the same request with the objectionable word removed, and the rail reads it as a fresh question because that is all it has ever been given. Repeat until something gets through. The published multi-turn methods differ in how they choose the rewrite, and they share that one assumption: that turn N+1 is judged without turn N.

So this rail judges the sequence rather than the message. It reads :history from the context, which a Conversation fills in, and it has exactly two things to say:

retry_after_refusal   the last question was refused, and this one is
                    that question again: mostly the same words, or a
                    bare reference back to it, or a reframing opener
                    ("hypothetically", "just for research") on top of
                    it
repeated_refusals     several refusals in a short window, whatever this
                    particular message says

Both are cheap and neither is clever. A caller that never passes :history gets a pass with an honest certain? of false, because a rail that reads history and was handed none has not checked anything. A caller that passes an empty one gets a certain pass: an empty dialogue is an answer rather than a missing one.

The limit is worth stating: a genuine crescendo never triggers a refusal at all until the last turn, and this rail sees nothing until one happens. It raises the cost of the cheap version of the attack, where the attacker probes until something lands. Judging a dialogue that has never been refused needs a model reading the trajectory, which is a different rail and a round trip.

Constant Summary collapse

REFERENCE_BACK =

A retry does not have to be a paraphrase. It can be a pointer.

/
  \A[^.?!]{0,60}\b(?:as\s+i\s+(?:said|asked|mentioned)|like\s+i\s+(?:said|asked)|
     (?:the|my)\s+(?:previous|last|earlier)\s+(?:question|request|message)|
     try\s+again|answer\s+(?:it|that|the\s+question)\s+anyway|
     just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue)\b
/xi
REFRAMING =

The openers that exist to relabel a refused request as something else.

/
  \b(?:hypothetically|in\s+theory|for\s+(?:a\s+)?(?:friend|research|a\s+paper|
     educational\s+purposes|academic\s+purposes)|purely\s+(?:academic|hypothetical)|
     what\s+if\s+i\s+(?:told\s+you|said)|imagine\s+(?:that\s+)?you|
     let\s+me\s+rephrase|to\s+(?:re)?phrase\s+(?:it|that)\s+differently|
     you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked))\b
/xi
STOP =
%w[
  the a an and or but is are was were be been being to of in on at for with
  from by as it its this that these those i you he she they we me my your do
  does did how what why when where can could would should will shall may
  might must not no yes if then than so about into over under please
].freeze

Constants inherited from Vangrail::Rail

Vangrail::Rail::DEFAULT_SIDES, Vangrail::Rail::SIDES

Instance Attribute Summary collapse

Attributes inherited from Vangrail::Rail

#name, #sides

Instance Method Summary collapse

Methods inherited from Vangrail::Rail

#applies_to?, #placeholder?, #to_s

Constructor Details

#initialize(overlap: 0.6, window: 6, tolerance: 2, name: 'escalation', sides: [:input]) ⇒ Escalation

overlap is the share of this question's content words that also appeared in the refused one. Three fifths is where the corpus put it: a rewrite keeps the nouns and changes the verb, so it lands near two thirds, while a genuine follow-up on the same subject shares one or two words out of seven. Higher and the measured rewrites walk through; lower and one refusal makes the topic unaskable, which ends the conversation rather than the attack.



74
75
76
77
78
79
# File 'lib/vangrail/rails/escalation.rb', line 74

def initialize(overlap: 0.6, window: 6, tolerance: 2, name: 'escalation', sides: [:input])
  super(name: name, sides: sides)
  @overlap = overlap
  @window = window
  @tolerance = tolerance
end

Instance Attribute Details

#overlapObject (readonly)

Returns the value of attribute overlap.



65
66
67
# File 'lib/vangrail/rails/escalation.rb', line 65

def overlap
  @overlap
end

#toleranceObject (readonly)

Returns the value of attribute tolerance.



65
66
67
# File 'lib/vangrail/rails/escalation.rb', line 65

def tolerance
  @tolerance
end

#windowObject (readonly)

Returns the value of attribute window.



65
66
67
# File 'lib/vangrail/rails/escalation.rb', line 65

def window
  @window
end

Instance Method Details

#cache_key(_text, _context) ⇒ Object

Not memoizable: the same question means different things depending on what came before it, which is the entire premise of the rail.



87
88
89
# File 'lib/vangrail/rails/escalation.rb', line 87

def cache_key(_text, _context)
  nil
end

#call(text, context) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/vangrail/rails/escalation.rb', line 91

def call(text, context)
  # A caller that never passes :history is not threading a dialogue, and
  # this rail has not checked anything: say so. A caller that passes an
  # empty one is threading a dialogue that has just started, which is a
  # real answer rather than a missing one. The distinction matters
  # because an uncertain pass here would otherwise be the first uncertain
  # result in every single-turn engine, and would mask the reason a
  # model rail actually failed.
  return unchecked('no history was provided, so nothing was compared') unless context.key?(:history)

  history = Array(context[:history])
  return pass if history.empty?

  refused = history.select { |t| user?(t) && t[:blocked] }
  return pass if refused.empty?

  recent = history.last(window).count { |t| user?(t) && t[:blocked] }
  if recent > tolerance
    return block(categories: ['repeated_refusals'],
                 reason: "#{recent} refused questions in the last #{window} turns")
  end

  retry_of(text.to_s, refused.last)
end

#offline?Boolean

Returns:

  • (Boolean)


81
82
83
# File 'lib/vangrail/rails/escalation.rb', line 81

def offline?
  true
end