From ebdcee3307ec7415b4e81f55be54bf1739d43039 Mon Sep 17 00:00:00 2001 From: Marty McEnroe Date: Thu, 11 Jun 2026 08:06:44 -0500 Subject: [PATCH] Carve out reusable Apache-2.0 library + package for PyPI (Closes #5) Split human_cursor into cross-platform silphe.model (path generation) and Windows-only silphe.cursor (driver); fixes the import-time ctypes.windll crash that made the package unimportable off Windows. Extract the analysis math into silphe.analysis over a documented session schema. Re-license to Apache-2.0, finalize packaging (metadata, classifiers, entry points, py.typed), add tests (known-value recovery for Fitts slope, tracking lag, tremor frequency) and an OIDC release workflow, and scrub PII / Talos refs. Closes #5 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 +++ LICENSE | 336 +++++++++++++++++++++--------------- README.md | 81 ++++++--- docs/0004-pypi-publish.md | 108 ++++++++++++ docs/0005-session-schema.md | 57 ++++++ poetry.lock | 4 +- pyproject.toml | 41 ++++- src/silphe/__init__.py | 56 +++++- src/silphe/analysis.py | 279 ++++++++++++++++++++++++++++++ src/silphe/analyze.py | 104 +++-------- src/silphe/analyze_lag.py | 82 ++------- src/silphe/arc.py | 12 +- src/silphe/calibrate.py | 16 +- src/silphe/cursor.py | 215 +++++++++++++++++++++++ src/silphe/human_cursor.py | 309 --------------------------------- src/silphe/model.py | 173 +++++++++++++++++++ src/silphe/py.typed | 0 src/silphe/range_demo.py | 15 +- tests/test_analysis.py | 91 ++++++++++ tests/test_cursor.py | 34 ++++ tests/test_model.py | 52 ++++++ 21 files changed, 1457 insertions(+), 633 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/0004-pypi-publish.md create mode 100644 docs/0005-session-schema.md create mode 100644 src/silphe/analysis.py create mode 100644 src/silphe/cursor.py delete mode 100644 src/silphe/human_cursor.py create mode 100644 src/silphe/model.py create mode 100644 src/silphe/py.typed create mode 100644 tests/test_analysis.py create mode 100644 tests/test_cursor.py create mode 100644 tests/test_model.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..73736f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to Silphe are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/); versioning is +[Semantic Versioning](https://semver.org/). + +## [0.1.0] — 2026-06-11 + +First public release: the reusable instrument. + +### Added +- `silphe.model.MovementModel` — cross-platform generation of human-fidelity + pointer paths (ballistic overshoot, corrective sub-movements, continuous + micro-tremor, heavy-tailed dwell). Pure standard library; deterministic with a + seeded RNG. +- `silphe.cursor.HumanCursor` / `RobotCursor` — drive the real OS cursor with a + trusted (`isTrusted`) click on Windows. Import is safe on any platform; + driving is cleanly guarded off Windows. +- `silphe.analysis` — quantify a recorded session into an aggregate movement + signature: Fitts fit, corrective reversals, hold tremor (amplitude + + dominant frequency), and a tracking lag / offset / noise decomposition. +- Calibration game and dashboards as console entry points: `silphe-play`, + `silphe-arc`, `silphe-analyze`, `silphe-lag`, `silphe-demo`. +- Apache-2.0 license; PyPI publishing via OIDC Trusted Publishing (no stored + token). diff --git a/LICENSE b/LICENSE index 8374c74..e97d588 100644 --- a/LICENSE +++ b/LICENSE @@ -1,135 +1,201 @@ -# PolyForm Noncommercial License 1.0.0 - - - -## Acceptance - -In order to get any license under these terms, you must agree -to them as both strict obligations and conditions to all -your licenses. - -## Copyright License - -The licensor grants you a copyright license for the -software to do everything you might do with the software -that would otherwise infringe the licensor's copyright -in it for any permitted purpose. However, you may -only distribute the software according to [Distribution -License](#distribution-license) and make changes or new works -based on the software according to [Changes and New Works -License](#changes-and-new-works-license). - -## Distribution License - -The licensor grants you an additional copyright license -to distribute copies of the software. Your license -to distribute covers distributing the software with -changes and new works permitted by [Changes and New Works -License](#changes-and-new-works-license). - -## Notices - -You must ensure that anyone who gets a copy of any part of -the software from you also gets a copy of these terms or the -URL for them above, as well as copies of any plain-text lines -beginning with `Required Notice:` that the licensor provided -with the software. For example: - -> Required Notice: Copyright martymcenroe (https://github.com/martymcenroe) - -## Changes and New Works License - -The licensor grants you an additional copyright license to -make changes and new works based on the software for any -permitted purpose. - -## Patent License - -The licensor grants you a patent license for the software that -covers patent claims the licensor can license, or becomes able -to license, that you would infringe by using the software. - -## Noncommercial Purposes - -Any noncommercial purpose is a permitted purpose. - -## Personal Uses - -Personal use for research, experiment, and testing for -the benefit of public knowledge, personal study, private -entertainment, hobby projects, amateur pursuits, or religious -observance, without any anticipated commercial application, -is use for a permitted purpose. - -## Noncommercial Organizations - -Use by any charitable organization, educational institution, -public research organization, public safety or health -organization, environmental protection organization, -or government institution is use for a permitted purpose -regardless of the source of funding or obligations resulting -from the funding. - -## Fair Use - -You may have "fair use" rights for the software under the -law. These terms do not limit them. - -## No Other Rights - -These terms do not allow you to sublicense or transfer any of -your licenses to anyone else, or prevent the licensor from -granting licenses to anyone else. These terms do not imply -any other licenses. - -## Patent Defense - -If you make any written claim that the software infringes or -contributes to infringement of any patent, your patent license -for the software granted under these terms ends immediately. If -your company makes such a claim, your patent license ends -immediately for work on behalf of your company. - -## Violations - -The first time you are notified in writing that you have -violated any of these terms, or done anything with the software -not covered by your licenses, your licenses can nonetheless -continue if you come into full compliance with these terms, -and take practical steps to correct past violations, within -32 days of receiving notice. Otherwise, all your licenses -end immediately. - -## No Liability - -***As far as the law allows, the software comes as is, without -any warranty or condition, and the licensor will not be liable -to you for any damages arising out of these terms or the use -or nature of the software, under any kind of legal claim.*** - -## Definitions - -The **licensor** is the individual or entity offering these -terms, and the **software** is the software the licensor makes -available under these terms. - -**You** refers to the individual or entity agreeing to these -terms. - -**Your company** is any legal entity, sole proprietorship, -or other kind of organization that you work for, plus all -organizations that have control over, are under the control of, -or are under common control with that organization. **Control** -means ownership of substantially all the assets of an entity, -or the power to direct its management and policies by vote, -contract, or otherwise. Control can be direct or indirect. - -**Your licenses** are all the licenses granted to you for the -software under these terms. - -**Use** means anything you do with the software requiring one -of your licenses. - ---- - -Required Notice: Copyright (c) 2026 martymcenroe (https://github.com/martymcenroe) + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Marty McEnroe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index b7cdcd7..b06bf03 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,14 @@ > 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. -**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. +**Silphe** (σίλφη — Ancient Greek for the small creature that runs in the dark) is a tiny, **fully local** instrument for your own visuomotor signature: 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. -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. +It has two halves: + +- a **library** that *generates* human-fidelity pointer movement and *quantifies* the movement you record, and +- a **game** that captures your movement while you play. + +It began as a mouse-calibration chore and turned into something more interesting. ## Why it's interesting @@ -13,7 +18,46 @@ It began as a mouse-calibration chore and turned into something more interesting - **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. -## The games (calibration in a clown costume) +## Install + +```bash +pip install silphe +``` + +Pure standard library — no third-party runtime dependencies. Generating and analyzing movement works on **any** OS; driving the real OS cursor (`silphe.cursor`) is Windows-only. + +## Use the library + +Generate a human-fidelity path — overshoot, corrections, tremor, dwell — on any platform: + +```python +import random +from silphe import MovementModel + +model = MovementModel(rng=random.Random(0)) # seed for reproducibility +path = model.plan(0, 0, 400, 250) # -> [(x, y, dt), ...] +``` + +Drive the real cursor with a trusted OS click (Windows): + +```python +from silphe import HumanCursor +HumanCursor().click(960, 540) +``` + +Quantify a recorded session into an aggregate signature: + +```python +from silphe import load_recordings, session_signature + +trials, _ = load_recordings() # ~/.silphe/recordings by default +sig = session_signature(trials) +print(sig["acquire"]["fitts"], sig["hold"]["tremor_hz"], sig["track"]["lag_ms"]) +``` + +See the [session schema](https://github.com/martymcenroe/silphe/blob/main/docs/0005-session-schema.md). + +## Play (calibration in a clown costume) A green-garden field with four tasks: @@ -23,38 +67,29 @@ A green-garden field with four tasks: - **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 ```bash -python src/silphe/calibrate.py # play (mouse) -python src/silphe/calibrate.py trackpad # tag the session as trackpad +silphe-play # play (mouse) +silphe-play trackpad # tag the session as a trackpad ``` -## See yourself +Then see yourself: ```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) +silphe-analyze # this session's aggregate signature +silphe-lag # are you late? temporal lag vs spatial offset vs noise +silphe-arc # the longitudinal dashboard — your fingerprint over time +silphe-demo # human vs robot cursor, side by side (Windows) ``` -Everything is pure standard library (tkinter + ctypes) — nothing to install to play. +From a source checkout, the same modules run via `python -m silphe.calibrate`, `python -m silphe.analyze`, and so on. ## The science, briefly -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). +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 [the science](https://github.com/martymcenroe/silphe/blob/main/docs/0003-the-science.md). ## Privacy -Local-first, always — your movement never leaves your computer. See [`docs/0002-privacy.md`](docs/0002-privacy.md). - -## Install (soon) - -```bash -pip install silphe -``` - -Coming — see the launch plan in [`docs/0001-launch-plan.md`](docs/0001-launch-plan.md). +Local-first, always — your movement never leaves your computer. See [the privacy note](https://github.com/martymcenroe/silphe/blob/main/docs/0002-privacy.md). ## License -PolyForm Noncommercial 1.0.0 — see [LICENSE](LICENSE). +[Apache-2.0](https://github.com/martymcenroe/silphe/blob/main/LICENSE) — permissive, with a patent grant. Use it, fork it, build on it. diff --git a/docs/0004-pypi-publish.md b/docs/0004-pypi-publish.md new file mode 100644 index 0000000..65c9ea1 --- /dev/null +++ b/docs/0004-pypi-publish.md @@ -0,0 +1,108 @@ +# Publishing Silphe to PyPI (and actually reserving the name) + +Silphe publishes via **OIDC Trusted Publishing**: GitHub Actions mints a +short-lived token and PyPI trusts it. **No API token is stored anywhere.** + +This is the silphe-specific version of AssemblyZero runbook +`0934-pypi-trusted-publisher-setup.md`, with two corrections that runbook still +needs (tracked in AssemblyZero #1582 and #1583). + +## The one thing everyone gets wrong + +> **Registering a "pending publisher" does NOT reserve the name.** + +PyPI's own docs: + +> "A 'pending' publisher does not create a project or reserve a project's name +> until it is actually used to publish. If you create a 'pending' publisher but +> another user registers the project name before you actually publish to it, +> your 'pending' publisher will be invalidated." +> +> — + +**The only action that reserves `silphe` is publishing a real release.** Until +the first `v*.*.*` tag publishes successfully, the name is free for anyone to +take — and if they take it, your pending publisher silently invalidates. + +## Credentials — what you actually need + +- **PyPI has no "Sign in with GitHub."** Account login is username/email + + password + **2FA** (authenticator app or passkey) + recovery codes. It's in + your password manager from a prior project's setup — reuse that account. +- The GitHub connection in Step 1 is only the OIDC *publish* trust, never login. +- **You need zero API token** for this path. Nothing to generate or store. + +## Pre-flight (done by the library PR) + +- [x] `pyproject.toml` → `[project] name = "silphe"`, `version = "0.1.0"` +- [x] `.github/workflows/release.yml` present, `environment: pypi`, tag `v*.*.*` +- [x] `poetry build` produces a wheel + sdist +- [x] Library PR merged to `main` + +## Step 1 — Register the pending publisher (browser, ~2 min) + +1. Log in at (your existing account). +2. Under **"Add a new pending publisher,"** fill in exactly: + + | Field | Value | + |---|---| + | PyPI Project Name | `silphe` | + | Owner | `martymcenroe` | + | Repository name | `silphe` | + | Workflow filename | `release.yml` | + | Environment name | `pypi` | + +3. Click **Add**. This configures trust. It does **not** yet reserve the name — + Step 2 does. + +## Step 2 — Publish (the tag push that reserves the name) + +From `main`, with the tag matching `pyproject.toml`'s version: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +Watch it: + +```bash +gh run watch --repo martymcenroe/silphe +``` + +The workflow builds the distributions and publishes via OIDC. On success the +release is live at within seconds, the +name is **now reserved**, and the pending publisher promotes to a permanent +trusted publisher. + +## Step 3 — Verify + +```bash +pip install silphe +python -c "import silphe; print(silphe.__version__)" # 0.1.0 +silphe-analyze # entry point resolves +``` + +## Subsequent releases + +Bump the version, tag, push the tag. No browser steps ever again. + +```bash +poetry version patch +git commit -am "chore: bump to $(poetry version -s)" +git tag "v$(poetry version -s)" +git push origin main "v$(poetry version -s)" +``` + +## If it fails + +| Symptom | Cause | Fix | +|---|---|---| +| `Trusted publisher not found` | Step 1 not done, or a field mismatched | Re-check owner / repo / `release.yml` / `pypi` match exactly | +| `Project name is not available` | Someone else already took `silphe` | The name is gone; choose another in `pyproject.toml` and re-register | +| Workflow never starts | Tag isn't `v*.*.*` | `v0.1.0` matches; `0.1.0` does not | + +## References + +- AssemblyZero `0934-pypi-trusted-publisher-setup.md` — the fleet runbook (corrections tracked in AZ #1582, #1583) +- PyPI Trusted Publishers — diff --git a/docs/0005-session-schema.md b/docs/0005-session-schema.md new file mode 100644 index 0000000..365ad53 --- /dev/null +++ b/docs/0005-session-schema.md @@ -0,0 +1,57 @@ +# Session recording schema + +A **session** is a JSON Lines file named `session--.jsonl`, +written by the calibration game (`silphe-play`) into the recordings directory +(`$SILPHE_RECORDINGS`, default `~/.silphe/recordings`). Each line is one JSON +object — a **trial**. + +Load them with `silphe.analysis.load_session(path)` or +`silphe.analysis.load_recordings()`. + +## Common keys (every trial) + +| Key | Type | Meaning | +|---|---|---| +| `kind` | string | `"acquire"`, `"track"`, `"hold"`, or `"evasive"` | +| `samples` | `[[t, x, y], ...]` | the cursor trace; `t` = seconds from trial start, `x`/`y` = screen pixels | +| `reaction_s` | number | seconds to first movement | +| `device` | string | `"mouse"`, `"trackpad"`, … (as tagged at launch) | +| `os` | string | `platform.system()` | + +## Per kind + +**acquire** — hit a small target. + +| Key | Meaning | +|---|---| +| `target` | `{x, y, r}` — target center + radius | +| `home` | `{x, y}` — where the cursor started | +| `click` | `{x, y, err}` — click point + miss distance (px) | + +**hold** — stay still on a pixel (tremor test). + +| Key | Meaning | +|---|---| +| `target` | `{x, y, r}` — the pixel + tolerance | + +**track** — follow a smoothly drifting dot (smooth pursuit). + +| Key | Meaning | +|---|---| +| `dot` | `[[t, x, y], ...]` — the target's trace | +| `locked_at` | seconds when the player first locked on (analysis uses only the post-lock tail) | +| `on_target_pct` | percent of post-lock time on the dot | + +**evasive** ("Andvari") — hunt a maze roach. + +| Key | Meaning | +|---|---| +| `path` | `[[t, x, y], ...]` — the roach's trace | +| `hits` | total hits to kill it | +| `switches` | `[[t, tool], ...]` — tool switches | + +## Privacy + +These files are local biometric data. The repo's `recordings/` directory is +gitignored and never leaves your machine. The analysis helpers return aggregate +numbers only — never raw coordinates. diff --git a/poetry.lock b/poetry.lock index dd1fe96..a9873a3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -326,5 +326,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "e5add6584c4fac9a9218ad0e0e9dabb4f1b378ad3ec70a011351b2ec1c42b8ee" +python-versions = ">=3.10,<4.0" +content-hash = "8c1fa646fffd7e8c7ec2f19d076bd42dde89018936eb1924860ce15f1daef5f0" diff --git a/pyproject.toml b/pyproject.toml index 2f357b8..cb734b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,53 @@ [project] name = "silphe" version = "0.1.0" -description = "" +description = "Generate and quantify human-fidelity pointer movement — your mouse's signature, captured locally." authors = [ - {name = "Marty McEnroe",email = "cto@thrivetech.ai"} + {name = "Marty McEnroe", email = "cto@thrivetech.ai"} ] -license = {text = "PolyForm-Noncommercial-1.0.0"} +license = "Apache-2.0" +license-files = ["LICENSE"] readme = "README.md" requires-python = ">=3.10,<4.0" -dependencies = [ +keywords = [ + "human cursor", "mouse movement", "pointer", "fitts law", "visuomotor", + "biometrics", "tremor", "bot detection", "automation", "human-like", ] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Human Machine Interfaces", + "Topic :: Software Development :: Testing", + "Typing :: Typed", +] +dependencies = [] + +[project.urls] +Homepage = "https://thrivetech.ai/silphe" +Repository = "https://github.com/martymcenroe/silphe" +Issues = "https://github.com/martymcenroe/silphe/issues" +[project.scripts] +silphe-play = "silphe.calibrate:main" +silphe-arc = "silphe.arc:main" +silphe-analyze = "silphe.analyze:main" +silphe-lag = "silphe.analyze_lag:main" +silphe-demo = "silphe.range_demo:main" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" +[tool.poetry] +packages = [{include = "silphe", from = "src"}] + [dependency-groups] dev = [ "pytest (>=9.0.3,<10.0.0)", @@ -23,6 +55,7 @@ dev = [ ] [tool.pytest.ini_options] +pythonpath = ["src"] testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] diff --git a/src/silphe/__init__.py b/src/silphe/__init__.py index bb2133d..f46e79e 100644 --- a/src/silphe/__init__.py +++ b/src/silphe/__init__.py @@ -1,3 +1,57 @@ -"""Silphe — capture your own pointer-movement signature, and watch it drift. Local-first.""" +""" +Silphe — your pointer-movement signature, captured and quantified. + +Your mouse has a signature as personal as your handwriting: the overshoot, the +correction, the tremor, the chase. Silphe generates human-fidelity pointer paths +and measures the ones *you* leave — locally, on your own machine. + +Two halves, both pure standard library: + +* :mod:`silphe.model` — generate human-fidelity paths (overshoot, corrective + sub-movements, tremor, dwell). Cross-platform. +* :mod:`silphe.analysis` — quantify a recorded session into an aggregate + movement signature (Fitts fit, tremor, tracking lag/offset). Cross-platform. + +And one Windows-only convenience: + +* :mod:`silphe.cursor` — drive the real OS cursor along a generated path + (``isTrusted`` clicks via Win32). Import is safe everywhere; driving needs + Windows. +""" + +from __future__ import annotations + +from silphe.analysis import ( + acquire_stats, + fitts_fit, + hold_stats, + lag_scan, + load_recordings, + load_session, + recordings_dir, + session_signature, +) +from silphe.cursor import HumanCursor, RobotCursor +from silphe.model import DEFAULT_PROFILE, TREMOR_PROFILE, MovementModel __version__ = "0.1.0" + +__all__ = [ + "__version__", + # generation + "MovementModel", + "DEFAULT_PROFILE", + "TREMOR_PROFILE", + # driving (Windows) + "HumanCursor", + "RobotCursor", + # analysis + "session_signature", + "acquire_stats", + "hold_stats", + "fitts_fit", + "lag_scan", + "load_session", + "load_recordings", + "recordings_dir", +] diff --git a/src/silphe/analysis.py b/src/silphe/analysis.py new file mode 100644 index 0000000..1e68ffc --- /dev/null +++ b/src/silphe/analysis.py @@ -0,0 +1,279 @@ +""" +silphe.analysis — quantify a movement signature from recorded sessions. + +These helpers read Silphe's session recordings and return **aggregate metrics +only** — movement time, path wander, corrective sub-movements, a Fitts's-law +fit, hold tremor (amplitude + dominant frequency), and a decomposition of +tracking into temporal *lag* / spatial *offset* / residual *noise*. No raw +coordinates are returned by the summary helpers, by design: the point is to +characterize the hand without exposing the trace. + +Pure standard library; runs on any platform. + +Session schema +-------------- +A *session* is a ``.jsonl`` file (one JSON object — a *trial* — per line). +Common keys on every trial:: + + kind : "acquire" | "track" | "hold" | "evasive" + samples : [[t, x, y], ...] cursor trace; t = seconds from trial start + reaction_s, device, os + +Per kind:: + + acquire : target{x, y, r}, home{x, y}, click{x, y, err} + hold : target{x, y, r} + track : dot [[t, x, y], ...] (target trace), locked_at, on_target_pct + evasive : path[[t, x, y], ...] (target trace), hits, switches + +``move_to``-style generated paths (see :mod:`silphe.model`) are NOT sessions; +these helpers operate on *recorded human* sessions written by the calibration +game. +""" + +from __future__ import annotations + +import bisect +import glob +import json +import math +import os +import statistics as st + +__all__ = [ + "recordings_dir", + "load_session", + "load_recordings", + "acquire_stats", + "hold_stats", + "fitts_fit", + "lag_scan", + "session_signature", +] + + +# -------------------------------------------------------------------------- +# Loading +# -------------------------------------------------------------------------- + +def recordings_dir() -> str: + """Where session recordings live. ``$SILPHE_RECORDINGS`` if set, else + ``~/.silphe/recordings``. Install-location independent, so the game and the + analyzers always agree regardless of where the package is installed. + """ + env = os.environ.get("SILPHE_RECORDINGS") + if env: + return env + return os.path.join(os.path.expanduser("~"), ".silphe", "recordings") + + +def load_session(path: str) -> list[dict]: + """Load one ``session-*.jsonl`` file into a list of trial dicts.""" + trials = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + trials.append(json.loads(line)) + return trials + + +def load_recordings(directory: str | None = None) -> tuple[list[dict], list[str]]: + """Load every ``session-*.jsonl`` under *directory* (default + :func:`recordings_dir`). Returns ``(flat_trials, file_paths)``. + """ + directory = directory or recordings_dir() + files = sorted(glob.glob(os.path.join(directory, "session-*.jsonl"))) + trials = [] + for fp in files: + trials.extend(load_session(fp)) + return trials, files + + +# -------------------------------------------------------------------------- +# Per-trial metrics +# -------------------------------------------------------------------------- + +def acquire_stats(trial: dict) -> dict | None: + """Metrics for one ACQUIRE trial: movement time, path wander, corrective + reversals, final error, and the Shannon index of difficulty (bits).""" + s = trial["samples"] + if len(s) < 3: + return None + tx, ty, r = trial["target"]["x"], trial["target"]["y"], trial["target"]["r"] + hx, hy = trial["home"]["x"], trial["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 + idx = math.log2(straight / (2 * r) + 1) if r > 0 else 0.0 # Fitts ID (bits) + return dict(mt=mt, eff=eff, rev=rev, err=trial["click"]["err"], ID=idx) + + +def hold_stats(trial: dict) -> dict | None: + """Metrics for one HOLD trial: tremor amplitude (px) and dominant + frequency (Hz) of the steady-hold tail.""" + s = trial["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 fitts_fit(acquire_rows: list[dict]) -> dict | None: + """Least-squares Fitts fit ``MT = a + b * ID`` over ACQUIRE rows (as + produced by :func:`acquire_stats`). ``a`` is base time (s), ``b`` is seconds + per bit. Returns ``None`` if there is too little spread to fit.""" + rows = [r for r in acquire_rows if r] + if len(rows) < 2: + return None + ids = [r["ID"] for r in rows] + mts = [r["mt"] for r in rows] + mid, mmt = st.mean(ids), st.mean(mts) + denom = sum((i - mid) ** 2 for i in ids) + if denom <= 0: + return None + b = sum((ids[i] - mid) * (mts[i] - mmt) for i in range(len(ids))) / denom + a = mmt - b * mid + return dict(a=a, b=b) + + +# -------------------------------------------------------------------------- +# Tracking: temporal lag vs spatial offset vs residual noise +# -------------------------------------------------------------------------- + +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: list, target: list, gap_filter: bool = False) -> dict | None: + """Find the time-shift that best aligns *cursor* to *target* (both lists of + ``[t, x, y]``). Returns the best lag (ms), the residual error at that lag, + the constant aim offset ``(dx, dy)``, and the error at zero lag — separating + *being late* from *being inaccurate*. ``gap_filter`` skips target times with + no nearby sample (e.g. while an evasive target was hidden).""" + 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): + 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) + + +# -------------------------------------------------------------------------- +# Whole-session signature +# -------------------------------------------------------------------------- + +def _mean(vals): + vals = [v for v in vals if v is not None] + return st.mean(vals) if vals else None + + +def session_signature(trials: list[dict]) -> dict: + """Bundle a movement signature from a flat list of trials: acquire timing + + accuracy + Fitts fit, hold tremor, and tracking lag/offset for the smooth + (``track``) and evasive targets. Missing task types come back as ``None``. + """ + 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] + + sig: dict = { + "n_trials": len(trials), + "acquire": None, + "hold": None, + "track": None, + "evasive": None, + } + + if acq: + sig["acquire"] = { + "n": len(acq), + "movement_time_s": _mean([a["mt"] for a in acq]), + "final_error_px": _mean([a["err"] for a in acq]), + "path_wander_x": _mean([a["eff"] for a in acq]), + "corrections": _mean([a["rev"] for a in acq]), + "fitts": fitts_fit(acq), + } + if hold: + sig["hold"] = { + "n": len(hold), + "tremor_px": _mean([h["jit"] for h in hold]), + "tremor_hz": _mean([h["freq"] for h in hold]), + } + + for kind, tkey, gap in (("track", "dot", False), ("evasive", "path", True)): + scans = [] + for r in trials: + 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 scans: + sig[kind] = { + "n": len(scans), + "lag_ms": _mean([s["lag_ms"] for s in scans]), + "error_at_lag_px": _mean([s["err"] for s in scans]), + "error_at_zero_lag_px": _mean([s["zero_err"] for s in scans]), + "aim_offset_px": ( + _mean([s["dx"] for s in scans]), + _mean([s["dy"] for s in scans]), + ), + } + return sig diff --git a/src/silphe/analyze.py b/src/silphe/analyze.py index 52c7e76..e4605a5 100644 --- a/src/silphe/analyze.py +++ b/src/silphe/analyze.py @@ -1,108 +1,56 @@ """ -analyze.py — read the local calibration recordings and print AGGREGATE stats only. +silphe-analyze — print this machine's AGGREGATE movement signature. -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. +Reads your local session recordings (see :mod:`silphe.analysis` for the schema +and the recordings location) and prints summary numbers only — never raw +coordinates. The metrics themselves live in :mod:`silphe.analysis`; this is just +the console view. - poetry run python talos-mouse-host/analyze.py + silphe-analyze # after `pip install silphe` + python -m silphe.analyze # from a source checkout """ 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") +from silphe.analysis import ( + acquire_stats, + fitts_fit, + hold_stats, + load_recordings, + recordings_dir, +) -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() +def main() -> None: + trials, files = load_recordings() if not trials: - print("No recordings found in", REC) - print("(Play a session: python talos-mouse-host/calibrate.py)") + print("No recordings found in", recordings_dir()) + print("(Play a session: silphe-play)") 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"Parsed {len(trials)} trials from {len(files)} session file(s).") - print(f"\n=== ACQUIRE - the'attempt' ({len(acq)} trials) ===") + 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'") + 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)") + fit = fitts_fit(acq) + if fit: + print(f" Fitts fit : MT = {fit['a']:.2f} + {fit['b']:.2f}*ID " + f"(base {fit['a']*1000:.0f}ms, {fit['b']*1000:.0f}ms per bit)") - print(f"\n=== HOLD - thesteady tremor ({len(hold)} trials) ===") + print(f"\n=== HOLD — the steady 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") diff --git a/src/silphe/analyze_lag.py b/src/silphe/analyze_lag.py index 47b065b..b7ff482 100644 --- a/src/silphe/analyze_lag.py +++ b/src/silphe/analyze_lag.py @@ -1,85 +1,35 @@ """ -analyze_lag.py — how late is the cursor vs the moving target? +silphe-lag — 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?" +Lines up your cursor against where the target actually was and decomposes the +tracking into temporal LAG (you're behind in time), spatial OFFSET (a constant +aim bias), and residual ERROR (noise left after both). Aggregate numbers only; +no raw coordinates printed. The math lives in :func:`silphe.analysis.lag_scan`. -Aggregate numbers only; no raw coordinates printed. - - poetry run python talos-mouse-host/analyze_lag.py + silphe-lag # after `pip install silphe` + python -m silphe.analyze_lag # from a source checkout """ 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") +from silphe.analysis import lag_scan, recordings_dir -def latest_session(): - fs = glob.glob(os.path.join(REC, "session-*.jsonl")) +def _latest_session() -> str | None: + fs = glob.glob(os.path.join(recordings_dir(), "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() +def main() -> None: + sf = _latest_session() if not sf: - print("No recordings. Play a session first.") + print("No recordings. Play a session first: silphe-play") return - rounds = [json.loads(l) for l in open(sf, encoding="utf-8") if l.strip()] + rounds = [json.loads(line) for line in open(sf, encoding="utf-8") if line.strip()] print(f"Session: {os.path.basename(sf)} ({len(rounds)} rounds)") for kind, tkey, gap in (("track", "dot", False), ("evasive", "path", True)): @@ -102,9 +52,9 @@ def main(): 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)") + print(f" error at THAT lag : {avg('err'):.1f} px (how tight you are once the 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" error at zero lag : {sum(ze)/len(ze):.1f} px (how it looks with 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() diff --git a/src/silphe/arc.py b/src/silphe/arc.py index 6ace6cc..7bf5ca3 100644 --- a/src/silphe/arc.py +++ b/src/silphe/arc.py @@ -1,5 +1,5 @@ """ -arc.py — the longitudinal "arc" view (the #177 cognitive-assessment dashboard). +arc.py — the longitudinal "arc" view: your movement signature over time. Reads every session in recordings/ and plots your cognitive fingerprint drifting over time: reaction, accuracy, speed, tracking, tremor — one point per session, @@ -8,8 +8,8 @@ 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 + silphe-arc # the dashboard, after `pip install silphe` + silphe-arc --text # headless summary """ from __future__ import annotations @@ -22,7 +22,9 @@ import sys import tkinter as tk -REC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") +from silphe.analysis import recordings_dir + +REC = recordings_dir() KIND_COLOR = {"acquire": "#e3b341", "track": "#a371f7", "hold": "#58a6ff", "evasive": "#d29922"} @@ -173,7 +175,7 @@ def gui(sessions): def main(): sessions = load_sessions() if not sessions: - print("No recordings yet. Play a session: python talos-mouse-host/calibrate.py") + print("No recordings yet. Play a session: silphe-play") return if "--text" in sys.argv: text_report(sessions) diff --git a/src/silphe/calibrate.py b/src/silphe/calibrate.py index c37ce99..9861ae3 100644 --- a/src/silphe/calibrate.py +++ b/src/silphe/calibrate.py @@ -11,11 +11,11 @@ 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 +Everything stays on your machine (see silphe.analysis.recordings_dir). 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 + silphe-play # mouse (default), after `pip install silphe` + silphe-play trackpad # tag the session as trackpad """ from __future__ import annotations @@ -29,7 +29,9 @@ import time import tkinter as tk -REC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recordings") +from silphe.analysis import recordings_dir + +REC_DIR = recordings_dir() VERSION = "Andvari" HOLD_SECS = 1.2 TRACK_SECS = 4.0 @@ -504,7 +506,11 @@ def _finish(self): self.state = "done" -if __name__ == "__main__": +def main() -> None: root = tk.Tk() Garden(root) root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/silphe/cursor.py b/src/silphe/cursor.py new file mode 100644 index 0000000..c1e9b3b --- /dev/null +++ b/src/silphe/cursor.py @@ -0,0 +1,215 @@ +""" +silphe.cursor — drive the REAL OS cursor along a human-fidelity path. + +Importing this module is safe on **any** platform. Actually *moving* the cursor +(:meth:`HumanCursor.move_to`, :meth:`HumanCursor.click`) uses the Win32 +``SendInput`` / ``SetCursorPos`` APIs via ``ctypes`` and therefore works only on +Windows; on other platforms those calls raise :class:`NotImplementedError` with a +clear message. The path *generation* lives in :mod:`silphe.model` and runs +everywhere — so you can plan, analyze, and visualize paths on any OS, and drive +them for real on Windows. + +The OS-level click means the event a page sees is ``isTrusted: true`` — +indistinguishable from a physical mouse. + + from silphe.cursor import HumanCursor + HumanCursor().click(960, 540) # Windows: moves the real cursor and clicks +""" + +from __future__ import annotations + +import ctypes # importing ctypes is cross-platform; only ctypes.windll is Windows-only +import random +import sys +import time + +# Re-export the model knobs so callers can do `from silphe.cursor import TREMOR_PROFILE`. +from silphe.model import DEFAULT_PROFILE, TREMOR_PROFILE, MovementModel + +_IS_WINDOWS = sys.platform == "win32" + +MOUSEEVENTF_LEFTDOWN = 0x0002 +MOUSEEVENTF_LEFTUP = 0x0004 +INPUT_MOUSE = 0 + +# --- ctypes struct layouts (safe to define on any OS; basic C types only) --- + + +class _MOUSEINPUT(ctypes.Structure): + _fields_ = ( + ("dx", ctypes.c_long), + ("dy", ctypes.c_long), + ("mouseData", ctypes.c_ulong), + ("dwFlags", ctypes.c_ulong), + ("time", ctypes.c_ulong), + ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), + ) + + +class _INPUT(ctypes.Structure): + class _I(ctypes.Union): + _fields_ = (("mi", _MOUSEINPUT),) + + _anonymous_ = ("i",) + _fields_ = (("type", ctypes.c_ulong), ("i", _I)) + + +class _POINT(ctypes.Structure): + _fields_ = (("x", ctypes.c_long), ("y", ctypes.c_long)) + + +_user32 = None +_winmm = None + + +def _ensure_win32(): + """Lazily bind the Win32 DLLs. Raises on non-Windows. Idempotent.""" + global _user32, _winmm + if _user32 is not None: + return + if not _IS_WINDOWS: + raise NotImplementedError( + "silphe.cursor drives the real OS cursor through Win32 and runs only " + "on Windows. To GENERATE paths on any platform, use " + "silphe.model.MovementModel(...).plan(sx, sy, tx, ty)." + ) + user32 = ctypes.windll.user32 + winmm = ctypes.windll.winmm + # Make the process DPI-aware so our pixel coordinates match the cursor's — + # the coordinate-mapping foot-gun on large / scaled displays. + try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) # PER_MONITOR_AWARE_V2-ish + except Exception: + try: + user32.SetProcessDPIAware() + except Exception: + pass + user32.SendInput.argtypes = (ctypes.c_uint, ctypes.POINTER(_INPUT), ctypes.c_int) + user32.SendInput.restype = ctypes.c_uint + user32.SetCursorPos.argtypes = (ctypes.c_int, ctypes.c_int) + user32.GetCursorPos.argtypes = (ctypes.POINTER(_POINT),) + _user32, _winmm = user32, winmm + + +def get_pos() -> tuple[int, int]: + """Current cursor position in screen pixels. Windows only.""" + _ensure_win32() + pt = _POINT() + _user32.GetCursorPos(ctypes.byref(pt)) + return pt.x, pt.y + + +def set_pos(x: float, y: float) -> None: + """Teleport the cursor to ``(x, y)`` in screen pixels. Windows only.""" + _ensure_win32() + _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)) + + +class HumanCursor: + """Plan a human-fidelity path (via :class:`~silphe.model.MovementModel`) and + drive the real cursor along it. Generation works anywhere; driving is + Windows-only. + """ + + def __init__(self, profile: dict | None = None, rng: random.Random | None = None): + self.model = MovementModel(profile, rng) + self.rng = self.model.rng + + # ---- 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 driven.""" + sx, sy = get_pos() + waypoints = self.model.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 + + def plan(self, sx, sy, tx, ty): + """Generate (do not drive) the waypoints. Works on any platform.""" + return self.model.plan(sx, sy, tx, ty) + + # ---- driver --------------------------------------------------------- + + def _drive(self, waypoints): + _ensure_win32() + _winmm.timeBeginPeriod(1) # 1 ms 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 a human path is 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)] + _ensure_win32() + _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 + + +def _smoke_test(): + """Safe demo: wander the real cursor near where it already is. 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 model moving the real cursor.") + + +if __name__ == "__main__": + _smoke_test() diff --git a/src/silphe/human_cursor.py b/src/silphe/human_cursor.py deleted file mode 100644 index 16f9632..0000000 --- a/src/silphe/human_cursor.py +++ /dev/null @@ -1,309 +0,0 @@ -""" -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/model.py b/src/silphe/model.py new file mode 100644 index 0000000..e03e7c0 --- /dev/null +++ b/src/silphe/model.py @@ -0,0 +1,173 @@ +""" +silphe.model — cross-platform generation of human-fidelity pointer paths. + +Pure standard library, no OS calls: this module runs anywhere (Windows, macOS, +Linux). It produces timed waypoints — a list of ``(x, y, dt)`` — that *overshoot* +the target, *correct* with a few shrinking sub-movements, carry a continuous +low-amplitude *tremor*, and end in a heavy-tailed *dwell*. Sampled fresh every +call; never the same path twice. + +Drive these waypoints onto the real OS cursor with :mod:`silphe.cursor` (Windows +only), feed them to a GUI, or quantify them with :mod:`silphe.analysis` — the +generation itself is platform-independent. + +The motion is deliberately NOT a smooth Bezier curve. It is a ballistic launch +that overshoots, corrective homing hops, physiological micro-tremor, and a +pre-click dwell — the texture a real hand leaves and a straight-line robot never +does. +""" + +from __future__ import annotations + +import math +import random + +# A waypoint is (x_px, y_px, dt_seconds_to_dwell_here). +Waypoint = tuple[float, float, float] + +# Movement profile — the knobs that shape a path's "hand". Override any subset +# when constructing a MovementModel; unspecified keys fall back to these. +DEFAULT_PROFILE: dict = { + "tremor_amp": 2.0, # px — physiological micro-tremor amplitude + "tremor_hz": 6.0, # Hz — tremor frequency + "dwell_amp": 1.6, # px — jitter while hovering before a click + "dwell_mean": 0.18, # s — heavy-tailed dwell, centered near ~0.2 s + "overshoot": (0.02, 0.09), # ballistic overshoot as a fraction of distance + "corrections": ([1, 2, 3, 4], [0.25, 0.40, 0.25, 0.10]), # # of homing hops + "settle": (0.03, 0.09), # s — brief pause between hops (the "try again") + "bow": 0.06, # max sideways arc as a fraction of segment length + "speed": 1.0, # global time multiplier (smaller = faster) +} + +# A heavier-tremor profile — wider tremor, longer dwell, more corrective hops. +# Useful for modelling an unsteady hand, or just to *see* the texture exaggerated. +TREMOR_PROFILE: dict = { + **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 MovementModel: + """Plans human-fidelity pointer paths. Pure and platform-independent. + + Pass a :class:`random.Random` with a fixed seed for reproducible paths + (handy in tests); leave it out for fresh randomness every run. + + >>> import random + >>> model = MovementModel(rng=random.Random(0)) + >>> path = model.plan(0, 0, 400, 250) + >>> abs(path[-1][0] - 400) < 6 and abs(path[-1][1] - 250) < 6 + True + """ + + 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 plan(self, sx: float, sy: float, tx: float, ty: float) -> list[Waypoint]: + """Return timed waypoints for moving from ``(sx, sy)`` to ``(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) + + # ---- path model ----------------------------------------------------- + + 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 diff --git a/src/silphe/py.typed b/src/silphe/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/silphe/range_demo.py b/src/silphe/range_demo.py index 511781f..7ba32c0 100644 --- a/src/silphe/range_demo.py +++ b/src/silphe/range_demo.py @@ -5,9 +5,10 @@ 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. +Nothing here touches any website — it's a window on your own machine. - poetry run python talos-mouse-host/range_demo.py + silphe-demo # after `pip install silphe` + python -m silphe.range_demo # from a source checkout Controls: SPACE — human cursor fires at the target @@ -25,7 +26,7 @@ import time import tkinter as tk -import human_cursor as hc +from silphe import cursor as hc HUMAN_COLOR = "#39d353" # GitHub-garden green, naturally ROBOT_COLOR = "#f85149" # sterile red @@ -43,7 +44,7 @@ def __init__(self, root: tk.Tk): 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.human = hc.HumanCursor() # swap to hc.TREMOR_PROFILE for a heavier tremor self.robot = hc.RobotCursor() self.tx = self.ty = 0 self.busy = False @@ -118,7 +119,11 @@ def _on_click(self, event): text="HIT" if hit else f"{d:.0f}px off") -if __name__ == "__main__": +def main() -> None: root = tk.Tk() Range(root) root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..5fbf90d --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,91 @@ +"""Tests for silphe.analysis — the movement-signature math. + +Where possible these inject a *known* truth (a known Fitts slope, a known lag, a +known tremor frequency) and assert the analyzer recovers it. +""" + +import math + +from silphe.analysis import ( + acquire_stats, + fitts_fit, + hold_stats, + lag_scan, + session_signature, +) + + +def _acquire(home, target, r, path, err=2.0): + samples = [[i * 0.01, x, y] for i, (x, y) in enumerate(path)] + return { + "kind": "acquire", + "samples": samples, + "target": {"x": target[0], "y": target[1], "r": r}, + "home": {"x": home[0], "y": home[1]}, + "click": {"x": target[0], "y": target[1], "err": err}, + } + + +def test_acquire_stats_on_a_straight_path(): + path = [(i * 10, 0) for i in range(11)] # (0,0) -> (100,0), monotonic + s = acquire_stats(_acquire((0, 0), (100, 0), 10, path)) + assert s is not None + assert abs(s["eff"] - 1.0) < 0.01 # straight line: path == straight distance + assert s["rev"] == 0 # no corrective reversals + assert abs(s["ID"] - math.log2(100 / 20 + 1)) < 1e-9 + assert s["err"] == 2.0 + + +def test_acquire_stats_counts_a_reversal(): + # approach, then overshoot far past the target (distance to target grows + # again), then correct back -> one corrective reversal + path = [(0, 0), (90, 0), (150, 0), (100, 0)] + s = acquire_stats(_acquire((0, 0), (100, 0), 10, path)) + assert s["rev"] >= 1 + + +def test_hold_stats_recovers_known_frequency(): + # 5 Hz oscillation over exactly 1 s -> dominant frequency should read ~5 Hz + samples = [[i * 0.01, 10 * math.sin(2 * math.pi * 5 * (i * 0.01)), 0.0] + for i in range(101)] + s = hold_stats({"kind": "hold", "samples": samples, + "target": {"x": 0, "y": 0, "r": 5}}) + assert s is not None + assert abs(s["freq"] - 5.0) < 0.6 + + +def test_fitts_fit_recovers_known_slope(): + rows = [{"ID": i, "mt": 0.20 + 0.15 * i} for i in (1, 2, 3, 4, 5)] + fit = fitts_fit(rows) + assert fit is not None + assert abs(fit["a"] - 0.20) < 1e-9 + assert abs(fit["b"] - 0.15) < 1e-9 + + +def test_fitts_fit_needs_two_points(): + assert fitts_fit([{"ID": 2.0, "mt": 0.5}]) is None + + +def test_lag_scan_recovers_injected_lag(): + n = 200 + tgt_xy = [(100 * math.sin(i * 0.07), 100 * math.cos(i * 0.07)) for i in range(n)] + target = [[i * 0.02, x, y] for i, (x, y) in enumerate(tgt_xy)] + # the cursor sits where the target was 5 samples (= 100 ms) earlier + cursor = [[i * 0.02, tgt_xy[i - 5][0], tgt_xy[i - 5][1]] for i in range(5, n)] + res = lag_scan(cursor, target) + assert res is not None + assert res["lag_ms"] == 100 + assert res["err"] < 1.0 # near-perfect alignment once the lag is removed + + +def test_lag_scan_too_short_returns_none(): + assert lag_scan([[0, 0, 0]], [[0, 0, 0]]) is None + + +def test_session_signature_integrates_task_types(): + trials = [_acquire((0, 0), (100, 0), 10, [(i * 10, 0) for i in range(11)])] + sig = session_signature(trials) + assert sig["n_trials"] == 1 + assert sig["acquire"] is not None and sig["acquire"]["n"] == 1 + assert sig["hold"] is None # no hold trials supplied + assert sig["track"] is None diff --git a/tests/test_cursor.py b/tests/test_cursor.py new file mode 100644 index 0000000..f4df678 --- /dev/null +++ b/tests/test_cursor.py @@ -0,0 +1,34 @@ +"""Tests for silphe.cursor — most importantly, that importing it (and the whole +package) is safe on any OS, and that driving is cleanly guarded off Windows. +""" + +import pytest + +import silphe +import silphe.cursor as cursor + + +def test_package_imports_and_exposes_api(): + # Importing silphe must NOT require Windows (the bug this package was born to + # avoid: a top-level ctypes.windll call that crashes on macOS/Linux). + assert silphe.__version__ + for name in ("MovementModel", "HumanCursor", "RobotCursor", "session_signature"): + assert hasattr(silphe, name) + + +def test_driving_is_guarded_off_windows(monkeypatch): + # Simulate a non-Windows platform and confirm the driver refuses clearly + # instead of blowing up with an opaque ctypes error. + monkeypatch.setattr(cursor, "_IS_WINDOWS", False) + monkeypatch.setattr(cursor, "_user32", None) + monkeypatch.setattr(cursor, "_winmm", None) + with pytest.raises(NotImplementedError): + cursor._ensure_win32() + + +def test_humancursor_can_plan_without_driving(): + # Generation works everywhere; this never touches the OS cursor. + import random + wp = cursor.HumanCursor(rng=random.Random(0)).plan(0, 0, 250, 120) + assert len(wp) > 10 + assert abs(wp[-1][0] - 250) < 8 diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..8596bc2 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,52 @@ +"""Tests for silphe.model — the cross-platform path generator.""" + +import random +import statistics + +from silphe.model import DEFAULT_PROFILE, TREMOR_PROFILE, MovementModel + + +def test_plan_ends_near_target(): + wp = MovementModel(rng=random.Random(0)).plan(0, 0, 400, 250) + x, y, _ = wp[-1] + assert abs(x - 400) < 8 and abs(y - 250) < 8 + + +def test_plan_is_deterministic_with_seed(): + a = MovementModel(rng=random.Random(42)).plan(10, 10, 300, 200) + b = MovementModel(rng=random.Random(42)).plan(10, 10, 300, 200) + assert a == b + + +def test_plan_varies_without_seed(): + a = MovementModel().plan(0, 0, 500, 300) + b = MovementModel().plan(0, 0, 500, 300) + assert a != b # fresh randomness every call — never the same path twice + + +def test_waypoints_well_formed(): + wp = MovementModel(rng=random.Random(1)).plan(0, 0, 200, 200) + assert len(wp) > 10 + assert all(len(p) == 3 for p in wp) + assert all(dt >= 0 for _, _, dt in wp) # time only moves forward + + +def test_overshoot_goes_past_the_target(): + # a ballistic launch overshoots: the path must at some point pass the target + wp = MovementModel(rng=random.Random(3)).plan(0, 0, 300, 0) + assert max(x for x, _, _ in wp) > 300 + + +def test_heavier_profile_has_wider_dwell(): + def dwell_spread(profile): + wp = MovementModel(profile=profile, rng=random.Random(7)).plan(0, 0, 100, 100) + return statistics.pstdev([x for x, _, _ in wp[-30:]]) + + assert dwell_spread(TREMOR_PROFILE) > dwell_spread(DEFAULT_PROFILE) + + +def test_profile_override_is_partial(): + # overriding one knob keeps the rest of DEFAULT_PROFILE + m = MovementModel(profile={"speed": 0.5}) + assert m.p["speed"] == 0.5 + assert m.p["tremor_hz"] == DEFAULT_PROFILE["tremor_hz"]