"""Adapter for the Balabit Mouse Dynamics Challenge dataset. Source: https://github.com/balabit/Mouse-Dynamics-Challenge Each session is a CSV file with columns: record timestamp, client timestamp, button, state, x, y Where: state ∈ {Move, Pressed, Released, Drag, Scroll} button ∈ {NoButton, Left, Right, Wheel} We extract "click-anchored" trajectory segments: each Pressed event defines a target, and the W ms of Move events preceding it form one training trace. """ from __future__ import annotations import json import logging from dataclasses import dataclass from pathlib import Path logger = logging.getLogger(__name__) @dataclass class MouseEvent: """A single mouse event from a Balabit CSV row.""" t_ms: int # client timestamp in milliseconds (relative to session start) button: str # "NoButton", "Left", "Right", "Wheel" state: str # "Move", "Pressed", "Released", "Drag", "Scroll" x: int y: int @dataclass class Segment: """A click-anchored trajectory segment ready to be written to JSONL.""" events: list[MouseEvent] # only Move events, sorted by t_ms ascending click_x: int # the Pressed event's x coordinate click_y: int # the Pressed event's y coordinate click_t_ms: int # the Pressed event's timestamp session_id: str # e.g. "user7_session_42" def parse_session_csv(path: Path) -> list[MouseEvent]: """Parse a Balabit session CSV file into MouseEvent objects. Malformed rows are logged and skipped (not raised). Client timestamps (seconds, float) are converted to int milliseconds. Args: path: Path to a Balabit session CSV file. Returns: List of MouseEvent in original order. Empty list if file is empty. """ import csv as csv_module events: list[MouseEvent] = [] with path.open("r", encoding="utf-8", newline="") as f: reader = csv_module.DictReader(f) for row_idx, row in enumerate(reader, 2): # 1-based, header is line 1 try: client_ts = float(row["client timestamp"]) t_ms = int(round(client_ts * 1000)) button = row["button"].strip() state = row["state"].strip() x = int(row["x"]) y = int(row["y"]) except (KeyError, ValueError, TypeError) as exc: logger.debug("Skipping malformed row %d in %s: %s", row_idx, path.name, exc) continue events.append(MouseEvent(t_ms=t_ms, button=button, state=state, x=x, y=y)) return events def segment_by_clicks( events: list[MouseEvent], window_ms: int, session_id: str, ) -> list[Segment]: """Extract click-anchored segments from a session. For each Left-button Pressed event, collect all Move events within [click_t - window_ms, click_t) into one segment. Args: events: Full session events (any state, any order is OK but typically sorted). window_ms: How far back to look before each click. session_id: String tag attached to every segment for debugging. Returns: List of Segment, one per Left Pressed event that has at least one preceding Move. """ segments: list[Segment] = [] for ev in events: if ev.state != "Pressed" or ev.button != "Left": continue click_t = ev.t_ms window_start = click_t - window_ms moves = [ m for m in events if m.state == "Move" and window_start <= m.t_ms < click_t ] if not moves: continue moves.sort(key=lambda m: m.t_ms) segments.append(Segment( events=moves, click_x=ev.x, click_y=ev.y, click_t_ms=click_t, session_id=session_id, )) return segments def filter_segments( segments: list[Segment], min_events: int, min_dist: int, max_span_ms: int, max_gap_ms: int, coord_max: int = 5000, ) -> list[Segment]: """Drop segments that fail any quality check. A segment is dropped if any of these are true: - len(events) < min_events - Euclidean dist(events[0], (click_x, click_y)) < min_dist - events[-1].t_ms - events[0].t_ms > max_span_ms - any adjacent Move pair has dt > max_gap_ms (sampling drop-out) - any coord (start/end/click) outside [0, coord_max] - total arc length < min_dist (high-frequency jitter only) Args: segments: Candidate segments. min_events: Minimum number of Move events. min_dist: Minimum start→click pixel distance AND minimum total arc length. max_span_ms: Maximum time span of the segment (events[-1] - events[0]). max_gap_ms: Maximum allowed gap between adjacent Move events. coord_max: Maximum allowed pixel coordinate value (5000 catches multi-monitor anomalies). Returns: Filtered list, original order preserved. """ import math keep: list[Segment] = [] for seg in segments: if len(seg.events) < min_events: continue sx, sy = seg.events[0].x, seg.events[0].y ex, ey = seg.click_x, seg.click_y # Coord range check if any(c < 0 or c > coord_max for c in (sx, sy, ex, ey)): continue # Endpoint distance dist = math.hypot(ex - sx, ey - sy) if dist < min_dist: continue # Time span span = seg.events[-1].t_ms - seg.events[0].t_ms if span > max_span_ms: continue # Gap check + total arc length total_arc = 0.0 bad_gap = False for i in range(1, len(seg.events)): dt = seg.events[i].t_ms - seg.events[i - 1].t_ms if dt > max_gap_ms: bad_gap = True break dx = seg.events[i].x - seg.events[i - 1].x dy = seg.events[i].y - seg.events[i - 1].y total_arc += math.hypot(dx, dy) if bad_gap: continue if total_arc < min_dist: continue keep.append(seg) return keep def process_session( csv_path: Path, output_jsonl: Path, config, ) -> int: """Convert one Balabit session CSV to JSONL traces, append to output. Args: csv_path: Path to a Balabit session CSV. output_jsonl: Output JSONL file (will be appended to). config: BalabitAdapterConfig with window_ms / min_dist / etc. Returns: Number of valid segments written. """ import math session_id = csv_path.stem # e.g. "session_42" events = parse_session_csv(csv_path) if not events: return 0 raw_segments = segment_by_clicks( events, window_ms=config.window_ms, session_id=session_id ) valid_segments = filter_segments( raw_segments, min_events=config.min_events, min_dist=config.min_dist, max_span_ms=config.max_span_ms, max_gap_ms=config.max_gap_ms, ) if not valid_segments: return 0 output_jsonl.parent.mkdir(parents=True, exist_ok=True) n_written = 0 with output_jsonl.open("a", encoding="utf-8") as f: for seg in valid_segments: sx, sy = seg.events[0].x, seg.events[0].y ex, ey = seg.click_x, seg.click_y dist = math.hypot(ex - sx, ey - sy) angle = math.degrees(math.atan2(ey - sy, ex - sx)) t0 = seg.events[0].t_ms record = { "meta": { "start": [sx, sy], "end": [ex, ey], "dist": int(round(dist)), "angle": round(angle, 1), "source": "balabit", "session_id": seg.session_id, }, "events": [ {"type": "move", "x": e.x, "y": e.y, "t": e.t_ms - t0} for e in seg.events ], } f.write(json.dumps(record, ensure_ascii=False) + "\n") n_written += 1 return n_written def main(argv: list[str] | None = None) -> int: """CLI entry point: convert a directory of Balabit sessions to one JSONL file.""" import argparse from tools.config import BalabitAdapterConfig parser = argparse.ArgumentParser(description="Convert Balabit dataset to traces.jsonl format") parser.add_argument( "--input", type=Path, required=True, help="Directory containing Balabit session CSV files (recursive)", ) parser.add_argument( "--output", type=Path, default=Path("data/pretrain_traces.jsonl"), help="Output JSONL path (default: data/pretrain_traces.jsonl)", ) parser.add_argument("--window-ms", type=int, default=1200) parser.add_argument("--min-dist", type=int, default=50) parser.add_argument("--min-events", type=int, default=5) parser.add_argument("--max-span-ms", type=int, default=5000) parser.add_argument("--max-gap-ms", type=int, default=200) parser.add_argument( "--overwrite", action="store_true", help="Truncate output file before writing (default: append)", ) args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") if not args.input.is_dir(): logger.error("Input is not a directory: %s", args.input) return 2 config = BalabitAdapterConfig( window_ms=args.window_ms, min_dist=args.min_dist, min_events=args.min_events, max_span_ms=args.max_span_ms, max_gap_ms=args.max_gap_ms, ) if args.overwrite and args.output.exists(): args.output.unlink() # Recursively walk the input directory looking for session files. # Balabit files have no extension; we accept any regular file. csv_files = sorted([p for p in args.input.rglob("*") if p.is_file() and not p.name.startswith(".")]) if not csv_files: logger.error("No session files found under %s", args.input) return 2 total = 0 for i, csv_path in enumerate(csv_files, 1): try: n = process_session(csv_path, args.output, config) except Exception as exc: # noqa: BLE001 logger.warning("Skipping %s due to error: %s", csv_path.name, exc) continue total += n if i % 10 == 0 or i == len(csv_files): logger.info("Processed %d/%d sessions, %d segments so far", i, len(csv_files), total) logger.info("Done. Wrote %d segments to %s", total, args.output) return 0 if __name__ == "__main__": import sys sys.exit(main())