diff --git a/.gitignore b/.gitignore index ccb8e06..c108600 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,10 @@ coverage.xml # Unleashed session transcripts (auto-generated, untracked) data/unleashed/ +# Silphe recordings — local biometric movement data, never published +recordings/ +**/recordings/ + # Agent-parked files (mv $file $file.bak / $file.parked-{timestamp}) # CLAUDE.md "Destroying uncommitted state" principle: the agent uses # mv-to-bak instead of rm to preserve recoverability. Ignored here so diff --git a/README.md b/README.md index a0e1733..b7cdcd7 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,60 @@ -# silphe +# Silphe -> One-line description of what this project does. +> Your mouse has a signature as personal as your handwriting. Silphe learns it — and shows you how it moves, holds, hunts, and drifts over time. -## Overview +**Silphe** (σίλφη — Ancient Greek for the small creature that runs in the dark) is a tiny, fun, **fully local** desktop game that captures how *you, specifically,* move a pointer. Not whether you hit the target — *how you miss it on the way there:* the overshoot, the correction, the tremor, the chase. -Brief (2-3 sentence) description of the project's purpose and value. +It began as a mouse-calibration chore and turned into something more interesting: a privacy-first instrument for your own visuomotor signature, and how it changes. -## Status +## Why it's interesting -| Aspect | Status | -|--------|--------| -| Development | Active | -| Documentation | In Progress | -| Tests | None | +- **Everyone clones voices; nobody clones movement.** Your pointer path is as individual as a fingerprint — and far less guarded. +- **Predictive vs. reactive.** Track a smooth target and you ride it with ~zero lag. Chase an evasive one and you're ~200 ms behind — pure human reaction time. Silphe measures both, separately. +- **It drifts.** Reaction, accuracy, tremor, tracking — they shift with the time of day, fatigue, a new medication, and the years. Silphe plots the **arc**. +- **Your data never leaves your machine.** Local capture, local model, local analysis. No cloud, no telemetry. Your silly walk is nobody's business but yours. -## Quick Start +## The games (calibration in a clown costume) -```bash -# Installation -poetry install # or npm install +A green-garden field with four tasks: + +- **Acquire** — hit the small gold target (Fitts's law: distance × size) +- **Track** — follow a slowly drifting dot (smooth pursuit) +- **Hold** — keep dead still on a single red pixel (tremor) +- **Andvari** — hunt the roach through the maze: it runs the dark, hides under silver cells, and you switch tools (swatter → pick, press **T**) to flush it out and finish it -# Run -poetry run python src/main.py # or npm start +```bash +python src/silphe/calibrate.py # play (mouse) +python src/silphe/calibrate.py trackpad # tag the session as trackpad ``` -## Project Structure +## See yourself -``` -silphe/ -├── src/ # Application source code -├── tests/ # Test suites -├── docs/ # Documentation -└── tools/ # Development utilities +```bash +python src/silphe/analyze.py # this session's aggregate signature +python src/silphe/analyze_lag.py # are you late? temporal lag vs spatial offset vs noise +python src/silphe/arc.py # the longitudinal dashboard — your fingerprint over time +python src/silphe/human_cursor.py # the cursor model: a human-fidelity move (Windows) +python src/silphe/range_demo.py # human vs robot cursor, side by side (Windows) ``` -## Documentation +Everything is pure standard library (tkinter + ctypes) — nothing to install to play. -- [Architecture](docs/adrs/) - Architecture Decision Records -- [Standards](docs/standards/) - Coding and process standards -- [Runbooks](docs/runbooks/) - Operational procedures +## The science, briefly -## Development +Fitts's law, corrective sub-movements, physiological tremor (4–12 Hz), smooth-pursuit lag, and the difference between getting *faster* and merely *learning the board*. See [`docs/0003-the-science.md`](docs/0003-the-science.md). -This project follows [AssemblyZero](https://github.com/martymcenroe/AssemblyZero) conventions: -- Worktree isolation for all code changes -- Pre-merge gates (implementation + test reports) -- Session logging for agent continuity +## Privacy -## License +Local-first, always — your movement never leaves your computer. See [`docs/0002-privacy.md`](docs/0002-privacy.md). -PolyForm Noncommercial 1.0.0 - See LICENSE file. +## Install (soon) ---- +```bash +pip install silphe +``` + +Coming — see the launch plan in [`docs/0001-launch-plan.md`](docs/0001-launch-plan.md). + +## License -*Managed under [AssemblyZero](https://github.com/martymcenroe/AssemblyZero) governance.* +PolyForm Noncommercial 1.0.0 — see [LICENSE](LICENSE). diff --git a/docs/0001-launch-plan.md b/docs/0001-launch-plan.md new file mode 100644 index 0000000..a39723f --- /dev/null +++ b/docs/0001-launch-plan.md @@ -0,0 +1,31 @@ +# 0001 — Silphe Launch Plan (GTM) + +**Status:** draft (2026-06-11). Planning conventions reused from the boostgauge `blueprint/` methodology. + +## Positioning +"Everyone's cloning voices; nobody's cloning movement." Silphe is a fun, local, privacy-first desktop toy that learns your pointer signature — with a serious second life as a longitudinal visuomotor / cognitive instrument. + +## Channels +- **PyPI:** `pip install silphe`. Package name is secured (`silphe`). See the publish issue. +- **Landing page:** **thrivetech.ai/silphe** — what it is, the privacy promise, a GIF of Andvari, the install one-liner, a link to GitHub. +- **GitHub:** flip the repo public at launch (currently private); good README (done), screenshots/GIFs. +- **Show HN:** the hook — "I built a game that learns how you move the mouse — and can watch it drift when you're tired." Title candidates in the GTM issue. + +## Support inboxes (on thrivetech.ai) +- `hello@thrivetech.ai` — general +- `support@thrivetech.ai` — user support +- `privacy@thrivetech.ai` — privacy / data requests (required by the privacy page) +- `security@thrivetech.ai` — vulnerability reports +- `press@thrivetech.ai` — media + +## Collateral checklist +- [ ] Landing page (thrivetech.ai/silphe) +- [ ] Privacy page published (`docs/0002-privacy.md`) +- [ ] Screenshots + a short GIF (green garden, Andvari, the arc view) +- [ ] Show HN post draft +- [ ] PyPI long-description (the README) +- [ ] Support inboxes provisioned + +## The two-sided story (keep the wall) +1. The **toy** — open-source, free fun. Ships openly: the capture, the games, the visualization, the arc. +2. The **applications** — impairment-aware action gating, decline / medication-effect detection, the patient-co-built cognitive game. These stay **IP**, never in the public repo. Patents filed in `patent-general`: #173, #174, #175, #177, #178. The published tool is the capture/visualization layer only. diff --git a/docs/0002-privacy.md b/docs/0002-privacy.md new file mode 100644 index 0000000..4fcceaa --- /dev/null +++ b/docs/0002-privacy.md @@ -0,0 +1,21 @@ +# Silphe Privacy + +**Short version: your movement data never leaves your computer.** + +## What Silphe records +When you play, Silphe records the path of your pointer — positions and timestamps — and a few per-task numbers (reaction time, accuracy, tremor, tracking). That's all. + +## Where it goes +Nowhere. It is written to a `recordings/` folder on your own machine and stays there. There is: +- **No cloud upload.** +- **No telemetry, no analytics, no "anonymized" exhaust.** +- **No account, no login, no network calls at all** for the core capture, analysis, and arc view. + +## Why this matters +The exact wobble of your hand is about as personal as data gets — it can reveal fatigue, intoxication, and motor or cognitive change. That is precisely why Silphe is built local-first. Your signature is yours. + +## Your data, your control +The recordings are plain text (JSONL) in `recordings/`. Read them, move them, or delete them whenever you like — they're just files on your disk. + +## Contact +privacy@thrivetech.ai diff --git a/docs/0003-the-science.md b/docs/0003-the-science.md new file mode 100644 index 0000000..7cb9530 --- /dev/null +++ b/docs/0003-the-science.md @@ -0,0 +1,23 @@ +# 0003 — The Science + +## Fitts's law +Time to acquire a target depends on its distance and size: `MT = a + b·log2(2D/W)`. The log term (the "index of difficulty," in bits) falls out of your overshoot-and-correct homing — you halve the remaining gap, halve it again, until you land. Your personal `a` (start/stop overhead) and `b` (cost per bit of difficulty) are part of your signature. + +## Predictive vs. reactive tracking +A real, measurable distinction: +- **Smooth pursuit** of a *predictable* target → near-zero lag. You *predict* its path and ride it. +- **Reactive** tracking of an *evasive* target → ~200 ms lag. You can't predict it, so you *react* — and human visuomotor reaction is ~150–250 ms. + +Silphe's **Track** task measures the first; the **Andvari** hunt measures the second. (First real session: ~7 ms lag on the smooth dot, ~230 ms on the roach — textbook. A clean separation of predictive from reactive control.) + +## Tremor +Everyone has physiological tremor (~4–12 Hz); it shows in the steady-hold task. A mouse's mass and surface friction low-pass it (the mouse *hides* your tremor); a trackpad, with almost no inertia, reveals far more. So the **input device is a labeled variable** — same hand, different signature. + +## The arc — and the metacognition of compensation +Over a night of play, reaction time can fall and tracking accuracy can rise. But getting *faster* is not the only way to improve, and Silphe must not confuse the two. + +A player can get **better at predicting where the target tends to get stuck** — learning the board, the pattern — and use that to compensate for reaction time that is *not actually getting quicker.* That is **metacognition substituting strategy for speed.** It is, in particular, how an older brain stays sharp against the slow decline of raw reaction: not by reacting faster, but by needing to react less. + +So the instrument's central question is not "are you scoring better?" but: **are you genuinely quicker, or have you just learned the room?** Telling earned speed from learned compensation — and watching which one carries a person as they age — is a core thing Silphe is built to study. + +*(This distinction was the operator's own observation on night one, watching his roach-hunting scores climb while suspecting, correctly, that his reaction time hadn't moved at all.)* diff --git a/src/silphe/__init__.py b/src/silphe/__init__.py new file mode 100644 index 0000000..bb2133d --- /dev/null +++ b/src/silphe/__init__.py @@ -0,0 +1,3 @@ +"""Silphe — capture your own pointer-movement signature, and watch it drift. Local-first.""" + +__version__ = "0.1.0" diff --git a/src/silphe/analyze.py b/src/silphe/analyze.py new file mode 100644 index 0000000..52c7e76 --- /dev/null +++ b/src/silphe/analyze.py @@ -0,0 +1,115 @@ +""" +analyze.py — read the local calibration recordings and print AGGREGATE stats only. + +No raw coordinates leave this script. It prints summary numbers (movement times, +overshoot, corrections, hold jitter + frequency, a Fitts fit) so we can fit the +movement model to the operator without dumping his every twitch. + + poetry run python talos-mouse-host/analyze.py +""" + +from __future__ import annotations + +import glob +import json +import math +import os +import statistics as st + +REC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") + + +def load(): + trials = [] + files = sorted(glob.glob(os.path.join(REC, "session-*.jsonl"))) + for fp in files: + with open(fp, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + trials.append(json.loads(line)) + return trials, len(files) + + +def acquire_stats(t): + s = t["samples"] + if len(s) < 3: + return None + tx, ty, r = t["target"]["x"], t["target"]["y"], t["target"]["r"] + hx, hy = t["home"]["x"], t["home"]["y"] + mt = s[-1][0] - s[0][0] + plen = sum(math.hypot(s[i + 1][1] - s[i][1], s[i + 1][2] - s[i][2]) + for i in range(len(s) - 1)) + straight = math.hypot(tx - hx, ty - hy) + eff = plen / straight if straight else 1.0 + rev, prev, shrinking = 0, None, True + for (_t, x, y) in s: + d = math.hypot(x - tx, y - ty) + if prev is not None: + if d > prev + 0.5 and shrinking: + rev += 1 + shrinking = False + elif d < prev - 0.5: + shrinking = True + prev = d + ID = math.log2(straight / (2 * r) + 1) # Shannon index of difficulty (bits) + return dict(mt=mt, eff=eff, rev=rev, err=t["click"]["err"], ID=ID) + + +def hold_stats(t): + s = t["samples"] + if len(s) < 10: + return None + tend = s[-1][0] + held = [p for p in s if p[0] >= tend - 1.5] or s # just the steady-hold tail + xs, ys = [p[1] for p in held], [p[2] for p in held] + jit = math.hypot(st.pstdev(xs), st.pstdev(ys)) + mx = st.mean(xs) + dur = held[-1][0] - held[0][0] + crossings = sum(1 for i in range(len(xs) - 1) if (xs[i] - mx) * (xs[i + 1] - mx) < 0) + freq = (crossings / 2) / dur if dur > 0 else 0.0 + return dict(jit=jit, freq=freq) + + +def main(): + trials, nfiles = load() + if not trials: + print("No recordings found in", REC) + print("(Play a session: python talos-mouse-host/calibrate.py)") + return + + acq = [a for a in (acquire_stats(t) for t in trials if t.get("kind") == "acquire") if a] + hold = [h for h in (hold_stats(t) for t in trials if t.get("kind") == "hold") if h] + col = lambda rows, k: [r[k] for r in rows] + + print(f"Parsed {len(trials)} trials from {nfiles} session file(s).") + + print(f"\n=== ACQUIRE - the'attempt' ({len(acq)} trials) ===") + if acq: + print(f" movement time : mean {st.mean(col(acq,'mt')):.2f}s " + f"(min {min(col(acq,'mt')):.2f}, max {max(col(acq,'mt')):.2f})") + print(f" final error : mean {st.mean(col(acq,'err')):.1f}px") + print(f" path wander : mean {st.mean(col(acq,'eff')):.2f}x the straight line") + print(f" corrections : mean {st.mean(col(acq,'rev')):.1f} per acquire " + f"(max {max(col(acq,'rev'))}) <-- the 'tremor in the attempt'") + print(f" difficulty : {min(col(acq,'ID')):.1f}-{max(col(acq,'ID')):.1f} bits (low = easy)") + ids, mts = col(acq, 'ID'), col(acq, 'mt') + mid, mmt = st.mean(ids), st.mean(mts) + denom = sum((i - mid) ** 2 for i in ids) + if denom > 0: + b = sum((ids[i] - mid) * (mts[i] - mmt) for i in range(len(ids))) / denom + a = mmt - b * mid + print(f" Fitts fit : MT = {a:.2f} + {b:.2f}*ID " + f"(your a={a*1000:.0f}ms base, b={b*1000:.0f}ms per bit)") + + print(f"\n=== HOLD - thesteady tremor ({len(hold)} trials) ===") + if hold: + print(f" jitter amp : mean {st.mean(col(hold,'jit')):.2f}px while holding still") + print(f" dominant freq : mean {st.mean(col(hold,'freq')):.1f} Hz") + print(" per hold : " + ", ".join( + f"{h['jit']:.1f}px@{h['freq']:.0f}Hz" for h in hold)) + print() + + +if __name__ == "__main__": + main() diff --git a/src/silphe/analyze_lag.py b/src/silphe/analyze_lag.py new file mode 100644 index 0000000..47b065b --- /dev/null +++ b/src/silphe/analyze_lag.py @@ -0,0 +1,113 @@ +""" +analyze_lag.py — how late is the cursor vs the moving target? + +Lines up the operator's cursor against where the target (track dot / roach) actually +was, and finds the time-shift that best explains the tracking. Separates: + - temporal LAG (you're behind in time) -> "am I late?" + - spatial OFFSET (constant dx/dy bias) -> "graphics/aim off?" + - residual ERROR (noise left after both) -> "do I just suck?" + +Aggregate numbers only; no raw coordinates printed. + + poetry run python talos-mouse-host/analyze_lag.py +""" + +from __future__ import annotations + +import bisect +import glob +import json +import math +import os + +REC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") + + +def latest_session(): + fs = glob.glob(os.path.join(REC, "session-*.jsonl")) + return max(fs, key=os.path.getmtime) if fs else None + + +def interp(path, ts, tq): + if tq <= ts[0]: + return path[0][1], path[0][2] + if tq >= ts[-1]: + return path[-1][1], path[-1][2] + i = bisect.bisect_left(ts, tq) + t0, x0, y0 = path[i - 1] + t1, x1, y1 = path[i] + f = (tq - t0) / (t1 - t0) if t1 > t0 else 0.0 + return x0 + (x1 - x0) * f, y0 + (y1 - y0) * f + + +def near(ts, t, tol=0.08): + i = bisect.bisect_left(ts, t) + return any(0 <= j < len(ts) and abs(ts[j] - t) <= tol for j in (i - 1, i)) + + +def lag_scan(cursor, target, gap_filter=False): + if len(cursor) < 10 or len(target) < 10: + return None + ts = [p[0] for p in target] + results = {} + for lag_ms in range(-40, 460, 20): + lag = lag_ms / 1000.0 + tot = cnt = sdx = sdy = 0.0 + for (t, cx, cy) in cursor: + tq = t - lag + if tq < ts[0] or tq > ts[-1]: + continue + if gap_filter and not near(ts, tq): # skip times the roach was hidden + continue + tx, ty = interp(target, ts, tq) + tot += math.hypot(cx - tx, cy - ty) + sdx += cx - tx + sdy += cy - ty + cnt += 1 + if cnt >= 10: + results[lag_ms] = (tot / cnt, sdx / cnt, sdy / cnt) + if not results: + return None + best = min(results, key=lambda k: results[k][0]) + err, dx, dy = results[best] + zero = results.get(0, (None,))[0] + return dict(lag_ms=best, err=err, dx=dx, dy=dy, zero_err=zero) + + +def main(): + sf = latest_session() + if not sf: + print("No recordings. Play a session first.") + return + rounds = [json.loads(l) for l in open(sf, encoding="utf-8") if l.strip()] + print(f"Session: {os.path.basename(sf)} ({len(rounds)} rounds)") + + for kind, tkey, gap in (("track", "dot", False), ("evasive", "path", True)): + scans = [] + for r in rounds: + if r.get("kind") != kind or not r.get(tkey) or not r.get("samples"): + continue + cur, tgt = r["samples"], r[tkey] + if kind == "track": # only the steady, post-lock pursuit + lk = r.get("locked_at", 0) + cur = [s for s in cur if s[0] >= lk] + tgt = [d for d in tgt if d[0] >= lk] + s = lag_scan(cur, tgt, gap) + if s: + scans.append(s) + if not scans: + print(f"\n{kind.upper()}: no usable rounds") + continue + avg = lambda k: sum(s[k] for s in scans) / len(scans) + ze = [s["zero_err"] for s in scans if s["zero_err"] is not None] + print(f"\n=== {kind.upper()} ({len(scans)} rounds) ===") + print(f" you lag the target by : {avg('lag_ms'):+.0f} ms <- how far BEHIND you are in time") + print(f" error at THAT lag : {avg('err'):.1f} px (how tight you are once your delay is removed)") + if ze: + print(f" error at zero lag : {sum(ze)/len(ze):.1f} px (how it looks if you had no reaction delay)") + print(f" constant aim offset : dx {avg('dx'):+.1f}px, dy {avg('dy'):+.1f}px (a real bias, or graphics, if big)") + print() + + +if __name__ == "__main__": + main() diff --git a/src/silphe/arc.py b/src/silphe/arc.py new file mode 100644 index 0000000..6ace6cc --- /dev/null +++ b/src/silphe/arc.py @@ -0,0 +1,185 @@ +""" +arc.py — the longitudinal "arc" view (the #177 cognitive-assessment dashboard). + +Reads every session in recordings/ and plots your cognitive fingerprint drifting +over time: reaction, accuracy, speed, tracking, tremor — one point per session, +with a trend (improving / declining) once there's more than one. Plus a +"tonight, round by round" strip so there's a real line on night one. + +Zero dependencies (tkinter + stdlib). Local only. + + poetry run python talos-mouse-host/arc.py # the dashboard + poetry run python talos-mouse-host/arc.py --text # headless summary +""" + +from __future__ import annotations + +import glob +import json +import math +import os +import statistics as st +import sys +import tkinter as tk + +REC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") +KIND_COLOR = {"acquire": "#e3b341", "track": "#a371f7", "hold": "#58a6ff", "evasive": "#d29922"} + + +def load_sessions(): + out = [] + for fp in sorted(glob.glob(os.path.join(REC, "session-*.jsonl"))): + rounds = [json.loads(l) for l in open(fp, encoding="utf-8") if l.strip()] + if not rounds: + continue + parts = os.path.basename(fp)[:-6].split("-") # session-- + ts = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else int(os.path.getmtime(fp)) + device = parts[2] if len(parts) > 2 else "?" + out.append({"ts": ts, "device": device, "rounds": rounds}) + out.sort(key=lambda s: s["ts"]) + return out + + +def hold_jitter(r): + s = r.get("samples", []) + if len(s) < 10: + return None + tend = s[-1][0] + tail = [p for p in s if p[0] >= tend - 1.0] or s + return math.hypot(st.pstdev([p[1] for p in tail]), st.pstdev([p[2] for p in tail])) + + +def _med(vals, lo=None, hi=None): + # median, with implausible values (pauses, walk-aways) filtered out + v = [x for x in vals if x is not None + and (lo is None or x >= lo) and (hi is None or x <= hi)] + return st.median(v) if v else None + + +def metrics(rounds): + by = lambda k: [r for r in rounds if r.get("kind") == k] + acq = by("acquire") + rt = _med([r["reaction_s"] for r in rounds if r.get("reaction_s") is not None], 0.05, 5.0) + mt = _med([max(0.0, r["samples"][-1][0] - (r.get("reaction_s") or 0)) + for r in acq if r.get("samples")], 0, 15) + return { + "reaction_ms": rt * 1000 if rt is not None else None, + "acquire_err": _med([r["click"]["err"] for r in acq if r.get("click")], 0, 400), + "acquire_s": mt, + "track_pct": _med([r["on_target_pct"] for r in by("track") if r.get("on_target_pct") is not None]), + "tremor_px": _med([j for j in (hold_jitter(r) for r in by("hold")) if j is not None], 0, 40), + } + + +PANELS = [ + ("reaction_ms", "REACTION", "down", lambda v: f"{v:.0f} ms"), + ("acquire_err", "ACCURACY (miss)", "down", lambda v: f"{v:.1f} px"), + ("acquire_s", "ACQUIRE SPEED", "down", lambda v: f"{v:.2f} s"), + ("track_pct", "TRACKING", "up", lambda v: f"{v:.0f}%"), + ("tremor_px", "TREMOR", "down", lambda v: f"{v:.1f} px"), +] + + +def text_report(sessions): + print(f"{len(sessions)} session(s) in {REC}\n") + for s in sessions: + m = metrics(s["rounds"]) + cells = " ".join(f"{k.split('_')[0]}={('-' if v is None else f'{v:.1f}')}" for k, v in m.items()) + print(f" #{sessions.index(s)+1} [{s['device']:>8}] {len(s['rounds']):>2} rounds {cells}") + print() + + +# ---- GUI ---------------------------------------------------------------- + +def chart(cv, x, y, w, h, title, series, better, fmt): + cv.create_rectangle(x, y, x + w, y + h, outline="#30363d", fill="#0d1117") + cv.create_text(x + 10, y + 15, text=title, anchor="w", fill="#8b949e", font=("Consolas", 11, "bold")) + vals = [v for v in series if v is not None] + if not vals: + cv.create_text(x + w / 2, y + h / 2, text="no data", fill="#6e7681", font=("Consolas", 10)) + return + cv.create_text(x + w - 10, y + 15, text=fmt(vals[-1]), anchor="e", fill="#f0f6fc", font=("Consolas", 14, "bold")) + px0, py0, px1, py1 = x + 14, y + 34, x + w - 14, y + h - 14 + lo, hi = min(vals), max(vals) + if hi == lo: + hi = lo + 1 + n = max(1, len(series) - 1) + fx = lambda i: px0 + (px1 - px0) * (i / n) + fy = lambda v: py1 - (py1 - py0) * ((v - lo) / (hi - lo)) + pts = [(fx(i), fy(v)) for i, v in enumerate(series) if v is not None] + if len(pts) >= 2: + cv.create_line(*sum(([a, b] for a, b in pts), []), fill="#58a6ff", width=2) + for a, b in pts: + cv.create_oval(a - 3, b - 3, a + 3, b + 3, fill="#58a6ff", outline="") + if len(vals) >= 2: + improving = (vals[-1] < vals[0]) if better == "down" else (vals[-1] > vals[0]) + col = "#39d353" if improving else "#f85149" + cv.create_text(x + 10, y + h - 10, text=("improving" if improving else "declining"), + anchor="w", fill=col, font=("Consolas", 10, "bold")) + else: + cv.create_text(x + 10, y + h - 10, text="baseline — play again to start the arc", + anchor="w", fill="#6e7681", font=("Consolas", 9)) + + +def tonight_strip(cv, x, y, w, h, rounds): + cv.create_rectangle(x, y, x + w, y + h, outline="#30363d", fill="#0d1117") + cv.create_text(x + 10, y + 15, text="TONIGHT — reaction time, round by round (ms)", + anchor="w", fill="#8b949e", font=("Consolas", 11, "bold")) + series = [(r.get("kind"), (r.get("reaction_s") or 0) * 1000) for r in rounds] + vals = [v for _, v in series] + if not vals: + return + px0, py0, px1, py1 = x + 14, y + 34, x + w - 14, y + h - 22 + lo, hi = min(vals), max(vals) + if hi == lo: + hi = lo + 1 + n = max(1, len(series) - 1) + fx = lambda i: px0 + (px1 - px0) * (i / n) + fy = lambda v: py1 - (py1 - py0) * ((v - lo) / (hi - lo)) + line = [coord for i, (_, v) in enumerate(series) for coord in (fx(i), fy(v))] + if len(line) >= 4: + cv.create_line(*line, fill="#484f58", width=1) + for i, (kind, v) in enumerate(series): + c = KIND_COLOR.get(kind, "#58a6ff") + cv.create_oval(fx(i) - 4, fy(v) - 4, fx(i) + 4, fy(v) + 4, fill=c, outline="") + legend = " ".join(f"● {k}" for k in KIND_COLOR) + cv.create_text(x + 14, y + h - 9, text=legend, anchor="w", fill="#6e7681", font=("Consolas", 9)) + + +def gui(sessions): + root = tk.Tk() + root.title("The Ministry of Silly Mice — Arc") + root.configure(bg="#010409") + W, H = 1120, 720 + cv = tk.Canvas(root, width=W, height=H, bg="#010409", highlightthickness=0) + cv.pack(fill="both", expand=True) + + series = {key: [metrics(s["rounds"]).get(key) for s in sessions] for key, *_ in PANELS} + devices = "/".join(sorted({s["device"] for s in sessions})) + cv.create_text(20, 22, anchor="w", fill="#f0f6fc", font=("Consolas", 15, "bold"), + text=f"Your Arc — {len(sessions)} session(s) · {devices}") + + cols, cw, ch, gap = 3, 350, 175, 15 + for idx, (key, title, better, fmt) in enumerate(PANELS): + cx = 20 + (idx % cols) * (cw + gap) + cy = 48 + (idx // cols) * (ch + gap) + chart(cv, cx, cy, cw, ch, title, series[key], better, fmt) + + tonight_strip(cv, 20, 48 + 2 * (ch + gap), 1080, 175, sessions[-1]["rounds"]) + root.bind("", lambda e: root.destroy()) + root.mainloop() + + +def main(): + sessions = load_sessions() + if not sessions: + print("No recordings yet. Play a session: python talos-mouse-host/calibrate.py") + return + if "--text" in sys.argv: + text_report(sessions) + else: + gui(sessions) + + +if __name__ == "__main__": + main() diff --git a/src/silphe/calibrate.py b/src/silphe/calibrate.py new file mode 100644 index 0000000..c37ce99 --- /dev/null +++ b/src/silphe/calibrate.py @@ -0,0 +1,510 @@ +""" +calibrate.py (v2.x) — the green-garden calibration range. + +Records how the operator actually moves, across four task types laid over a +GitHub-contribution-graph field of squares: + + ACQUIRE — click the small gold square (smaller targets + visual clutter) + TRACK — follow a slowly-moving dot; it's quick until you lock on, then it + settles and the clock starts (smooth pursuit) + HOLD — hold dead still on a single red pixel inside a white dot (tremor test) + EVASIVE — "Andvari": the roach runs the dark grid (green = walls), ducks + under silver hide-cells (they pulse red), thump it there; several hits + +Everything stays on your machine (talos-mouse-host/recordings/*.jsonl). Each +record is stamped with device + OS. ESC quits; progress is saved as you go. + + poetry run python talos-mouse-host/calibrate.py # mouse (default) + poetry run python talos-mouse-host/calibrate.py trackpad # tag the session as trackpad +""" + +from __future__ import annotations + +import json +import math +import os +import platform +import random +import sys +import time +import tkinter as tk + +REC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") +VERSION = "Andvari" +HOLD_SECS = 1.2 +TRACK_SECS = 4.0 +GREENS = ["#0e4429", "#006d32", "#26a641", "#39d353"] + + +class Garden: + def __init__(self, root: tk.Tk): + self.root = root + root.title(f"The Ministry of Silly Mice — {VERSION}") + root.configure(bg="#0d1117") + self.W, self.H = 1200, 760 + self.canvas = tk.Canvas(root, width=self.W, height=self.H, + bg="#0d1117", highlightthickness=0) + self.canvas.pack(fill="both", expand=True) + + self.CELL, self.GAP, self.COLS, self.ROWS = 30, 6, 30, 15 + gw = self.COLS * (self.CELL + self.GAP) - self.GAP + gh = self.ROWS * (self.CELL + self.GAP) - self.GAP + self.ox, self.oy = (self.W - gw) // 2, (self.H - gh) // 2 + 12 + + self.device = (sys.argv[1] if len(sys.argv) > 1 else "mouse").lower() + self.os = platform.system() + os.makedirs(REC_DIR, exist_ok=True) + self.path = os.path.join(REC_DIR, f"session-{int(time.time())}-{self.device}.jsonl") + self.fh = open(self.path, "a", encoding="utf-8") + + self.cells, self.base = {}, {} + self._draw_field() + + self.plan = self._make_plan() + self.i = 0 + self.state = "idle" + self.samples = [] + self.t0 = 0.0 + self.first_move = None + self.last = (self.W // 2, self.H // 2) + self.target = None + self.inside_since = None + self.hides = set() + self.walls = set() + self.tool = "swatter" + + self.canvas.bind("", self._motion) + self.canvas.bind("", self._click) + root.bind("", lambda e: self._finish()) + root.bind("t", self._switch_tool) + root.bind("T", self._switch_tool) + self._next() + + # ---- geometry / field ---------------------------------------------- + + def _cell_xy(self, r, c): + return self.ox + c * (self.CELL + self.GAP), self.oy + r * (self.CELL + self.GAP) + + def _center(self, r, c): + x0, y0 = self._cell_xy(r, c) + return x0 + self.CELL / 2, y0 + self.CELL / 2 + + def _draw_field(self): + for r in range(self.ROWS): + for c in range(self.COLS): + x0, y0 = self._cell_xy(r, c) + col = random.choice(GREENS) if random.random() < 0.16 else "#161b22" + self.cells[(r, c)] = self.canvas.create_rectangle( + x0, y0, x0 + self.CELL, y0 + self.CELL, fill=col, outline="#0d1117") + self.base[(r, c)] = col + + def _pick(self): + return random.randint(0, self.ROWS - 1), random.randint(0, self.COLS - 1) + + # ---- flow ----------------------------------------------------------- + + def _make_plan(self): + kinds = ["acquire"] * 4 + ["track"] * 3 + ["hold"] * 3 + ["evasive"] * 2 + random.shuffle(kinds) + return kinds + + def _hud(self, msg, color="#8b949e", sub=None): + self.canvas.delete("hud") + self.canvas.create_text(self.W // 2, 22, text=msg, fill=color, tag="hud", + font=("Consolas", 15, "bold")) + self.canvas.create_text(self.W // 2, self.H - 12, fill="#6e7681", tag="hud", + font=("Consolas", 10), + text=f"round {min(self.i + 1, len(self.plan))}/{len(self.plan)}" + f" · ESC to stop (saved as you go)") + if sub: + self.canvas.create_text(self.W // 2, 46, fill="#6e7681", tag="hud", + font=("Consolas", 11), text=sub) + + def _toast(self, rc, text, good): + cx, cy = self._center(*rc) + self._toast_xy(cx, cy, text, good) + + def _toast_xy(self, x, y, text, good): + self.canvas.delete("toast") # only the latest message — no more clogging + tid = self.canvas.create_text(x, y - self.CELL, text=text, tags=("toast",), + fill="#39d353" if good else "#f85149", + font=("Consolas", 12, "bold")) + self.root.after(700, lambda: self.canvas.delete(tid)) + + def _hit_mark(self, x, y, n): # persistent numbered hit — stays for the round + self.canvas.create_oval(x - 8, y - 8, x + 8, y + 8, outline="#f0f6fc", width=1, tag="hitmark") + self.canvas.create_text(x, y, text=str(n), fill="#f0f6fc", + font=("Consolas", 9, "bold"), tag="hitmark") + + def _restore(self): + if self.target and "cell" in self.target: + rc = self.target["cell"] + self.canvas.itemconfig(self.cells[rc], fill=self.base[rc]) + for rc in self.hides: + self.canvas.itemconfig(self.cells[rc], fill=self.base[rc]) + self.hides = set() + self.canvas.delete("mark", "ring", "dot", "roach", "tool", "toast", "hitmark") + + def _next(self): + self._restore() + self.canvas.delete("hud") + if self.i >= len(self.plan): + return self._finish() + self.samples, self.t0 = [], time.perf_counter() + self.first_move, self.inside_since = None, None + getattr(self, "_begin_" + self.plan[self.i])() + + # ---- recording ------------------------------------------------------ + + def _motion(self, e): + self.last = (e.x, e.y) + if self.state in ("acquire", "track", "hold", "evasive"): + t = time.perf_counter() - self.t0 + self.samples.append((round(t, 4), e.x, e.y)) + if self.first_move is None: + self.first_move = t + + def _save(self, obj): + obj["device"], obj["os"] = self.device, self.os + self.fh.write(json.dumps(obj) + "\n") + self.fh.flush() + + # ---- ACQUIRE -------------------------------------------------------- + + def _begin_acquire(self): + rc = self._pick() + cx, cy = self._center(*rc) + self.target = {"cell": rc, "x": cx, "y": cy, "r": self.CELL / 2} + self.canvas.itemconfig(self.cells[rc], fill="#e3b341") + self._hud("Click the GOLD square.", "#e3b341", + sub="small target, in the weeds — find it and hit it") + self.state = "acquire" + + # ---- TRACK (follow the slowly-moving dot) -------------------------- + + def _begin_track(self): + sq = 90 + x0 = random.randint(self.ox + 20, self.W - 20 - sq) + y0 = random.randint(72, self.H - 64 - sq) + cx, cy = x0 + sq / 2, y0 + sq / 2 + self.target = { + "x0": x0, "y0": y0, "sq": sq, "cx": cx, "cy": cy, + "rd": sq / 8, "tol": sq / 8 + 5, # dot = 1/4 the square's width + "w1": 2 * math.pi * 0.18, "w2": 2 * math.pi * 0.13, # slow, incommensurate -> smooth wander + "ph1": random.uniform(0, 6.28), "ph2": random.uniform(0, 6.28), + "locked": False, "lock_t": 0.0, "last_tick": time.perf_counter(), + "dot": [], "on": 0, "tot": 0, + } + self.canvas.create_rectangle(x0, y0, x0 + sq, y0 + sq, + fill="#241a3d", outline="#a371f7", width=2, tag="mark") + self.canvas.create_oval(0, 0, 0, 0, fill="#d2a8ff", outline="", tag="dot") + self._hud("Catch the moving dot to start.", "#a371f7", + sub="it's quick until you lock on — then it settles and the clock runs") + self.state = "track" + self._track_tick() + + def _track_tick(self): + if self.state != "track": + return + tg = self.target + now = time.perf_counter() + dt = now - tg["last_tick"] + tg["last_tick"] = now + sf = 1.0 if tg["locked"] else 2.4 # fast until you lock on + tg["ph1"] += tg["w1"] * dt * sf + tg["ph2"] += tg["w2"] * dt * sf + amp = tg["sq"] / 2 - tg["rd"] - 3 + dx = tg["cx"] + amp * math.sin(tg["ph1"]) + dy = tg["cy"] + amp * math.sin(tg["ph2"]) + rd = tg["rd"] + self.canvas.coords("dot", dx - rd, dy - rd, dx + rd, dy + rd) + tg["dot"].append((round(now - self.t0, 4), round(dx, 1), round(dy, 1))) + x, y = self.last + on = math.hypot(x - dx, y - dy) <= tg["tol"] + self.canvas.itemconfig("dot", fill="#39d353" if on else "#d2a8ff") + + if not tg["locked"]: + if on: + tg["locked"], tg["lock_t"] = True, now + self._hud("Locked — now STAY on it.", "#39d353", sub="follow it as it drifts") + return self.root.after(16, self._track_tick) + + tg["tot"] += 1 + tg["on"] += 1 if on else 0 + el = now - tg["lock_t"] + self.canvas.delete("ring") + self.canvas.create_rectangle(tg["x0"], tg["y0"] + tg["sq"] + 6, + tg["x0"] + tg["sq"] * min(1.0, el / TRACK_SECS), + tg["y0"] + tg["sq"] + 10, fill="#a371f7", outline="", tag="ring") + if el >= TRACK_SECS: + pct = round(100 * tg["on"] / max(1, tg["tot"])) + self._save({"kind": "track", "square": {"x": tg["x0"], "y": tg["y0"], "size": tg["sq"]}, + "reaction_s": round(self.first_move or 0, 4), + "locked_at": round(tg["lock_t"] - self.t0, 4), + "on_target_pct": pct, "dot": tg["dot"], "samples": self.samples}) + self.canvas.delete("dot") + self._hud(f"Tracked - {pct}% glued to it.", "#39d353") + self.state = "idle" + self.i += 1 + return self.root.after(800, self._next) + self.root.after(16, self._track_tick) + + # ---- HOLD (single red pixel — the tremor test) --------------------- + + def _begin_hold(self): + rc = self._pick() + cx, cy = self._center(*rc) + self.target = {"cell": rc, "x": cx, "y": cy, "r": 5.0} + self.canvas.itemconfig(self.cells[rc], fill="#1f6feb") + self.canvas.create_oval(cx - 5, cy - 5, cx + 5, cy + 5, + outline="#f0f6fc", width=1, tag="mark") + self.canvas.create_rectangle(cx, cy, cx + 1, cy + 1, + fill="#ff3b30", outline="", tag="mark") + self._hud("Hold STEADY on the single red pixel.", "#ff7b72", + sub="one pixel, inside the white dot — your hand vs the mouse's inertia") + self.state = "hold" + self._dwell_tick("hold", HOLD_SECS, "STEADY") + + def _dwell_tick(self, kind, secs, ok_text): + if self.state != kind: + return + cx, cy, tol = self.target["x"], self.target["y"], self.target["r"] + x, y = self.last + now = time.perf_counter() + self.canvas.delete("ring") + if math.hypot(x - cx, y - cy) <= tol: + if self.inside_since is None: + self.inside_since = now + frac = min(1.0, (now - self.inside_since) / secs) + rr = tol + 9 + self.canvas.create_arc(cx - rr, cy - rr, cx + rr, cy + rr, start=90, + extent=-360 * frac, style="arc", + outline="#39d353", width=3, tag="ring") + if now - self.inside_since >= secs: + self._save({"kind": kind, "target": {"x": cx, "y": cy, "r": tol}, + "reaction_s": round(self.first_move or 0, 4), + "samples": self.samples}) + self._toast(self.target["cell"], ok_text, True) + self.state = "idle" + self.i += 1 + return self.root.after(700, self._next) + else: + self.inside_since = None + self.root.after(20, lambda: self._dwell_tick(kind, secs, ok_text)) + + # ---- EVASIVE: "Andvari" — the Pac-Man maze roach ------------------ + + def _begin_evasive(self): + self.walls = {rc for rc, col in self.base.items() if col in GREENS} + paths = [rc for rc in self.cells if rc not in self.walls] + self.hides = set(random.sample(paths, min(5, len(paths)))) + for rc in self.hides: + self.canvas.itemconfig(self.cells[rc], fill="#b1bac4") # silver hide-holes + start = random.choice([rc for rc in paths if rc not in self.hides] or paths) + cx, cy = self._center(*start) + self.tool = "swatter" + self.target = { + "cell": start, "to": start, "prog": 1.0, "px": cx, "py": cy, + "health": random.randint(4, 6), "hp0": 0, "speed": 10.0, + "hidden": False, "hide_cell": None, "hide_until": 0.0, + "burst_until": 0.0, "pause_until": 0.0, "want_hide": False, + "last": time.perf_counter(), "path": [], "switches": [], + } + self.target["hp0"] = self.target["health"] + self._hud("ANDVARI — hunt the roach.", "#d29922", + sub="SWATTER for the runner; press T for the PICK to stab it in its silver hole") + self.state = "evasive" + self._show_tool() + self._roach_tick() + + def _switch_tool(self, e=None): + if self.state != "evasive": + return + self.tool = "pick" if self.tool == "swatter" else "swatter" + self.target["switches"].append((round(time.perf_counter() - self.t0, 4), self.tool)) + self._show_tool() + + def _show_tool(self): + self.canvas.delete("tool") + if self.state != "evasive": + return + name = "SWATTER" if self.tool == "swatter" else "PICK (sharp)" + self.canvas.create_text(self.W - 120, 22, text=f"[T] tool: {name}", fill="#e3b341", + tag="tool", font=("Consolas", 12, "bold")) + + def _neighbors(self, rc): + r, c = rc + out = [] + for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nb = (r + dr, c + dc) + if 0 <= nb[0] < self.ROWS and 0 <= nb[1] < self.COLS and nb not in self.walls: + out.append(nb) + return out + + def _wander(self, rc, mx, my, fleeing): + nb = self._neighbors(rc) + if not nb: + return rc + if fleeing: + return max(nb, key=lambda cell: math.hypot(self._center(*cell)[0] - mx, + self._center(*cell)[1] - my)) + return random.choice(nb) + + def _toward(self, rc, goal): + nb = self._neighbors(rc) + if not nb or goal is None: + return None + return min(nb, key=lambda cell: abs(cell[0] - goal[0]) + abs(cell[1] - goal[1])) + + def _nearest_hide(self, rc): + if not self.hides: + return None + return min(self.hides, key=lambda h: abs(h[0] - rc[0]) + abs(h[1] - rc[1])) + + def _draw_roach(self, ax, ay, bx, by): + self.canvas.delete("roach") + tg = self.target + x, y = tg["px"], tg["py"] + ang = math.atan2(by - ay, bx - ax) if (bx != ax or by != ay) else 0.0 + body = "#a9712f" if tg["health"] < tg["hp0"] else "#6e4b1f" + self.canvas.create_oval(x - 8, y - 5, x + 8, y + 5, fill=body, outline="#3d2b12", tag="roach") + dx, dy = math.cos(ang), math.sin(ang) + px, py = -dy, dx + self.canvas.create_line(x + dx * 7, y + dy * 7, x + dx * 15 - px * 4, y + dy * 15 - py * 4, + fill="#3d2b12", width=2, tag="roach") + self.canvas.create_line(x + dx * 7, y + dy * 7, x + dx * 15 + px * 4, y + dy * 15 + py * 4, + fill="#3d2b12", width=2, tag="roach") + self.canvas.create_text(x, y - 14, text="•" * tg["health"], fill="#f85149", + font=("Consolas", 9, "bold"), tag="roach") + + def _roach_tick(self): + if self.state != "evasive": + return + tg = self.target + now = time.perf_counter() + dt = now - tg["last"] + tg["last"] = now + mx, my = self.last + + if tg["hidden"]: # lurks under the silver cell — pulses, won't leave on its own + self.canvas.itemconfig(self.cells[tg["hide_cell"]], + fill="#f85149" if (now * 4) % 2 < 1 else "#b1bac4") + return self.root.after(16, self._roach_tick) + + fleeing = now < tg["burst_until"] + speed = tg["speed"] * (2.0 if fleeing else 1.0) + if now < tg["pause_until"] and not fleeing: + speed = 0.0 + tg["prog"] += speed * dt + if tg["prog"] >= 1.0: # arrived in the next cell — decide + tg["prog"], tg["cell"] = 0.0, tg["to"] + ccx, ccy = self._center(*tg["cell"]) + if math.hypot(mx - ccx, my - ccy) < 95 and now > tg["burst_until"]: + tg["burst_until"] = now + 0.5 + tg["want_hide"] = random.random() < 0.3 + elif random.random() < 0.12 and now > tg["burst_until"]: + tg["burst_until"] = now + random.uniform(0.2, 0.5) # chaotic random darts + if tg["cell"] in self.hides and (tg["want_hide"] or random.random() < 0.4): + tg["hidden"], tg["hide_cell"] = True, tg["cell"] + tg["picked_this_hide"], tg["want_hide"] = False, False + self.canvas.delete("roach") + return self.root.after(16, self._roach_tick) + if tg["want_hide"]: + tg["to"] = (self._toward(tg["cell"], self._nearest_hide(tg["cell"])) + or self._wander(tg["cell"], mx, my, True)) + else: + if random.random() < 0.02: + tg["pause_until"] = now + random.uniform(0.1, 0.3) + tg["to"] = self._wander(tg["cell"], mx, my, fleeing) + ax, ay = self._center(*tg["cell"]) + bx, by = self._center(*tg["to"]) + tg["px"] = ax + (bx - ax) * tg["prog"] + tg["py"] = ay + (by - ay) * tg["prog"] + tg["path"].append((round(now - self.t0, 4), round(tg["px"], 1), round(tg["py"], 1))) + self._draw_roach(ax, ay, bx, by) + self.root.after(16, self._roach_tick) + + # ---- click router --------------------------------------------------- + + def _click(self, e): + if self.state == "acquire": + tx, ty, r = self.target["x"], self.target["y"], self.target["r"] + err = math.hypot(e.x - tx, e.y - ty) + start = self.samples[0] if self.samples else (0, e.x, e.y) + self._save({"kind": "acquire", "target": {"x": tx, "y": ty, "r": r}, + "home": {"x": start[1], "y": start[2]}, + "click": {"x": e.x, "y": e.y, "err": round(err, 1)}, + "reaction_s": round(self.first_move or 0, 4), "samples": self.samples}) + self._toast(self.target["cell"], "GOT IT" if err <= r * 1.4 else "missed", err <= r * 1.4) + self.state = "idle" + self.i += 1 + self.root.after(700, self._next) + elif self.state == "evasive": + tg = self.target + if tg["hidden"]: # in the hole -> PICK only + if self.tool != "pick": + return self._toast_xy(e.x, e.y, "need the PICK [T]", False) + hc = tg["hide_cell"] + x0, y0 = self._cell_xy(*hc) + if not (x0 <= e.x <= x0 + self.CELL and y0 <= e.y <= y0 + self.CELL): + return + # a stab into the big hole ALWAYS scares it out and burns the hole for good, + # but only lands a wound 1-in-4 + self.canvas.itemconfig(self.cells[hc], fill=self.base[hc]) + self.hides.discard(hc) # it won't hide here again this round + tg["hidden"], tg["hide_cell"] = False, None + tg["prog"], tg["burst_until"] = 0.0, time.perf_counter() + 0.4 + tg["to"] = self._wander(tg["cell"], e.x, e.y, True) + if random.random() < 0.25: + tg["health"] -= 1 + tg["speed"] *= 0.88 + tg["hit_n"] = tg.get("hit_n", 0) + 1 + self._hit_mark(e.x, e.y, tg["hit_n"]) + if tg["health"] <= 0: + self._save({"kind": "evasive", "hits": tg["hp0"], "switches": tg["switches"], + "reaction_s": round(self.first_move or 0, 4), + "path": tg["path"], "samples": self.samples}) + self.canvas.delete("roach") + self._toast_xy(e.x, e.y, "SQUASHED", True) + self.state = "idle" + self.i += 1 + return self.root.after(800, self._next) + return self._toast_xy(e.x, e.y, "STABBED it!", True) + return self._toast_xy(e.x, e.y, "missed — it bolted out", False) + + if self.tool != "swatter": # on the run -> SWATTER + return self._toast_xy(e.x, e.y, "use the SWATTER [T]", False) + if math.hypot(e.x - tg["px"], e.y - tg["py"]) > 14: + return + tg["health"] -= 1 + tg["speed"] *= 0.88 # wounded wood roach slows down + tg["hit_n"] = tg.get("hit_n", 0) + 1 + self._hit_mark(e.x, e.y, tg["hit_n"]) + if tg["health"] <= 0: + self._save({"kind": "evasive", "hits": tg["hp0"], "switches": tg["switches"], + "reaction_s": round(self.first_move or 0, 4), + "path": tg["path"], "samples": self.samples}) + self.canvas.delete("roach") + self._toast_xy(e.x, e.y, "SQUASHED", True) + self.state = "idle" + self.i += 1 + return self.root.after(800, self._next) + self._toast_xy(e.x, e.y, "hit!", True) + + # ---- end ------------------------------------------------------------ + + def _finish(self): + try: + self.fh.close() + except Exception: + pass + self.canvas.delete("all") + self._hud("Done — thank you. Your movement is saved.", "#39d353", + sub=os.path.basename(self.path)) + self.state = "done" + + +if __name__ == "__main__": + root = tk.Tk() + Garden(root) + root.mainloop() diff --git a/src/silphe/human_cursor.py b/src/silphe/human_cursor.py new file mode 100644 index 0000000..16f9632 --- /dev/null +++ b/src/silphe/human_cursor.py @@ -0,0 +1,309 @@ +""" +human_cursor.py — the keystone of ADR 0202 (Human-Fidelity Input). + +Moves the *real* Windows cursor to a target and issues an OS-level click, so the +event the page sees is `isTrusted: true` — indistinguishable from a physical +mouse. The motion is deliberately NOT a smooth Bezier curve: it is a ballistic +launch that overshoots, a few corrective sub-movements homing in, continuous +low-amplitude tremor, and a pre-click dwell — sampled fresh every call, never +the same path twice. + +Pure standard library: ctypes drives Win32 SendInput/SetCursorPos. No pip. + +Run it directly for a safe, click-free smoke test (it moves the cursor in a +small wander near where it already is): + + poetry run python talos-mouse-host/human_cursor.py +""" + +from __future__ import annotations + +import ctypes +import math +import random +import time +from ctypes import wintypes + +# -------------------------------------------------------------------------- +# Win32 plumbing (stdlib ctypes) +# -------------------------------------------------------------------------- + +_user32 = ctypes.windll.user32 +_winmm = ctypes.windll.winmm + +# Make the process DPI-aware so our pixel coordinates match the cursor's. +# This is the coordinate-mapping foot-gun ADR 0202 flags for large/scaled +# displays; setting it here keeps tkinter coords and SetCursorPos in agreement. +try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE_V2-ish +except Exception: + try: + _user32.SetProcessDPIAware() + except Exception: + pass + +MOUSEEVENTF_LEFTDOWN = 0x0002 +MOUSEEVENTF_LEFTUP = 0x0004 +INPUT_MOUSE = 0 +_PUL = ctypes.POINTER(ctypes.c_ulong) + + +class _MOUSEINPUT(ctypes.Structure): + _fields_ = ( + ("dx", wintypes.LONG), + ("dy", wintypes.LONG), + ("mouseData", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", _PUL), + ) + + +class _INPUT(ctypes.Structure): + class _I(ctypes.Union): + _fields_ = (("mi", _MOUSEINPUT),) + + _anonymous_ = ("i",) + _fields_ = (("type", wintypes.DWORD), ("i", _I)) + + +_user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int) +_user32.SendInput.restype = wintypes.UINT +_user32.SetCursorPos.argtypes = (ctypes.c_int, ctypes.c_int) +_user32.GetCursorPos.argtypes = (ctypes.POINTER(wintypes.POINT),) + + +def get_pos() -> tuple[int, int]: + pt = wintypes.POINT() + _user32.GetCursorPos(ctypes.byref(pt)) + return pt.x, pt.y + + +def set_pos(x: float, y: float) -> None: + _user32.SetCursorPos(int(round(x)), int(round(y))) + + +def _send_flag(flag: int) -> None: + extra = ctypes.c_ulong(0) + mi = _MOUSEINPUT(0, 0, 0, flag, 0, ctypes.pointer(extra)) + inp = _INPUT(type=INPUT_MOUSE) + inp.mi = mi + _user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(inp)) + + +# -------------------------------------------------------------------------- +# Movement profiles (calibration target: replace DEFAULT with the operator's +# fitted parameters once #187 records real movement) +# -------------------------------------------------------------------------- + +DEFAULT_PROFILE = { + "tremor_amp": 2.0, # px — physiological micro-tremor amplitude + "tremor_hz": 6.0, # Hz — slowed per operator feedback ("vibrates too fast") + "dwell_amp": 1.6, # px — jitter while hovering before the click + "dwell_mean": 0.18, # s — heavy-tailed dwell, centered near ~0.2 s + "overshoot": (0.02, 0.09), # ballistic overshoot as fraction of distance + "corrections": ([1, 2, 3, 4], [0.25, 0.40, 0.25, 0.10]), # nudge-overshoot-retry hops + "settle": (0.03, 0.09), # s — brief pause between hops (the "try again") + "bow": 0.06, # max sideways arc as fraction of segment length + "speed": 1.0, # global time multiplier (smaller = faster) +} + +# A heavier-tremor profile — a placeholder standing in for the operator's own +# signature (62, a palsy) until real calibration data is fitted in #187. +TREMOR_PROFILE = { + **DEFAULT_PROFILE, + "tremor_amp": 5.5, + "tremor_hz": 5.0, + "dwell_amp": 3.0, + "dwell_mean": 0.35, + "corrections": ([2, 3, 4, 5], [0.25, 0.35, 0.25, 0.15]), +} + + +class HumanCursor: + """Generates and drives a human-fidelity cursor path to a screen target.""" + + def __init__(self, profile: dict | None = None, rng: random.Random | None = None): + self.rng = rng or random.Random() # no fixed seed in real use + self.p = {**DEFAULT_PROFILE, **(profile or {})} + + # ---- public API ----------------------------------------------------- + + def move_to(self, tx: float, ty: float) -> list[tuple[float, float, float]]: + """Move the real cursor to (tx, ty). Returns the waypoints used.""" + sx, sy = get_pos() + waypoints = self._plan(sx, sy, tx, ty) + self._drive(waypoints) + return waypoints + + def click(self, tx: float, ty: float) -> list[tuple[float, float, float]]: + """Human-move to (tx, ty), dwell, then issue a trusted OS click.""" + waypoints = self.move_to(tx, ty) + self._press(tx, ty) + return waypoints + + # ---- path model (ADR 0202 §4) -------------------------------------- + + def _aims(self, sx, sy, tx, ty): + """Aim points: a ballistic overshoot, then shrinking corrections.""" + rng, p = self.rng, self.p + dist = math.hypot(tx - sx, ty - sy) + ang = math.atan2(ty - sy, tx - sx) + + ov = rng.uniform(*p["overshoot"]) * dist + a1 = ang + rng.uniform(-0.25, 0.25) + aims = [(tx + math.cos(a1) * ov, ty + math.sin(a1) * ov)] + + n = rng.choices(p["corrections"][0], weights=p["corrections"][1])[0] + residual = ov + for _ in range(n): + residual *= rng.uniform(0.25, 0.5) + a = rng.uniform(0, 2 * math.pi) + aims.append((tx + math.cos(a) * residual, ty + math.sin(a) * residual)) + # final settle: essentially on target, sub-pixel imperfect + aims.append((tx + rng.uniform(-0.7, 0.7), ty + rng.uniform(-0.7, 0.7))) + return aims + + def _segment(self, x0, y0, x1, y1, first): + """Timed waypoints for one sub-movement: S-curve position (=> bell + velocity), a slight randomized sideways bow (NOT a fixed curve).""" + rng, p = self.rng, self.p + d = math.hypot(x1 - x0, y1 - y0) + steps = max(2, min(220, int(d / rng.uniform(6, 12)))) + T = (0.07 + 0.0011 * d) * p["speed"] * rng.uniform(0.8, 1.25) + + # perpendicular unit vector for the bow + if d > 1e-6: + px, py = -(y1 - y0) / d, (x1 - x0) / d + else: + px = py = 0.0 + bow = rng.uniform(-1, 1) * d * rng.uniform(0.0, p["bow"]) + skew = 0.85 if first else 1.0 # ballistic launch accelerates harder + + seg = [] + for i in range(1, steps + 1): + t = (i / steps) ** skew + u = t * t * (3 - 2 * t) # smoothstep -> bell-shaped speed + bx = x0 + (x1 - x0) * u + px * bow * math.sin(math.pi * u) + by = y0 + (y1 - y0) * u + py * bow * math.sin(math.pi * u) + dt = (T / steps) * rng.uniform(0.75, 1.3) + seg.append((bx, by, dt)) + return seg + + def _apply_tremor(self, raw): + """Overlay continuous, non-periodic micro-tremor across the path.""" + rng, p = self.rng, self.p + amp = p["tremor_amp"] + w = p["tremor_hz"] * 2 * math.pi + ph1, ph2 = rng.uniform(0, 2 * math.pi), rng.uniform(0, 2 * math.pi) + rwx = rwy = 0.0 + out = [] + for (x, y, dt) in raw: + ph1 += w * dt * rng.uniform(0.85, 1.15) # frequency drift + ph2 += w * 1.7 * dt * rng.uniform(0.85, 1.15) # => no clean period + rwx = max(-amp, min(amp, rwx + rng.gauss(0, 0.3))) + rwy = max(-amp, min(amp, rwy + rng.gauss(0, 0.3))) + ox = amp * math.sin(ph1) + 0.4 * amp * math.sin(ph2) + rwx + oy = amp * math.cos(ph1 * 1.05) + 0.4 * amp * math.cos(ph2) + rwy + out.append((x + ox, y + oy, dt)) + return out + + def _dwell(self, tx, ty): + """Hover-and-jitter around the target before clicking (heavy-tailed).""" + rng, p = self.rng, self.p + dur = min(1.0, 0.05 + rng.expovariate(1.0 / p["dwell_mean"])) + ph = rng.uniform(0, 2 * math.pi) + out, t = [], 0.0 + while t < dur: + dt = rng.uniform(0.005, 0.012) + ph += p["tremor_hz"] * 2 * math.pi * dt * rng.uniform(0.8, 1.2) + ox = p["dwell_amp"] * math.sin(ph) + rng.gauss(0, 0.6) + oy = p["dwell_amp"] * math.cos(ph) + rng.gauss(0, 0.6) + out.append((tx + ox, ty + oy, dt)) + t += dt + return out + + def _settle(self, x, y): + """A brief near-still pause between corrective hops — the 'try again'.""" + rng, p = self.rng, self.p + dur, out, t = rng.uniform(*p["settle"]), [], 0.0 + while t < dur: + dt = rng.uniform(0.006, 0.013) + out.append((x + rng.gauss(0, 0.5), y + rng.gauss(0, 0.5), dt)) + t += dt + return out + + def _plan(self, sx, sy, tx, ty): + aims = self._aims(sx, sy, tx, ty) + raw, (cx, cy) = [], (sx, sy) + for i, (ax, ay) in enumerate(aims): + raw += self._segment(cx, cy, ax, ay, first=(i == 0)) + cx, cy = ax, ay + if i < len(aims) - 1: + raw += self._settle(cx, cy) # pause before the next correction + return self._apply_tremor(raw) + self._dwell(tx, ty) + + # ---- driver --------------------------------------------------------- + + def _drive(self, waypoints): + _winmm.timeBeginPeriod(1) # 1ms timer resolution for smooth pacing + try: + t0 = time.perf_counter() + planned = 0.0 + for (x, y, dt) in waypoints: + set_pos(x, y) + planned += dt + while True: + rem = planned - (time.perf_counter() - t0) + if rem <= 0: + break + time.sleep(rem - 0.0015) if rem > 0.003 else None # then busy-spin + finally: + _winmm.timeEndPeriod(1) + + def _press(self, tx, ty): + rng = self.rng + _send_flag(MOUSEEVENTF_LEFTDOWN) + hold = rng.uniform(0.04, 0.16) + time.sleep(hold * 0.5) + set_pos(tx + rng.uniform(-1.2, 1.2), ty + rng.uniform(-1.2, 1.2)) # press drift + time.sleep(hold * 0.5) + _send_flag(MOUSEEVENTF_LEFTUP) + + +class RobotCursor: + """The foil: a straight line at constant speed, no tremor, instant click. + Exists only so you can SEE what we're refusing to do.""" + + def move_to(self, tx, ty): + sx, sy = get_pos() + steps = 60 + wp = [(sx + (tx - sx) * i / steps, sy + (ty - sy) * i / steps, 0.5 / steps) + for i in range(1, steps + 1)] + _winmm.timeBeginPeriod(1) + try: + for (x, y, dt) in wp: + set_pos(x, y) + time.sleep(dt) + finally: + _winmm.timeEndPeriod(1) + return wp + + def click(self, tx, ty): + wp = self.move_to(tx, ty) + _send_flag(MOUSEEVENTF_LEFTDOWN) + _send_flag(MOUSEEVENTF_LEFTUP) + return wp + + +if __name__ == "__main__": + # Safe smoke test: wander near the current position, NO clicks. + print("Smoke test: moving the cursor in a small human wander (no clicks).") + print("Watch your pointer. Ctrl+C to stop.") + cur = HumanCursor() + ox, oy = get_pos() + for _ in range(6): + cur.move_to(ox + random.randint(-180, 180), oy + random.randint(-120, 120)) + time.sleep(0.3) + cur.move_to(ox, oy) + print("Done — that's the keystone moving the real cursor.") diff --git a/src/silphe/range_demo.py b/src/silphe/range_demo.py new file mode 100644 index 0000000..511781f --- /dev/null +++ b/src/silphe/range_demo.py @@ -0,0 +1,124 @@ +""" +range_demo.py — a safe little shooting range for the keystone. + +A local tkinter window with a bullseye. You trigger the cursor; it stalks the +target like a human (or like a robot, for contrast) and lands a real OS click on +the canvas. The path it actually took is drawn so you can SEE the difference. + +Nothing here touches LinkedIn or any website — it's a window on your own machine. + + poetry run python talos-mouse-host/range_demo.py + +Controls: + SPACE — human cursor fires at the target + R — robot cursor fires (straight line, no tremor — the foil) + N — new target (clears the drawn paths) + ESC — quit + +Heads-up: when you fire, your real pointer gets pulled to the target for about a +second. That's the whole point. ESC any time the range is idle. +""" + +from __future__ import annotations + +import random +import time +import tkinter as tk + +import human_cursor as hc + +HUMAN_COLOR = "#39d353" # GitHub-garden green, naturally +ROBOT_COLOR = "#f85149" # sterile red +TARGET_R = 22 + + +class Range: + def __init__(self, root: tk.Tk): + self.root = root + root.title("The Ministry of Silly Mice — Firing Range") + root.configure(bg="#0d1117") + self.W, self.H = 1200, 760 + + self.canvas = tk.Canvas(root, width=self.W, height=self.H, + bg="#0d1117", highlightthickness=0) + self.canvas.pack(fill="both", expand=True) + + self.human = hc.HumanCursor() # swap to hc.TREMOR_PROFILE to feel the palsy + self.robot = hc.RobotCursor() + self.tx = self.ty = 0 + self.busy = False + + self.canvas.bind("", self._on_click) + root.bind("", lambda e: self._fire(self.human, HUMAN_COLOR, "HUMAN")) + root.bind("r", lambda e: self._fire(self.robot, ROBOT_COLOR, "ROBOT")) + root.bind("R", lambda e: self._fire(self.robot, ROBOT_COLOR, "ROBOT")) + root.bind("n", lambda e: self.new_target()) + root.bind("N", lambda e: self.new_target()) + root.bind("", lambda e: root.destroy()) + + self.new_target() + + # ---- drawing -------------------------------------------------------- + + def _hud(self, msg, color="#8b949e"): + self.canvas.delete("hud") + self.canvas.create_text( + self.W // 2, 28, text=msg, fill=color, tag="hud", + font=("Consolas", 14, "bold")) + self.canvas.create_text( + self.W // 2, self.H - 22, tag="hud", fill="#6e7681", + font=("Consolas", 11), + text="SPACE human · R robot · N new target · ESC quit") + + def new_target(self): + self.canvas.delete("all") + m = 120 + self.tx = random.randint(m, self.W - m) + self.ty = random.randint(m + 30, self.H - m) + for i, r in enumerate((TARGET_R, TARGET_R * 0.66, TARGET_R * 0.33)): + self.canvas.create_oval(self.tx - r, self.ty - r, self.tx + r, self.ty + r, + outline="#30363d" if i == 0 else "#484f58", width=2) + self.canvas.create_oval(self.tx - 3, self.ty - 3, self.tx + 3, self.ty + 3, + fill="#39d353", outline="") + self._hud("Fire when ready.") + + def _draw_path(self, waypoints, rootx, rooty, color): + pts = [] + for (wx, wy, _dt) in waypoints: + pts.extend((wx - rootx, wy - rooty)) + if len(pts) >= 4: + self.canvas.create_line(*pts, fill=color, width=2, smooth=False) + + # ---- firing --------------------------------------------------------- + + def _fire(self, cursor, color, label): + if self.busy: + return + self.busy = True + for n in ("3", "2", "1"): + self._hud(f"{label} locking on… {n}", color) + self.canvas.update() + time.sleep(0.18) + rootx, rooty = self.canvas.winfo_rootx(), self.canvas.winfo_rooty() + screen_x, screen_y = rootx + self.tx, rooty + self.ty + waypoints = cursor.click(screen_x, screen_y) # <-- real cursor + trusted click + self._draw_path(waypoints, rootx, rooty, color) + self._hud(f"{label}: {len(waypoints)} micro-moves traced.", color) + self.busy = False + + def _on_click(self, event): + # Fires from the trusted OS click the cursor just issued. + d = ((event.x - self.tx) ** 2 + (event.y - self.ty) ** 2) ** 0.5 + hit = d <= TARGET_R + self.canvas.create_oval(event.x - 5, event.y - 5, event.x + 5, event.y + 5, + outline="#f0f6fc", width=2) + self.canvas.create_text( + self.tx, self.ty - TARGET_R - 16, tag="hud", + fill="#39d353" if hit else "#f85149", font=("Consolas", 13, "bold"), + text="HIT" if hit else f"{d:.0f}px off") + + +if __name__ == "__main__": + root = tk.Tk() + Range(root) + root.mainloop()