Class: Vangrail::Session
- Inherits:
-
Object
- Object
- Vangrail::Session
- 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
-
#alpha ⇒ Object
readonly
Returns the value of attribute alpha.
-
#attack ⇒ Object
readonly
Returns the value of attribute attack.
-
#beta ⇒ Object
readonly
Returns the value of attribute beta.
-
#channel ⇒ Object
readonly
Returns the value of attribute channel.
-
#contamination ⇒ Object
readonly
Returns the value of attribute contamination.
-
#decay ⇒ Object
readonly
Returns the value of attribute decay.
-
#engine ⇒ Object
readonly
Returns the value of attribute engine.
-
#evidence ⇒ Object
readonly
Returns the value of attribute evidence.
-
#policy ⇒ Object
readonly
Returns the value of attribute policy.
-
#prior ⇒ Object
readonly
Returns the value of attribute prior.
Instance Method Summary collapse
- #action(channel = nil) ⇒ Object
- #allow?(channel = nil) ⇒ Boolean
-
#bits(channel = nil) ⇒ Object
How far the session sits from where it started, in bits.
-
#bits_to_decide(channel = nil) ⇒ Object
How much more evidence the test needs before it can decide, in bits.
-
#block?(channel = nil) ⇒ Boolean
Without a name this is true if either populated track would block.
-
#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.
- #cusum(channel = nil) ⇒ Object
-
#fold(event, origin: nil, side: nil) ⇒ Object
Folds a judgement, or a Result from a walk that already ran.
-
#initialize(engine:, prior:, decay: DEFAULT_DECAY, policy: Policy::DEFAULT, alpha: 0.01, beta: 0.05, evidence: nil) ⇒ Session
constructor
alphaandbetaare 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. - #log_odds(channel = nil) ⇒ Object
- #lower_threshold ⇒ Object
-
#observe(text, side: :input, origin: nil, **context) ⇒ Object
Judges one turn and folds it into the session.
- #posterior(channel = nil) ⇒ Object
-
#quarantined ⇒ Object
Turns that landed on the other rank.
- #review?(channel = nil) ⇒ Boolean
-
#shift?(channel = nil) ⇒ Boolean
True when the recent burst of attack-direction evidence has reached the same bar Wald uses for the accumulated total.
- #to_h ⇒ Object
- #to_s ⇒ Object
- #turns(channel = nil) ⇒ Object
- #upper_threshold ⇒ Object
-
#verdict(channel = nil) ⇒ Object
Wald's sequential test over the same accumulated evidence.
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.
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
#alpha ⇒ Object (readonly)
Returns the value of attribute alpha.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def alpha @alpha end |
#attack ⇒ Object (readonly)
Returns the value of attribute attack.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def attack @attack end |
#beta ⇒ Object (readonly)
Returns the value of attribute beta.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def beta @beta end |
#channel ⇒ Object (readonly)
Returns the value of attribute channel.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def channel @channel end |
#contamination ⇒ Object (readonly)
Returns the value of attribute contamination.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def contamination @contamination end |
#decay ⇒ Object (readonly)
Returns the value of attribute decay.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def decay @decay end |
#engine ⇒ Object (readonly)
Returns the value of attribute engine.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def engine @engine end |
#evidence ⇒ Object (readonly)
Returns the value of attribute evidence.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def evidence @evidence end |
#policy ⇒ Object (readonly)
Returns the value of attribute policy.
83 84 85 |
# File 'lib/vangrail/session.rb', line 83 def policy @policy end |
#prior ⇒ Object (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
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.
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.
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_threshold ⇒ Object
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)) = evidence ? { evidence: evidence } : {} judgement = engine.assess(text, side: side, prior: prior, policy: policy, origin: origin, **, **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 |
#quarantined ⇒ Object
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
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.
239 240 241 |
# File 'lib/vangrail/session.rb', line 239 def shift?(channel = nil) cusum(channel) >= upper_threshold end |
#to_h ⇒ Object
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_s ⇒ Object
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_threshold ⇒ Object
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 |