human-movement-bench 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ """Independent evaluator. Does not import human_movement internals."""
2
+
3
+ __version__ = "0.1.2"
4
+ BENCHMARK_VERSION = "hmbench-1"
@@ -0,0 +1,27 @@
1
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Human Movement · Independent fixture</title>
2
+ <style>body{font:16px/1.5 system-ui;background:#f4f3ed;color:#183b2b;max-width:1000px;margin:30px auto;padding:20px}h1{font-size:36px;font-weight:550}button{font:inherit;border:0;background:#285740;color:white;padding:12px 20px;border-radius:8px;cursor:pointer}header{display:flex;justify-content:space-between;align-items:center}#arena{height:420px;background:white;border:1px solid #d6dfd3;border-radius:12px;position:relative;overflow:hidden;margin:24px 0}.target{position:absolute;padding:0;border-radius:50%}.drop{position:absolute;right:50px;top:180px;width:120px;height:100px;border:2px dashed #285740;display:grid;place-items:center}.drag{position:absolute;left:50px;top:180px;width:50px;height:50px;background:#285740;border-radius:8px;touch-action:none}.corridor{position:absolute;left:50px;right:50px;top:165px;height:130px;background:#edf3e9}.pane{overflow:auto;height:100%;padding:20px}.card{height:150px;background:#f1f4ec;margin:12px;padding:20px}.goal{border:2px solid #285740}textarea{font:20px system-ui;width:90%;height:160px;padding:15px;margin:10px}#status{color:#60766a}#error{color:#a33}small{letter-spacing:1px}nav{display:flex;gap:12px;flex-wrap:wrap}.summary{padding:25px}#finish{background:#e1e7db;color:#183b2b}</style>
3
+ <header><small>HMBENCH-1 · LOCAL FIXTURE</small><span id="label"></span></header><h1>Measure what actually arrives.</h1><p id="instructions">Capture pointing, clicks, a corridor drag, scrolling, and a known typing sample. These events belong to a separate benchmark session.</p>
4
+ <nav><button id="start">Start session</button><button id="next" disabled>Next task</button><button id="finish" disabled>Finish & save</button></nav><section id="arena"><div class="summary"><h2>Ready when you are.</h2><p>Use identical display settings for every method. Browser and injection diagnostics are reported separately from behavioral similarity.</p></div></section><p id="status">Nothing is recording.</p><p id="error" role="alert"></p>
5
+ <script>
6
+ const config=__CONFIG__,arena=document.querySelector('#arena'),status=document.querySelector('#status');document.querySelector('#label').textContent=config.label+' · '+config.version;
7
+ let active=false,mode=0,trial=0,events=[],start=0,chain=Promise.resolve(),presses=new Map(),id=0,untrusted=0,dragging=false,corridor=true;
8
+ const names=['mouse','click','mouse','scroll','keyboard'],tasks=['pointing','click_hold','corridor_drag','scroll_card','fixed_text'];
9
+ const prompt='Hello world — café, 中文, 🙂';
10
+ function post(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json','X-HM-Token':config.token},body:JSON.stringify(body)}).then(async r=>{let d=await r.json();if(!r.ok)throw Error(d.error);return d})}
11
+ function emit(kind,extra={},event){if(!active)return;if(event&&!event.isTrusted)untrusted++;events.push({kind,t:(performance.now()-start)/1000,...extra})}
12
+ function env(){return {platform:navigator.platform,dpr:devicePixelRatio,width:innerWidth,height:innerHeight,screenWidth:screen.width,screenHeight:screen.height}}
13
+ function save(functional={}){if(!events.length)return;const payload={events,task_id:tasks[mode],modality:names[mode],functional,environment:env(),diagnostics:{webdriver:navigator.webdriver,untrusted_events:untrusted,userAgent:navigator.userAgent}};events=[];start=performance.now();untrusted=0;chain=chain.then(()=>post('/trace',payload)).then(r=>{status.textContent=r.saved+' traces saved locally.'}).catch(e=>document.querySelector('#error').textContent=e.message)}
14
+ function layout(){const out={task:tasks[mode],prompt,viewport:env(),screenX,screenY};for(const name of ['.target','.drag','.drop','textarea','.goal']){const e=arena.querySelector(name);if(e){const r=e.getBoundingClientRect();out[name]={x:r.x,y:r.y,width:r.width,height:r.height}}}post('/layout',out).catch(()=>{})}
15
+ function target(){const widths=[24,48,80],locations=[[.2,.3],[.8,.7],[.4,.7],[.75,.2]];const size=widths[trial%3],loc=locations[trial%4];arena.innerHTML='<button class="target" aria-label="Benchmark target">+</button>';const b=arena.firstChild;b.style.cssText=`width:${size}px;height:${size}px;left:${loc[0]*(arena.clientWidth-size)}px;top:${loc[1]*(arena.clientHeight-size)}px`;b.onclick=()=>{save(mode===0?{target_hit:true}:{});trial++;target()};layout()}
16
+ function stage(){events=[];start=performance.now();trial=0;presses.clear();document.querySelector('#instructions').textContent=['Click varied targets several times, then choose Next task.','Click the target repeatedly at your normal pace.','Drag the green block through the pale corridor into the outlined box.','Scroll down to the outlined card and click it.','Type the displayed sample exactly, then click Finish & save.'][mode];if(mode<2)target();else if(mode===2){arena.innerHTML='<div class="corridor"></div><div class="drop">Release here</div><div class="drag" role="button" aria-label="Drag block"></div>';const d=arena.querySelector('.drag');corridor=true;d.onpointerdown=e=>{dragging=true;d.setPointerCapture(e.pointerId)};d.onpointermove=e=>{if(!dragging)return;const r=arena.getBoundingClientRect();const y=e.clientY-r.y;corridor&&=y>=165&&y<=295;d.style.left=(e.clientX-r.x-25)+'px';d.style.top=(y-25)+'px'};d.onpointerup=e=>{dragging=false;const r=arena.querySelector('.drop').getBoundingClientRect();save({drag_success:corridor&&e.clientX>=r.left&&e.clientX<r.right&&e.clientY>=r.top&&e.clientY<r.bottom});stage()};layout()}else if(mode===3){arena.innerHTML='<div class="pane"></div>';for(let i=0;i<12;i++){const c=document.createElement('div');c.className='card'+(i===7?' goal':'');c.textContent=i===7?'Click this card':'Card '+i;arena.firstChild.append(c);if(i===7)c.onclick=()=>{save({});stage()}}layout()}else{arena.innerHTML='<div class="summary"><p id="prompt"></p><textarea aria-label="Type the sample" spellcheck="false"></textarea></div>';arena.querySelector('#prompt').textContent=prompt;arena.querySelector('textarea').focus();layout()}}
17
+ document.querySelector('#start').onclick=()=>{active=true;document.querySelector('#start').disabled=true;document.querySelector('#next').disabled=false;document.querySelector('#finish').disabled=false;status.textContent='Recording browser-delivered input.';stage()};
18
+ document.querySelector('#next').onclick=()=>{if(mode===4){save({exact_text:arena.querySelector('textarea').value===prompt})}else events=[];mode=(mode+1)%5;stage()};
19
+ document.querySelector('#finish').onclick=async()=>{save(mode===4?{exact_text:arena.querySelector('textarea').value===prompt}:{});active=false;await chain;document.querySelector('#next').disabled=true;document.querySelector('#finish').disabled=true;arena.innerHTML='<div class="summary"><h2>Session saved.</h2><p>The evaluator will check data sufficiency, independent controls, and task correctness before showing a score.</p></div>'};
20
+ arena.addEventListener('pointermove',e=>emit('move',{x:e.clientX,y:e.clientY},e),true);
21
+ for(const [name,kind] of [['pointerdown','button_down'],['pointerup','button_up']])arena.addEventListener(name,e=>emit(kind,{x:e.clientX,y:e.clientY,button:['left','middle','right'][e.button]||'other'},e),true);
22
+ arena.addEventListener('wheel',e=>emit('scroll',{dx:e.deltaX,dy:e.deltaY},e),{capture:true,passive:true});
23
+ arena.addEventListener('keydown',e=>{if(!presses.has(e.code))presses.set(e.code,++id);emit('key_down',{press_id:presses.get(e.code),repeat:e.repeat},e)},true);
24
+ arena.addEventListener('keyup',e=>{emit('key_up',{press_id:presses.get(e.code)||0},e);presses.delete(e.code)},true);
25
+ arena.addEventListener('pointerup',e=>{if(active&&mode===0&&e.button===0&&!e.target.closest('.target')){save({target_hit:false});trial++;target()}});window.addEventListener('resize',layout);
26
+ </script></html>
27
+
@@ -0,0 +1,48 @@
1
+ """Frozen engineering controls; no SDK internal implementation is imported."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+
8
+ BASELINE_VERSION = "engineering-1"
9
+ EXTERNAL_REVISIONS = {
10
+ "humancursor": "39d33f387b953950ffc1035183c9609f7f87c2c8",
11
+ "human_mouse": "c5cb6db923d4ff2a410abb466691a9659d924f9a",
12
+ }
13
+
14
+
15
+ def trajectory(
16
+ method: str,
17
+ start: tuple,
18
+ goal: tuple,
19
+ *,
20
+ duration: float = 0.6,
21
+ seed: int = 17842,
22
+ samples: int = 75,
23
+ ) -> list[dict]:
24
+ if method not in {"linear", "bezier", "minimum_jerk"}:
25
+ raise ValueError("Choose a frozen built-in baseline")
26
+ if samples < 2 or not math.isfinite(duration) or duration <= 0:
27
+ raise ValueError("Positive duration and at least two samples required")
28
+ rng = random.Random(seed)
29
+ dx, dy = goal[0] - start[0], goal[1] - start[1]
30
+ bend = rng.uniform(-0.15, 0.15)
31
+ c1 = (start[0] + dx / 3 - dy * bend, start[1] + dy / 3 + dx * bend)
32
+ c2 = (start[0] + dx * 2 / 3 - dy * bend, start[1] + dy * 2 / 3 + dx * bend)
33
+ output = []
34
+ for i in range(samples):
35
+ s = i / (samples - 1)
36
+ u = 10 * s**3 - 15 * s**4 + 6 * s**5 if method == "minimum_jerk" else s
37
+ if method == "bezier":
38
+ x, y = [
39
+ (1 - u) ** 3 * start[j]
40
+ + 3 * (1 - u) ** 2 * u * c1[j]
41
+ + 3 * (1 - u) * u * u * c2[j]
42
+ + u**3 * goal[j]
43
+ for j in (0, 1)
44
+ ]
45
+ else:
46
+ x, y = start[0] + dx * u, start[1] + dy * u
47
+ output.append({"kind": "move", "t": duration * s, "x": x, "y": y})
48
+ return output
@@ -0,0 +1,178 @@
1
+ import argparse
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .traces import assert_disjoint, digest, make_manifest, read_jsonl, write_jsonl
8
+
9
+
10
+ def main(argv=None):
11
+ parser = argparse.ArgumentParser(prog="human-movement-bench")
12
+ sub = parser.add_subparsers(dest="command", required=True)
13
+ for name in ("validate", "split", "fit", "evaluate"):
14
+ p = sub.add_parser(name)
15
+ p.add_argument("input", type=Path)
16
+ p.add_argument("--personal", action="store_true")
17
+ if name in {"split", "fit", "evaluate"}:
18
+ p.add_argument("--output", type=Path, required=True)
19
+ if name == "evaluate":
20
+ p.add_argument("--detectors", type=Path, required=True)
21
+ p.add_argument("--candidate-version", required=True)
22
+ p.add_argument("--profile-hash", default="none")
23
+ p.add_argument("--model-hash", default="none")
24
+ serve = sub.add_parser("serve")
25
+ serve.add_argument("--output", type=Path, required=True)
26
+ serve.add_argument("--participant", required=True)
27
+ serve.add_argument("--session")
28
+ serve.add_argument(
29
+ "--label", choices=("human", "candidate", "control", "human_control"), default="human"
30
+ )
31
+ serve.add_argument("--split", default="final_test")
32
+ serve.add_argument("--candidate-version", default="manual")
33
+ serve.add_argument("--no-browser", action="store_true")
34
+ report = sub.add_parser("report")
35
+ report.add_argument("reports", nargs="+", type=Path)
36
+ report.add_argument("--output", required=True, type=Path)
37
+ delivery = sub.add_parser("delivery")
38
+ delivery.add_argument("input", type=Path)
39
+ delivery.add_argument("--output", type=Path, required=True)
40
+ baseline = sub.add_parser("baseline")
41
+ baseline.add_argument(
42
+ "method", choices=("linear", "bezier", "minimum_jerk", "humancursor", "human_mouse")
43
+ )
44
+ baseline.add_argument("--start", type=float, nargs=2, default=(100, 100))
45
+ baseline.add_argument("--goal", type=float, nargs=2, default=(900, 500))
46
+ baseline.add_argument("--duration", type=float, default=0.6)
47
+ baseline.add_argument("--checkout", type=Path)
48
+ baseline.add_argument("--seed", type=int, default=17842)
49
+ baseline.add_argument("--output", type=Path, required=True)
50
+ imp = sub.add_parser("import")
51
+ imp.add_argument("dataset", choices=("cmu", "sapimouse", "balabit"))
52
+ imp.add_argument("input", type=Path)
53
+ imp.add_argument("--output", type=Path, required=True)
54
+ imp.add_argument("--split", required=True)
55
+ imp.add_argument("--terms-note", required=True)
56
+ imp.add_argument("--participant")
57
+ imp.add_argument("--session")
58
+ imp.add_argument("--timestamp-scale", type=float)
59
+ imp.add_argument("--timestamp-column")
60
+ args = parser.parse_args(argv)
61
+ try:
62
+ if args.command in {"validate", "split", "fit", "evaluate"}:
63
+ traces = read_jsonl(args.input)
64
+ assert_disjoint(traces, personal=args.personal)
65
+ if args.command == "validate":
66
+ print(
67
+ json.dumps(
68
+ {
69
+ "traces": len(traces),
70
+ "sha256": digest(args.input),
71
+ "leakage_check": "passed",
72
+ }
73
+ )
74
+ )
75
+ elif args.command == "split":
76
+ print(json.dumps(make_manifest(traces, args.output), indent=2))
77
+ elif args.command == "fit":
78
+ from .detectors import fit
79
+
80
+ print(json.dumps(fit(traces, args.output, personal=args.personal), indent=2))
81
+ else:
82
+ from .evaluation import evaluate
83
+
84
+ result = evaluate(
85
+ traces,
86
+ args.detectors,
87
+ personal=args.personal,
88
+ metadata={
89
+ "dataset_sha256": digest(args.input),
90
+ "candidate_version": args.candidate_version,
91
+ "profile_sha256": args.profile_hash,
92
+ "model_sha256": args.model_hash,
93
+ },
94
+ )
95
+ args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
96
+ print(
97
+ json.dumps(
98
+ {"status": result["status"], "overall_score": result["overall_score"]}
99
+ )
100
+ )
101
+ elif args.command == "serve":
102
+ from .website import Fixture
103
+
104
+ Fixture(
105
+ args.output,
106
+ participant=args.participant,
107
+ session=args.session,
108
+ label=args.label,
109
+ split=args.split,
110
+ candidate_version=args.candidate_version,
111
+ ).serve(open_browser=not args.no_browser)
112
+ elif args.command == "report":
113
+ from .report import render
114
+
115
+ render([json.loads(p.read_text()) for p in args.reports], args.output)
116
+ elif args.command == "delivery":
117
+ from .delivery import analyze_delivery
118
+
119
+ args.output.write_text(
120
+ json.dumps(analyze_delivery(json.loads(args.input.read_text())), indent=2),
121
+ encoding="utf-8",
122
+ )
123
+ elif args.command == "baseline":
124
+ from .baselines import BASELINE_VERSION, trajectory
125
+
126
+ if args.method in {"humancursor", "human_mouse"}:
127
+ if not args.checkout:
128
+ parser.error("External baseline requires --checkout at its frozen revision")
129
+ request = {
130
+ "method": args.method,
131
+ "checkout": str(args.checkout),
132
+ "start": args.start,
133
+ "goal": args.goal,
134
+ "seed": args.seed,
135
+ }
136
+ captured = subprocess.run(
137
+ [sys.executable, "-m", "human_movement_bench.external"],
138
+ input=json.dumps(request),
139
+ text=True,
140
+ capture_output=True,
141
+ check=True,
142
+ timeout=60,
143
+ )
144
+ result = json.loads(captured.stdout)
145
+ else:
146
+ result = {
147
+ "method": args.method,
148
+ "version": BASELINE_VERSION,
149
+ "events": trajectory(
150
+ args.method, args.start, args.goal, duration=args.duration, seed=args.seed
151
+ ),
152
+ }
153
+ args.output.parent.mkdir(parents=True, exist_ok=True)
154
+ args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
155
+ elif args.command == "import":
156
+ from .datasets import cmu_csv, mouse_csv
157
+
158
+ if args.dataset == "cmu":
159
+ result = cmu_csv(args.input, split=args.split, terms_note=args.terms_note)
160
+ else:
161
+ if not args.participant or not args.session or not args.timestamp_scale:
162
+ parser.error(
163
+ "Mouse import requires participant, session, and explicit timestamp-scale"
164
+ )
165
+ result = mouse_csv(
166
+ args.input,
167
+ dataset=args.dataset,
168
+ participant=args.participant,
169
+ session=args.session,
170
+ split=args.split,
171
+ timestamp_scale=args.timestamp_scale,
172
+ timestamp_column=args.timestamp_column,
173
+ terms_note=args.terms_note,
174
+ )
175
+ write_jsonl(args.output, result)
176
+ print(f"Imported {len(result)} traces")
177
+ except (ValueError, OSError) as exc:
178
+ parser.exit(1, f"{exc}\n")
@@ -0,0 +1,209 @@
1
+ """Local, explicit dataset adapters. Downloads and terms acceptance are not implicit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from .traces import digest, validate
11
+
12
+ CATALOG = {
13
+ "webclick": {
14
+ "url": "https://huggingface.co/datasets/Hcompany/WebClick",
15
+ "cohort": "webclick",
16
+ "role": "layout fixtures only; no timed motion",
17
+ "terms": "Apache-2.0 dataset card; retain source attribution",
18
+ },
19
+ "sapimouse": {
20
+ "url": "https://github.com/margitantal68/sapimouse",
21
+ "cohort": "sapimouse",
22
+ "role": "mouse sessions",
23
+ "terms": "Repository Apache-2.0; verify separate raw-download terms before redistribution",
24
+ },
25
+ "balabit": {
26
+ "url": "https://github.com/balabit/Mouse-Dynamics-Challenge",
27
+ "cohort": "balabit",
28
+ "role": "remote desktop mouse sessions",
29
+ "terms": "Record source README terms; no blanket license inferred",
30
+ },
31
+ "derived_mouse_dynamics": {
32
+ "url": "https://data.mendeley.com/datasets/9rv9s3v2br/1",
33
+ "cohort": "balabit",
34
+ "role": "derived from Balabit; not an independent cohort",
35
+ "terms": "Verify version-specific data terms",
36
+ },
37
+ "cmu": {
38
+ "url": "https://www.cs.cmu.edu/~keystroke/",
39
+ "cohort": "cmu_fixed_text",
40
+ "role": "fixed-text timing only",
41
+ "terms": "Research dataset; cite Killourhy and Maxion DSN 2009; verify source terms",
42
+ },
43
+ "becaptcha": {
44
+ "url": "https://github.com/BiDAlab/BeCAPTCHA-Mouse",
45
+ "cohort": "becaptcha",
46
+ "role": "optional mouse trajectories",
47
+ "terms": "Institutional agreement required; no automatic access or redistribution",
48
+ },
49
+ }
50
+
51
+
52
+ def provenance(path, dataset, terms_note):
53
+ if not terms_note.strip():
54
+ raise ValueError("Record the permitted use/terms you verified for this dataset")
55
+ return {**CATALOG[dataset], "file_sha256": digest(path), "terms_note": terms_note}
56
+
57
+
58
+ def webclick_box(box: list[float], width: int, height: int) -> dict:
59
+ """WebClick bbox is normalized [left, top, right, bottom], never a path."""
60
+ if len(box) != 4 or not all(isinstance(x, (float, int)) and 0 <= x <= 1 for x in box):
61
+ raise ValueError("WebClick bounding box must be finite normalized coordinates")
62
+ left, top, right, bottom = box
63
+ if right <= left or bottom <= top or width <= 0 or height <= 0:
64
+ raise ValueError("Empty, reversed, or invalid WebClick box")
65
+ return {
66
+ "x": left * width,
67
+ "y": top * height,
68
+ "width": (right - left) * width,
69
+ "height": (bottom - top) * height,
70
+ }
71
+
72
+
73
+ def mouse_csv(
74
+ path: str | Path,
75
+ *,
76
+ dataset: str,
77
+ participant: str,
78
+ session: str,
79
+ split: str,
80
+ timestamp_scale: float,
81
+ terms_note: str,
82
+ timestamp_column: str | None = None,
83
+ ) -> list[dict]:
84
+ if dataset not in {"sapimouse", "balabit"} or timestamp_scale <= 0:
85
+ raise ValueError("Choose sapimouse/balabit and explicit seconds-per-timestamp-unit")
86
+ path = Path(path)
87
+ with path.open(encoding="utf-8-sig", newline="") as stream:
88
+ sample = stream.read(4096)
89
+ stream.seek(0)
90
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
91
+ rows = [
92
+ {k.strip().lower(): v.strip() for k, v in r.items()}
93
+ for r in csv.DictReader(stream, dialect=dialect)
94
+ ]
95
+ events = []
96
+ column = timestamp_column or "client timestamp"
97
+ for row in rows:
98
+ state = row["state"].lower()
99
+ if state in {"move", "drag"}:
100
+ kind = "move"
101
+ elif state in {"pressed", "press", "down"}:
102
+ kind = "button_down"
103
+ elif state in {"released", "release", "up"}:
104
+ kind = "button_up"
105
+ else:
106
+ continue
107
+ event = {
108
+ "kind": kind,
109
+ "t": float(row[column]) * timestamp_scale,
110
+ "x": float(row["x"]),
111
+ "y": float(row["y"]),
112
+ }
113
+ if kind != "move":
114
+ event["button"] = row["button"].lower()
115
+ events.append(event)
116
+ if not events:
117
+ raise ValueError("No supported input events found")
118
+ origin = events[0]["t"]
119
+ for e in events:
120
+ e["t"] -= origin
121
+ source = digest(path)
122
+ chunks, chunk = [], []
123
+ for e in events:
124
+ chunk.append(e)
125
+ if e["kind"] == "button_up" and len(chunk) >= 5:
126
+ chunks.append(chunk)
127
+ chunk = []
128
+ if len(chunk) >= 5:
129
+ chunks.append(chunk)
130
+ output = []
131
+ for i, chunk in enumerate(chunks):
132
+ start = chunk[0]["t"]
133
+ chunk = [{**e, "t": e["t"] - start} for e in chunk]
134
+ output.append(
135
+ validate(
136
+ {
137
+ "participant_id": participant,
138
+ "session_id": session,
139
+ "recording_id": f"{source}:{i}",
140
+ "source_recording_id": source,
141
+ "cohort": CATALOG[dataset]["cohort"],
142
+ "condition": "remote_desktop" if dataset == "balabit" else "sapimouse_capture",
143
+ "task_id": "unlabelled_pointing",
144
+ "split": split,
145
+ "label": "human",
146
+ "modality": "mouse",
147
+ "events": chunk,
148
+ "provenance": provenance(path, dataset, terms_note),
149
+ }
150
+ )
151
+ )
152
+ return output
153
+
154
+
155
+ def cmu_csv(path: str | Path, *, split: str, terms_note: str) -> list[dict]:
156
+ source = digest(path)
157
+ output = []
158
+ with Path(path).open(encoding="utf-8-sig", newline="") as stream:
159
+ for i, row in enumerate(csv.DictReader(stream)):
160
+ holds = [(k, float(v)) for k, v in row.items() if k.startswith("H.")]
161
+ down_to_down = [float(v) for k, v in row.items() if k.startswith("DD.")]
162
+ events, t = [], 0.0
163
+ for j, (_, hold) in enumerate(holds):
164
+ events.extend(
165
+ [
166
+ {"kind": "key_down", "press_id": j + 1, "t": t},
167
+ {"kind": "key_up", "press_id": j + 1, "t": t + hold},
168
+ ]
169
+ )
170
+ if j < len(down_to_down):
171
+ t += down_to_down[j]
172
+ participant, session = row["subject"], row["sessionIndex"]
173
+ # Source grouping is per actual subject/session, not the consolidated CSV container.
174
+ recording = hashlib.sha256(f"{source}:{participant}:{session}".encode()).hexdigest()
175
+ output.append(
176
+ validate(
177
+ {
178
+ "participant_id": participant,
179
+ "session_id": f"{participant}:{session}",
180
+ "session_time": int(session),
181
+ "recording_id": f"{recording}:{i}",
182
+ "source_recording_id": recording,
183
+ "cohort": "cmu_fixed_text",
184
+ "condition": "cmu_fixed_password",
185
+ "task_id": "cmu_fixed_text",
186
+ "split": split,
187
+ "label": "human",
188
+ "modality": "keyboard",
189
+ "events": sorted(events, key=lambda e: e["t"]),
190
+ "provenance": provenance(path, "cmu", terms_note),
191
+ }
192
+ )
193
+ )
194
+ return output
195
+
196
+
197
+ def becaptcha_json(path, *, agreement_reference: str) -> list[dict]:
198
+ if not agreement_reference.strip():
199
+ raise ValueError("A verified institutional agreement reference is required")
200
+ # Deliberately accepts an institution's canonical export, not undocumented restricted formats.
201
+ rows = json.loads(Path(path).read_text())
202
+ for row in rows:
203
+ validate(row)
204
+ row["provenance"] = {
205
+ **CATALOG["becaptcha"],
206
+ "agreement_reference": agreement_reference,
207
+ "file_sha256": digest(path),
208
+ }
209
+ return rows
@@ -0,0 +1,51 @@
1
+ """Pair native and received timestamps; keep timing uncertainty visible."""
2
+
3
+ from collections import defaultdict, deque
4
+
5
+ import numpy as np
6
+
7
+
8
+ def analyze_delivery(bundle: dict) -> dict:
9
+ native = sorted(
10
+ (e for e in bundle["events"] if e.get("source") == "native"), key=lambda e: e["t"]
11
+ )
12
+ received = sorted(
13
+ (e for e in bundle["events"] if e.get("source") == "browser"), key=lambda e: e["t"]
14
+ )
15
+ pending = defaultdict(deque)
16
+ for e in native:
17
+ pending[e["kind"]].append(e)
18
+ latencies = defaultdict(list)
19
+ unmatched = defaultdict(int)
20
+ for e in received:
21
+ candidates = pending[e["kind"]]
22
+ while candidates and candidates[0]["t"] < e["t"] - 0.25:
23
+ candidates.popleft()
24
+ # For coalesced movement, pair to the latest preceding native sample.
25
+ while e["kind"] == "move" and len(candidates) > 1 and candidates[1]["t"] <= e["t"]:
26
+ candidates.popleft()
27
+ if candidates and abs(candidates[0]["t"] - e["t"]) <= 0.25:
28
+ latencies[e["kind"]].append(e["t"] - candidates.popleft()["t"])
29
+ else:
30
+ unmatched[e["kind"]] += 1
31
+ nmove = sum(e["kind"] == "move" for e in native)
32
+ bmove = sum(e["kind"] == "move" for e in received)
33
+ return {
34
+ "native_events": len(native),
35
+ "received_events": len(received),
36
+ "native_moves": nmove,
37
+ "received_moves": bmove,
38
+ "movement_delivery_ratio": bmove / nmove if nmove else None,
39
+ "clock_uncertainty_ms": bundle.get("environment", {}).get("clock_uncertainty_ms"),
40
+ "latency_seconds": {
41
+ kind: {
42
+ "n": len(v),
43
+ "median": float(np.median(v)),
44
+ "p95": float(np.quantile(v, 0.95)),
45
+ "sd": float(np.std(v)),
46
+ }
47
+ for kind, v in latencies.items()
48
+ },
49
+ "unmatched_received": dict(unmatched),
50
+ "interpretation": "Timestamp matching estimate. Delivery ratio includes coalescing and browser scope; it is not a pure OS loss rate. Negative delays can reflect clock-offset uncertainty.",
51
+ }