not-a-robot 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. not_a_robot-0.1.0/LICENSE +21 -0
  2. not_a_robot-0.1.0/PKG-INFO +365 -0
  3. not_a_robot-0.1.0/README.md +337 -0
  4. not_a_robot-0.1.0/pyproject.toml +42 -0
  5. not_a_robot-0.1.0/setup.cfg +4 -0
  6. not_a_robot-0.1.0/src/not_a_robot/__init__.py +47 -0
  7. not_a_robot-0.1.0/src/not_a_robot/autoretrain.py +231 -0
  8. not_a_robot-0.1.0/src/not_a_robot/detector.py +86 -0
  9. not_a_robot-0.1.0/src/not_a_robot/features/__init__.py +15 -0
  10. not_a_robot-0.1.0/src/not_a_robot/features/clicks.py +47 -0
  11. not_a_robot-0.1.0/src/not_a_robot/features/engagement.py +25 -0
  12. not_a_robot-0.1.0/src/not_a_robot/features/enrichment.py +49 -0
  13. not_a_robot-0.1.0/src/not_a_robot/features/mouse.py +118 -0
  14. not_a_robot-0.1.0/src/not_a_robot/features/scroll.py +59 -0
  15. not_a_robot-0.1.0/src/not_a_robot/features/timing.py +49 -0
  16. not_a_robot-0.1.0/src/not_a_robot/io.py +90 -0
  17. not_a_robot-0.1.0/src/not_a_robot/pipeline.py +718 -0
  18. not_a_robot-0.1.0/src/not_a_robot/schema.py +82 -0
  19. not_a_robot-0.1.0/src/not_a_robot/session.py +84 -0
  20. not_a_robot-0.1.0/src/not_a_robot/train.py +212 -0
  21. not_a_robot-0.1.0/src/not_a_robot.egg-info/PKG-INFO +365 -0
  22. not_a_robot-0.1.0/src/not_a_robot.egg-info/SOURCES.txt +35 -0
  23. not_a_robot-0.1.0/src/not_a_robot.egg-info/dependency_links.txt +1 -0
  24. not_a_robot-0.1.0/src/not_a_robot.egg-info/requires.txt +6 -0
  25. not_a_robot-0.1.0/src/not_a_robot.egg-info/top_level.txt +1 -0
  26. not_a_robot-0.1.0/tests/test_autoretrain.py +107 -0
  27. not_a_robot-0.1.0/tests/test_click_features.py +21 -0
  28. not_a_robot-0.1.0/tests/test_detector.py +50 -0
  29. not_a_robot-0.1.0/tests/test_engagement_features.py +18 -0
  30. not_a_robot-0.1.0/tests/test_enrichment.py +28 -0
  31. not_a_robot-0.1.0/tests/test_evaluate_cv.py +126 -0
  32. not_a_robot-0.1.0/tests/test_io.py +47 -0
  33. not_a_robot-0.1.0/tests/test_mouse_features.py +40 -0
  34. not_a_robot-0.1.0/tests/test_pipeline.py +43 -0
  35. not_a_robot-0.1.0/tests/test_scroll_features.py +21 -0
  36. not_a_robot-0.1.0/tests/test_session.py +23 -0
  37. not_a_robot-0.1.0/tests/test_timing_features.py +31 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 not-a-robot project authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.4
2
+ Name: not-a-robot
3
+ Version: 0.1.0
4
+ Summary: Behavioral-telemetry feature extraction and ML scoring for a bot-verification system you run yourself.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/sergeyitaly/not_a_robot
7
+ Project-URL: Issues, https://github.com/sergeyitaly/not_a_robot/issues
8
+ Keywords: bot-detection,behavioral-biometrics,captcha,fraud-detection,machine-learning
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: scikit-learn>=1.3
24
+ Requires-Dist: joblib>=1.3
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.4; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # not-a-robot
30
+
31
+ A Python library for building the **detector side** of a "prove you're not a
32
+ robot" check: it extracts behavioral-telemetry features (mouse-movement
33
+ dynamics, keystroke timing, overall pacing) from an interaction session and
34
+ trains an ML classifier that scores how human-like the session looks.
35
+
36
+ This is meant to run on infrastructure you control, as one signal alongside
37
+ your own verification flow — not to defeat verification on someone else's
38
+ site. See [Scope](#scope) below.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install -e ".[dev]"
44
+ ```
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from not_a_robot import BotDetector, InteractionSession, MouseEvent, KeyEvent
50
+
51
+ # Sessions you've captured and labeled from your own application.
52
+ # label=True for a known-human session, label=False for a known-bot session.
53
+ sessions = [
54
+ InteractionSession(
55
+ mouse_events=[MouseEvent(x=10, y=12, t=0), MouseEvent(x=14, y=20, t=35), ...],
56
+ key_events=[KeyEvent(t_down=500, t_up=560), ...],
57
+ page_load_t=0.0,
58
+ submit_t=4200.0,
59
+ label=True,
60
+ ),
61
+ # ... more labeled sessions ...
62
+ ]
63
+
64
+ detector = BotDetector()
65
+ detector.fit(sessions)
66
+ detector.save("bot_detector.joblib")
67
+
68
+ # Later, score a new session:
69
+ detector = BotDetector.load("bot_detector.joblib")
70
+ p_human = detector.score(new_session) # float in [0, 1]
71
+ is_human = detector.predict(new_session) # bool at the default 0.5 threshold
72
+ ```
73
+
74
+ Run the end-to-end example (uses synthetic data, see below) from the repo
75
+ root:
76
+
77
+ ```bash
78
+ python -m examples.quickstart
79
+ ```
80
+
81
+ ## What it extracts
82
+
83
+ - **Mouse dynamics** (`not_a_robot.features.mouse`): path length vs.
84
+ straight-line distance ("path efficiency"), velocity/acceleration/jerk
85
+ statistics, turning-angle statistics, direction reversals, pause count.
86
+ - **Timing / keystroke dynamics** (`not_a_robot.features.timing`): dwell
87
+ time (key down -> up), flight time (key up -> next key down), time to
88
+ first interaction, time to submit.
89
+ - **Scroll behavior** (`not_a_robot.features.scroll`): total distance,
90
+ direction reversals, interval and delta statistics.
91
+ - **Click/tap behavior** (`not_a_robot.features.clicks`): click count,
92
+ interval statistics, position variance (scripted clicks tend to land on
93
+ the exact same pixel repeatedly).
94
+ - **Tab-focus and paste behavior** (`not_a_robot.features.engagement`):
95
+ blur/refocus count, paste count and total pasted characters.
96
+ - **Enrichment ratios** (`not_a_robot.features.enrichment`): coefficients
97
+ of variation and per-second rates derived from the feature groups above
98
+ (e.g. `mouse_velocity_cv`, `key_rate_per_sec`, `scroll_rate_per_sec`,
99
+ `typed_vs_pasted_ratio`), which normalize for session length/typing
100
+ speed and tend to separate scripted, uniform behavior from naturally
101
+ variable human behavior better than any single raw statistic.
102
+
103
+ Every field on `InteractionSession` (`mouse_events`, `key_events`,
104
+ `scroll_events`, `click_events`, `focus_events`, `paste_events`) is
105
+ optional and defaults to empty — you don't have to capture all of them to
106
+ use the library, but the more of them you wire up client-side, the more
107
+ signal the detector has to work with.
108
+
109
+ All features are combined into one fixed-order vector
110
+ (`not_a_robot.session.FEATURE_NAMES`) that feeds a scikit-learn classifier
111
+ (`RandomForestClassifier` by default — pass your own via `BotDetector(model=...)`).
112
+
113
+ ## Training pipeline and success-rate validation
114
+
115
+ There are three evaluation paths, answering three different questions.
116
+
117
+ **`run_training_pipeline()`** fits the detector you'd actually deploy: one
118
+ stratified train/test split, fit on train, evaluated once on test. Useful
119
+ for producing a model + a quick report, but its metrics are a **single
120
+ point estimate** — on a dataset in the hundreds of sessions, one 75/25
121
+ split can look meaningfully better or worse than another from sampling
122
+ luck alone, before the model is even a variable.
123
+
124
+ **`evaluate_cv()`** runs repeated stratified k-fold CV (`n_splits x
125
+ n_repeats` independent folds, default 5x10=50) on *one* sample of
126
+ sessions, and reports mean +/- std per metric, **recall pooled by
127
+ `InteractionSession.group`** with a Wilson 95% confidence interval (not a
128
+ mean/std of per-fold rates — a rare group can have 0-2 members in a given
129
+ fold, where std is close to meaningless; pooling raw hit/total counts
130
+ across all folds is the number that's actually defensible), and the
131
+ **cost-optimal decision threshold** for a stated false-accept-vs-reject
132
+ cost ratio, with per-group recall at that threshold instead of just the
133
+ classifier's default 0.5 cut.
134
+
135
+ **`summarize_across_seeds()`** (CLI: `--seeds 0,1,2,3`) is the one to
136
+ actually quote. Repeated CV within one seed only captures fold-partition
137
+ variance — every fold in that run shares the same 400 sessions. Running
138
+ `evaluate_cv` at several seeds and pooling exposes the variance that
139
+ matters: how much the numbers move when the *sample itself* changes.
140
+
141
+ ```python
142
+ from not_a_robot import run_training_pipeline, evaluate_cv, summarize_across_seeds
143
+
144
+ detector, report = run_training_pipeline(sessions, data_source="prod-2026-09")
145
+ detector.save("bot_detector.joblib")
146
+
147
+ cv_report = evaluate_cv(sessions, data_source="prod-2026-09")
148
+ print(cv_report.summary())
149
+ ```
150
+
151
+ From the command line, against a real captured session log:
152
+
153
+ ```bash
154
+ python -m not_a_robot.train --data sessions.jsonl --model-out bot_detector.joblib --report-out report.json
155
+ python -m not_a_robot.train --data sessions.jsonl --seeds 0,1,2,3 # the defensible report
156
+ ```
157
+
158
+ `--synthetic` runs the same pipeline against the bundled demo dataset (see
159
+ below) so you can see a real, computed report before you have real traffic:
160
+
161
+ ```bash
162
+ python -m not_a_robot.train --synthetic --n-per-class 200 --seeds 0,1,2,3
163
+ ```
164
+
165
+ That produced (**1,600 sessions total**: 400/seed x 4 seeds, 5-fold x
166
+ 10-repeat CV per seed, full feature set, `c_fa=10 : c_fr=1` for the
167
+ cost-optimal threshold, `BotDetector`'s calibrated default model — see
168
+ below):
169
+
170
+ ```
171
+ seed accuracy human pass bot catch FAR FRR
172
+ 0 92.5% 92.9% 92.1% 7.9% 7.1%
173
+ 1 96.0% 98.2% 93.7% 6.3% 1.8%
174
+ 2 94.7% 95.8% 93.6% 6.4% 4.2%
175
+ 3 94.4% 96.5% 92.3% 7.7% 3.5%
176
+
177
+ Bot catch rate range across seeds: 92.1% - 93.7% <- the honest operating characteristic
178
+
179
+ Combined per-group recall (pooled across all seeds, Wilson 95% CI):
180
+ group weight n recall [95% CI]
181
+ human 50.0% 8000 95.9% [95.4%-96.3%]
182
+ naive 21.9% 3500 100.0% [99.9%-100.0%]
183
+ evasive 17.2% 2760 100.0% [99.9%-100.0%]
184
+ headless 5.5% 880 100.0% [99.6%-100.0%]
185
+ sophisticated 5.4% 860 34.3% [31.2%-37.5%]
186
+ ```
187
+
188
+ **Read it as:** bot catch rate is stable at 92-94% across resamples, not a
189
+ single point estimate. `naive`/`evasive`/`headless` are caught at ~100%
190
+ with a tight interval (n in the thousands, pooled). `sophisticated` is
191
+ caught at 34.3% [31.2-37.5%] pooled — but **per-seed it ranges 10.7% to
192
+ 54.3%**, a ~40-point spread the pooled interval doesn't show on its own.
193
+ That per-seed spread, not the pooled point estimate, is the honest
194
+ finding about this group: the only signal separating it from humans is
195
+ the scroll/click/engagement channels, and it's weak enough that which
196
+ seed the model happens to train on visibly changes how much of it gets
197
+ caught. **Do not treat any single seed's `sophisticated` recall as an
198
+ estimate of real-world performance against mimicry bots** — not the
199
+ 54.3% from seed 2, and not the pooled 34.3% either, without also carrying
200
+ that per-seed range.
201
+
202
+ **Calibration, and what it did and didn't fix.** `BotDetector`'s default
203
+ model wraps its `RandomForestClassifier` in `CalibratedClassifierCV`
204
+ (isotonic) — a raw random forest's `predict_proba` is a vote fraction,
205
+ not a real probability, and a reliability check on the raw model showed
206
+ the predicted-vs-observed relationship breaking down badly in a sparse
207
+ mid-range (a handful of test sessions per 0.1-wide probability bin, not
208
+ tracking the observed human fraction there) while a real, if partial,
209
+ overlap between `sophisticated` bots and humans sits in exactly that
210
+ region. Calibrating **did** meaningfully improve default-threshold
211
+ `sophisticated` recall (23.7% pooled before calibration -> 34.3% after)
212
+ and nudged overall bot catch rate up a couple points. It did **not**,
213
+ however, change the cost-curve behavior at `c_fa=10:c_fr=1`: the
214
+ cost-optimal threshold is still 0.85-0.89 across seeds, with FAR pushed
215
+ to ~0% at the cost of a 13-16% false reject rate on real humans, both
216
+ before and after calibration. That similarity is itself informative: it
217
+ means that behavior was never primarily a calibration artifact — it's
218
+ what a 10:1 cost ratio actually does when `sophisticated` bots and a
219
+ minority of real humans (the ones who also don't scroll, blur, or paste
220
+ in a given session) genuinely overlap in score. **Whether trading a
221
+ ~1-in-7 real-user rejection rate for catching most `sophisticated` bots
222
+ is worth it depends entirely on your own false-accept-vs-reject cost,
223
+ which is why `cost_fa`/`cost_fr` are parameters, not constants** — the
224
+ 10:1 default here is illustrative, not a recommendation; pass
225
+ `--cost-fa`/`--cost-fr` with your actual deployment's asymmetry (a login
226
+ form and a comment form do not have the same one), and don't ship the
227
+ cost-optimal threshold without deciding you actually want that trade.
228
+
229
+ **`--drop-keys` ablation** (excludes keystroke-timing features, simulating
230
+ a mouse-only capture surface): removing them barely moved anything — bot
231
+ catch rate range 91.6-94.2% (vs. 92.1-93.7% with keys), combined
232
+ `sophisticated` recall 34.2% [31.1-37.4%] (vs. 34.3% with keys),
233
+ statistically indistinguishable. This holds both before and after
234
+ calibration, and contradicts what the single-split top-feature-importance
235
+ list suggested earlier (keystroke features ranked highest) — that ranking
236
+ reflected `naive`/`evasive` separability, not what actually separates
237
+ `sophisticated`. The reason is in the generator: `sophisticated` reuses
238
+ the human archetype's keystroke timing *and* mouse trajectory exactly, so
239
+ neither channel ever carried separating signal against it — only the
240
+ scroll/click/engagement features it doesn't fake do. Keystroke timing
241
+ helps separate `naive`/`evasive` (which fake it badly), but mouse
242
+ geometry alone already separates those too, so dropping keys is
243
+ redundant there, not costly. **The lesson isn't "keystroke timing matters
244
+ most" — it's "the channels a specific bot doesn't bother faking are what
245
+ catch it," a property of the bot, not of any one feature group.** Run
246
+ this against your own real data before assuming it transfers; a real
247
+ mouse-only capture surface (e.g. a slider puzzle with no text field) will
248
+ likely have worse `naive`/`evasive` separability than this synthetic set,
249
+ since here they still fail on mouse geometry too.
250
+
251
+ The synthetic generator (`examples/synthetic_data.py`) draws bots from
252
+ four weighted archetypes: naive (straight-line path, uniform keystrokes,
253
+ fixed click coordinate, 45%), evasive (jittered but still tighter than
254
+ human, scripted scroll, 35%), headless (near-instant submit, little/no
255
+ activity, 10%), and sophisticated (10%) — which reuses the human
256
+ archetype's mouse and keyboard distributions *exactly*, so those two
257
+ channels carry zero separable signal against it by construction (see
258
+ `description.txt` on GAN-generated mouse trajectories and keystroke
259
+ mimicry for why an attacker would specifically invest there). The
260
+ non-zero recall it shows comes entirely from the scroll/click/engagement
261
+ channels it does not mimic, plus (at the cost-optimal threshold) trading
262
+ human pass rate for `sophisticated`-bot recall. That is the pipeline
263
+ correctly recovering the partial signal the generator leaves available —
264
+ not a demonstration of general robustness against every kind of mimicry.
265
+
266
+ The report format and numbers above are real, computed output from this
267
+ repo. The input data is not: it's synthetic, generated locally, with no
268
+ interaction with any real website. Run
269
+ `python -m not_a_robot.train --data <your sessions.jsonl> --seeds 0,1,2,3`
270
+ on real, labeled traffic from your own site to get numbers you can
271
+ actually trust for a production decision.
272
+
273
+ ## Auto-retrain per project
274
+
275
+ `AutoRetrainStore` automates *when* a project's detector gets retrained,
276
+ not *what counts as ground truth*. Each project gets its own store rooted
277
+ at its own directory -- no data or model is shared across projects, and
278
+ there's no code path that trains on anything but a session you've
279
+ explicitly labeled:
280
+
281
+ ```python
282
+ from not_a_robot import AutoRetrainStore
283
+
284
+ store = AutoRetrainStore("path/to/project/.not_a_robot", min_new_sessions=50)
285
+
286
+ # From your live scoring path (cheap -- just a file append):
287
+ store.record_session(session) # raises if session.label is None
288
+ p_human = store.score(new_session)
289
+
290
+ # From a separate periodic job (cron, a scheduled task) -- NOT the
291
+ # request path: fitting + multi-seed CV takes tens of seconds, not ms.
292
+ record = store.maybe_retrain() # None if under min_new_sessions since last retrain
293
+ ```
294
+
295
+ Or as a scheduled command:
296
+
297
+ ```bash
298
+ python -m not_a_robot.autoretrain --root path/to/project/.not_a_robot --min-new-sessions 50
299
+ ```
300
+
301
+ Real output from a run (30 sessions recorded, below the 50 threshold, then
302
+ 20 more crossing it):
303
+
304
+ ```
305
+ pending after 30 sessions: 30
306
+ maybe_retrain() result: None
307
+ pending after 50 sessions: 50
308
+ {
309
+ "timestamp": "2026-09-16T20:40:15.396101+00:00",
310
+ "n_sessions": 50,
311
+ "n_new_sessions": 50,
312
+ "seeds": [0, 1, 2],
313
+ "accuracy_range": [0.942, 0.946],
314
+ "human_pass_rate_range": [0.964, 0.972],
315
+ "bot_catch_rate_range": [0.92, 0.92]
316
+ }
317
+ model file exists: True
318
+ ```
319
+
320
+ Each retrain fits on every session recorded so far, runs the same
321
+ multi-seed `evaluate_cv` used above (so the record's ranges are the
322
+ defensible cross-seed numbers, not a single split), backs up the model it
323
+ replaces (`model.joblib.<timestamp>.bak`, never deleted automatically --
324
+ rollback is a file copy), and appends the summary to `state.json`. Not
325
+ built here, deliberately: any mechanism that would label sessions from
326
+ the detector's own predictions or from unverified live traffic. That's
327
+ the difference between "automates when you retrain" (this) and "trains
328
+ itself on whatever it sees" (a real risk of training-data poisoning, and
329
+ out of scope for this library — see [Scope](#scope)).
330
+
331
+ ## Capturing real training data
332
+
333
+ The library only defines the schema and the feature math; you own the
334
+ client-side capture. On the page you're protecting, record `mousemove`
335
+ coordinates + timestamps and `keydown`/`keyup` timestamps into
336
+ `MouseEvent`/`KeyEvent` objects, tag each finished session with a label
337
+ (from a secondary signal you trust — e.g. a CAPTCHA outcome, an email
338
+ verification, or manual review), and either pass the collected
339
+ `InteractionSession` objects straight to `run_training_pipeline()`, or
340
+ persist them with `not_a_robot.io.save_sessions_jsonl()` (one JSON object
341
+ per line) so `python -m not_a_robot.train --data sessions.jsonl` can pick
342
+ them up later.
343
+
344
+ `examples/synthetic_data.py` generates crude synthetic sessions (one
345
+ human archetype and four weighted bot archetypes, see above) purely so
346
+ the rest of the pipeline has example data to run against before you have
347
+ real, labeled traffic. It is not a model of real bot or human behavior —
348
+ replace it with your own data before relying on this for anything.
349
+
350
+ ## Scope
351
+
352
+ This library builds a defensive behavioral classifier for a system you run
353
+ and control. It intentionally does **not** include: CAPTCHA-solving (OCR,
354
+ image-grid classifiers), browser automation for clicking through third-party
355
+ challenges, integrations with CAPTCHA-solving services, or synthetic
356
+ mouse-trajectory generation meant to fool someone else's bot detection.
357
+ Those are a different (and, outside authorized testing of your own systems,
358
+ frequently abusive) category of tool.
359
+
360
+ ## Development
361
+
362
+ ```bash
363
+ pip install -e ".[dev]"
364
+ pytest
365
+ ```