Class: Vangrail::Session

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

Overview

The posterior over a session rather than over a message.

Every check in this gem, and every detector in the published work, judges one string and forgets it. That is the wrong shape for the attack family that actually gets through a desk: ask something harmless, ask for more detail about the part of the answer that helped, keep going. No message in that sequence is an attack, which is why per-message detection is blind to it, and Rails::Escalation only catches the crude version where a refusal is followed by a retry.

Read as evidence, the sequence is the obvious case. Three turns that each move the odds by two bits have moved them by six, and a reader whose every question is unremarkable but slightly odd looks exactly like what they are: unlikely, three times over. Nothing about that needs a new detector. It needs the arithmetic to carry across turns, which is one multiplication.

Two things keep it from becoming a session that eventually blocks everyone.

Evidence decays. Between turns the excess over the prior is multiplied by decay, so a session's posture reflects recent behaviour rather than everything since login. This is the standard forgetting factor of sequential inference with drift, and the drift here is real: the person asking is allowed to change what they are doing, and a reader who asked one odd question an hour ago is not a suspect.

Ordinary turns push back. A clean turn contributes the silence of every rail that ran, which is negative evidence, so a session recovers rather than only ratcheting. A reader who trips one rail and then asks twenty normal questions ends where they started.

session = Vangrail::Session.new(engine: engine, prior: 1e-3)
session.observe(question)          # => Judgement for the turn
session.posterior                  # => the session's, not the turn's
session.action                     # => :allow, :review, :block

After a retrieved page or an answer, both tracks have turns. Unnamed posterior and action raise then. Name the channel:

session.posterior(:attack)
session.posterior(:contamination)
session.block?                     # true if either track would block

The per-turn judgement is still returned, because both numbers are real and they answer different questions. "Is this message an attack" is what a request path routes on. "Is this session an attack" is what a desk wants before it decides whether a reader is probing it.

Defined Under Namespace

Classes: Track

Constant Summary collapse

DEFAULT_DECAY =

How much of the accumulated excess survives to the next turn. At 0.6, two bits of suspicion are worth about one and a quarter after one ordinary turn and a third of a bit after four, so a single odd question fades in a handful of turns while a pattern of them does not.

0.6

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(engine:, prior:, decay: DEFAULT_DECAY, policy: Policy::DEFAULT, alpha: 0.01, beta: 0.05, evidence: nil) ⇒ Session

alpha and beta are the error rates a sequential test is allowed: how often it may call an ordinary reader an attacker, and how often it may miss one. Given those two numbers the thresholds are not a choice, which is the whole appeal of the sequential test.

Raises:

  • (ArgumentError)


90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/vangrail/session.rb', line 90

def initialize(engine:, prior:, decay: DEFAULT_DECAY, policy: Policy::DEFAULT,
               alpha: 0.01, beta: 0.05, evidence: nil)
  raise ArgumentError, 'prior must be strictly between 0 and 1' unless prior.positive? && prior < 1
  raise ArgumentError, 'decay must be in (0, 1]' unless decay.positive? && decay <= 1
  raise ArgumentError, 'alpha and beta must be in (0, 1)' unless [alpha, beta].all? { |v| v.positive? && v < 1 }

  @engine = engine
  @prior = prior
  @decay = decay
  @policy = policy
  @alpha = alpha
  @beta = beta
  base = Math.log2(Posterior.to_odds(prior))
  @attack = Track.new(base)
  @contamination = Track.new(base)
  @channel = nil
  # An operating point given outright, for a caller measuring the
  # arithmetic rather than the shipped corpus.
  @evidence = evidence
end

Instance Attribute Details

#alphaObject (readonly)

Returns the value of attribute alpha.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def alpha
  @alpha
end

#attackObject (readonly)

Returns the value of attribute attack.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def attack
  @attack
end

#betaObject (readonly)

Returns the value of attribute beta.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def beta
  @beta
end

#channelObject (readonly)

Returns the value of attribute channel.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def channel
  @channel
end

#contaminationObject (readonly)

Returns the value of attribute contamination.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def contamination
  @contamination
end

#decayObject (readonly)

Returns the value of attribute decay.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def decay
  @decay
end

#engineObject (readonly)

Returns the value of attribute engine.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def engine
  @engine
end

#evidenceObject (readonly)

Returns the value of attribute evidence.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def evidence
  @evidence
end

#policyObject (readonly)

Returns the value of attribute policy.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def policy
  @policy
end

#priorObject (readonly)

Returns the value of attribute prior.



83
84
85
# File 'lib/vangrail/session.rb', line 83

def prior
  @prior
end

Instance Method Details

#action(channel = nil) ⇒ Object



173
174
175
# File 'lib/vangrail/session.rb', line 173

def action(channel = nil)
  policy.action_for(posterior(channel))
end

#allow?(channel = nil) ⇒ Boolean

Returns:

  • (Boolean)


189
190
191
# File 'lib/vangrail/session.rb', line 189

def allow?(channel = nil)
  action(channel) == :allow
end

#bits(channel = nil) ⇒ Object

How far the session sits from where it started, in bits. The readable summary: zero is an ordinary session, and positive is a reader who keeps doing things that ordinary readers do not.



169
170
171
# File 'lib/vangrail/session.rb', line 169

def bits(channel = nil)
  named_track(channel).bits(prior)
end

#bits_to_decide(channel = nil) ⇒ Object

How much more evidence the test needs before it can decide, in bits.



229
230
231
232
233
234
# File 'lib/vangrail/session.rb', line 229

def bits_to_decide(channel = nil)
  return 0.0 unless verdict(channel) == :undecided

  score = bits(channel)
  [upper_threshold - score, score - lower_threshold].min
end

#block?(channel = nil) ⇒ Boolean

Without a name this is true if either populated track would block. Unnamed posterior and action raise once both tracks have turns.

Returns:

  • (Boolean)


179
180
181
182
183
# File 'lib/vangrail/session.rb', line 179

def block?(channel = nil)
  return action(channel) == :block if channel

  tracks_for_block.any? { |track| policy.action_for(track.posterior) == :block }
end

#certain?Boolean

False as soon as any turn was judged without every rail reaching a decision, because the session's number inherits every gap in the turns that built it.

Returns:

  • (Boolean)


246
247
248
# File 'lib/vangrail/session.rb', line 246

def certain?
  attack.turns.all?(&:certain?) && contamination.turns.all?(&:certain?)
end

#cusum(channel = nil) ⇒ Object



152
153
154
# File 'lib/vangrail/session.rb', line 152

def cusum(channel = nil)
  named_track(channel).cusum
end

#fold(event, origin: nil, side: nil) ⇒ Object

Folds a judgement, or a Result from a walk that already ran. origin and side name the span when the event is a Result; a Judgement already carries both.



136
137
138
139
140
141
142
# File 'lib/vangrail/session.rb', line 136

def fold(event, origin: nil, side: nil)
  judgement = coerce(event, origin: origin, side: side)
  origin = judgement.origin || Origin.default_for(judgement.side || :input)
  @channel ||= origin.channel
  apply(track_for(origin.channel), judgement)
  self
end

#log_odds(channel = nil) ⇒ Object



144
145
146
# File 'lib/vangrail/session.rb', line 144

def log_odds(channel = nil)
  named_track(channel).log_odds
end

#lower_thresholdObject



224
225
226
# File 'lib/vangrail/session.rb', line 224

def lower_threshold
  Math.log2(beta / (1 - alpha))
end

#observe(text, side: :input, origin: nil, **context) ⇒ Object

Judges one turn and folds it into the session.

The turn's own judgement is computed against the session's prior rather than against the session's current posterior, deliberately. Feeding the running posterior back in as the prior would compound the same evidence every turn and reach certainty on a reader who did nothing new; the accumulation belongs in the session's state, not in each turn's premise.

origin defaults from the side: a question is a user span, a retrieved page is data. Privileged origin updates the attack track. Untrusted origin updates contamination. The two numbers never add: a poisoned wiki page cannot accuse a reader, and a reader cannot contaminate a document they did not write.



124
125
126
127
128
129
130
131
# File 'lib/vangrail/session.rb', line 124

def observe(text, side: :input, origin: nil, **context)
  origin = Origin.coerce(origin || Origin.default_for(side))
  options = evidence ? { evidence: evidence } : {}
  judgement = engine.assess(text, side: side, prior: prior, policy: policy,
                            origin: origin, **options, **context)
  fold(judgement)
  judgement
end

#posterior(channel = nil) ⇒ Object



162
163
164
# File 'lib/vangrail/session.rb', line 162

def posterior(channel = nil)
  named_track(channel).posterior
end

#quarantinedObject

Turns that landed on the other rank. They still moved that rank's posterior; they did not move this one.



158
159
160
# File 'lib/vangrail/session.rb', line 158

def quarantined
  other.turns
end

#review?(channel = nil) ⇒ Boolean

Returns:

  • (Boolean)


185
186
187
# File 'lib/vangrail/session.rb', line 185

def review?(channel = nil)
  action(channel) == :review
end

#shift?(channel = nil) ⇒ Boolean

True when the recent burst of attack-direction evidence has reached the same bar Wald uses for the accumulated total. A change of behaviour, not a lifetime score.

Returns:

  • (Boolean)


239
240
241
# File 'lib/vangrail/session.rb', line 239

def shift?(channel = nil)
  cusum(channel) >= upper_threshold
end

#to_hObject



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/vangrail/session.rb', line 250

def to_h
  single = !ambiguous?
  {
    'prior' => prior,
    'posterior' => (posterior.round(6) if single),
    'bits' => (bits.round(2) if single),
    'decay' => decay,
    'turns' => single ? turns.size : attack.turns.size + contamination.turns.size,
    'channel' => channel&.to_s,
    'quarantined' => (quarantined.size unless quarantined.empty?),
    'attack' => track_h(attack),
    'contamination' => track_h(contamination),
    'action' => (action.to_s if single),
    'verdict' => (verdict.to_s if single),
    'cusum' => (cusum.round(2) if single),
    'shift' => (shift? if single),
    'certain' => certain?,
  }.compact
end

#to_sObject



270
271
272
273
274
275
276
277
278
# File 'lib/vangrail/session.rb', line 270

def to_s
  if ambiguous?
    return format('session attack p=%<attack>.4f contamination p=%<data>.4f',
                  attack: attack.posterior, data: contamination.posterior)
  end

  format('session %<action>s p=%<posterior>.4f over %<turns>d turn(s), %<bits>+.1f bits',
         action: action, posterior: posterior, turns: turns.size, bits: bits)
end

#turns(channel = nil) ⇒ Object



148
149
150
# File 'lib/vangrail/session.rb', line 148

def turns(channel = nil)
  named_track(channel).turns
end

#upper_thresholdObject



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

def upper_threshold
  Math.log2((1 - beta) / alpha)
end

#verdict(channel = nil) ⇒ Object

Wald's sequential test over the same accumulated evidence.

The posterior answers "how likely is this"; the sequential test answers a question an operator often prefers: "have I seen enough to decide, at error rates I chose in advance". It is the older machinery, it is what the network-detection work uses for exactly this shape of problem, and it costs nothing extra here because the log-likelihood ratio is already being accumulated.

Two thresholds, both fixed by alpha and beta rather than by taste: accumulate until the evidence passes log((1 - beta) / alpha) and call it an attack, or falls below log(beta / (1 - alpha)) and call it ordinary. In between, the honest answer is that the session has not said enough yet.

Reported beside the posterior rather than instead of it. They answer different questions and disagreeing is informative: a session that the test calls undecided while the policy says review is a session where the cost argument and the error-rate argument point different ways, and somebody should know that.



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

def verdict(channel = nil)
  score = bits(channel)
  return :attack if score >= upper_threshold
  return :benign if score <= lower_threshold

  :undecided
end