# getCoalescedEvents() Headless-vs-Headful Detection — Validation Results

**Date:** 2026-06-23
**Status:** **VALIDATED** as headless-vs-headful discriminator
**Source claim:** NIGHTLY-FINDINGS-2026-06-23.md (Priority 1, originally framed as CDP-vs-no-CDP — **rejected** at that framing, **validated** at the headless-vs-headful framing)
**Upstream reference:** WebDecoy/FCaptcha#10

## Test Setup

- **Test A (vanilla headless):** `playwright.async_api` headless Chromium 148, no CDP attachment. 50 jittered `page.mouse.move()` calls at ~60Hz.
- **Test B (CDP-attached headless):** Chrome with `--headless=new --remote-debugging-port=9336`, connected via `playwright.chromium.connect_over_cdp()`. Same 50-move sequence.
- **Test C (real human, Windows Chrome desktop):** Real user interaction via `https://detect-bot.vercel.app/collect-human`. 393 mouse events over ~10.5 seconds.

## Results

| Test | count | max | avg | median | size>1 | P90 | P99 |
|------|-------|-----|-----|--------|--------|-----|-----|
| **A. Vanilla headless** | 50 | **1** | 1.00 | 1.0 | 0 (0%) | 1 | 1 |
| **B. CDP-attached headless** | 50 | **1** | 1.00 | 1.0 | 0 (0%) | 1 | 1 |
| **C. Real human (Windows Chrome)** | 393 | **27** | 11.79 | 10.0 | 343 (**87.2%**) | 17 | 27 |

**Separation:** headless = single-entry batches (max=1, zero overlap). Real human = heavy multi-entry coalescing (max=27, 87.2% of events have size > 1).

## Verdict: VALIDATED

The spec claim is **correct when framed as headless-vs-headful**, not as CDP-vs-no-CDP. The original NIGHTLY-FINDINGS framing was wrong; the underlying observation is real:

- **Headless Chromium** (both Playwright-vanilla and CDP-attached) emits single-entry `getCoalescedEvents()` batches because it synthesizes one `PointerEvent` per `page.mouse.move()` call.
- **Real hardware** (USB mouse on Windows Chrome) coalesces multiple hardware samples per frame, producing `getCoalescedEvents()` arrays of size 2-27.

**Why CDP-vs-no-CDP framing failed:** both vanilla Playwright and CDP-attached Chrome are headless. CDP attachment changes protocol-level behavior but not the underlying event synthesis. The discriminator is **headless vs. real hardware**, not CDP vs. no-CDP.

## Production Implementation

Added to `src/app/api/script/route.ts` Phase 3 scoring as `coalesced_mouse_synthesis` finding:

- **Capture** (line 109-110): each `mousemove` event records `e.getCoalescedEvents().length` as `pt.coalescedCount`
- **Threshold** (line 1186+ in `scoreBehavioral`): if `coalMax <= 1` AND `coalMulti === 0` across >= 5 events with valid `coalescedCount` → `coalesced_mouse_synthesis` finding, +30 penalty
- **Why this threshold is safe:** the 87.2% real-human pass rate gives ample headroom — even if a small fraction of real humans have software-rendered mouse (e.g., VMs, RDP), 5+ single-entry events in a row is the synthetic-mouse signature, not human noise.

**Risk of false positives:** near-zero on physical hardware. Theoretical risk: VMs, RDP sessions, software-rendered pointers — but those are detectable via other Phase 1 signals (canvas hashes, WebGL params) and would already be flagged as bot-like.

## Files

- `demo.html` — local page with pointermove capture
- `test.py` — 2-test runner (vanilla + CDP, headless baselines)
- `VALIDATION-RESULTS.md` — this file
- Production: `src/app/api/script/route.ts` (capture at line 109-110, scoring at line 1186+)
- Production: `src/app/collect-human/page.tsx` (capture added for future real-human baseline collection)

## Reproduction

```bash
cd /home/huxley/projects/detect-bot
python3 -m http.server 3457 --bind 127.0.0.1 &
/usr/bin/python3.9 spikes/coalesced-events/test.py
```

For real-human baseline:
1. Open https://detect-bot.vercel.app/collect-human in real Chrome/Firefox/Safari
2. Move mouse naturally for 10+ seconds
3. Pull the session from /api/admin/save-raw-session and inspect `coalescedSizes` field
