|
|
| Feasibility |
Medium |
| Status |
In progress |
MEASURED 2026-08-10 on 100 real strokes captured with the device label (see row 139 for the harness). THE PEN'S SAMPLES ARRIVE AT HALF THE RATE OF A FINGER'S, THROUGH THE SAME CODE PATH: median interval between samples was 33.4 ms for the stylus (~30 Hz, 44 strokes), 7.3 ms for touch (~137 Hz, 34 strokes) and 49.5 ms for a mouse (17 strokes), on a 60 Hz display. This is what the user reported by hand and could not have been guessed: 'with the finger I do not need [prediction] at all as this is very quick, only the pen has delay'. THE STRUCTURE OF THE NUMBERS IS THE FINDING. Expressed in display frames, the stylus arrives at 2.00 frames per sample and the mouse at 2.97, each within 0.03-0.04 of a WHOLE frame, while touch sits at 0.44 frames with a deviation of 0.35 - i.e. pointer motion is locked to the frame clock and touch is not. GTK delivers a stylus as the LOGICAL POINTER (the row 135 finding), so the pen is subject to pointer motion compression and a finger is not. TWO THINGS THIS RULES OUT. (1) Our pipeline is not the ceiling: touch pushes ~137 Hz through the same canvas, the same live smoothing and the same redraw, so nothing downstream caps us at 30 Hz. (2) Live smoothing costs nothing measurable - the deliberate with/without groups came out at 34.0 ms and 33.5 ms, so the feature accepted the same day is not paying for this. AND ONE THING IT ESTABLISHES: the pen is not a 30 Hz device. The fastest stylus strokes, all on an empty page early in the session, came in at 16.5-16.7 ms - exactly ONE frame - so the hardware can deliver 60 Hz and 30 Hz is not a hardware ceiling. WHAT IS STILL OPEN, and the capture cannot answer it: _now_ms is wall-clock at HANDLING time and pointer events are frame-locked, so this measures DELIVERY. Whether pen samples are being DISCARDED by motion compression, or the device simply reports ~30 Hz most of the time, needs the raw device: sudo libinput debug-events --device /dev/input/eventN while drawing (neither libinput nor evtest is installed here, and the user is not in the input group, so an agent cannot run it). A weak correlation with ink already on the page (34 -> 30 -> 27 -> 30 Hz across the session) is NOT strong enough to claim the redraw degrades with stroke count; re-measure before believing it. WHY THIS OUTRANKS PREDICTION, and closes row 139's loop: prediction is a workaround for a low sample rate. Graded per device on this same capture, it recovers 6.3% of the lag at 20 ms on the stylus but is catastrophically counterproductive on touch (-1255% at 20 ms, worse on 88% of samples) - because at 7 ms per sample there is almost no lag left to cancel and any guess is larger than the error it replaces. That is precisely why a finger needs no prediction. Doubling the pen's rate would help the felt lag, the denoiser (spacing/feature was 0.337 = UNDERSAMPLED for the pen against 0.08 for touch) and prediction all at once, and is worth more than any tuning of the three. ANSWERED, and it is OURS (2026-08-10, extras/device_rate.py reading the evdev node directly, 15 s of continuous drawing each). RAW HARDWARE RATE: stylus 7.51 ms median (133 Hz), p10 7.37 / p90 7.65 - a tight hardware clock, 1753 motion reports; touch 10.00 ms (100 Hz), p10 9.80 / p90 10.20. So the PEN IS THE FASTER DEVICE at the panel, and the story is the exact inverse of how it feels: touch reaches the canvas at close to its full rate while the pen arrives at 30 Hz, i.e. WE DISCARD ABOUT 78% OF THE PEN'S SAMPLES. The panel is not the limit and there is nothing to fix in the hardware. CAUSE: GTK compresses POINTER motion to one event per frame and a stylus is delivered as the logical pointer (row 135), while touch events are not compressed - which is exactly the frames-per-sample signature measured earlier (stylus 2.00 frames, mouse 2.97, touch 0.44). TWO SEPARATE LOSSES, and they must not be confused. (1) RESOLUTION: everything between the frames is thrown away, which is the whole of the undersampling that every ink constant has been tuned around - spacing/feature 0.337 for the pen against 0.08 for touch. Gdk.Event.get_history() exists precisely for this: it returns the GdkTimeCoord trail that was compressed away since the last delivered event, and it is available in this GTK (verified). Reading it recovers the full 133 Hz for the stroke geometry. (2) LATENCY: the newest sample is still only as fresh as the frame it arrived on, and the measured 33.4 ms is TWO frames, so the canvas is running at ~30 fps during a stroke, not 60. The fastest strokes in the capture - all early, on an empty page - hit 16.5 ms, one frame, which points at redraw cost growing with ink on the page. get_history does NOT fix this half: it improves the SHAPE, not the age of the tip. Do not sell one as the other. HALF ONE SHIPPED (2026-08-10): motion_history() reads Gdk.Event.get_history() in both drawing routers and walks the recovered trail into current_stroke BEFORE the event's own point, so a stroke is captured at the pen's rate instead of the frame's. TWO TRAPS, both silent if wrong. (1) GdkTimeCoord axes are in SURFACE coordinates while a GestureDrag reports WIDGET ones, so every recovered sample would land offset by the canvas's position in the window - a header bar's height too high. The offset is derived from the EVENT's own position, which is available in both spaces at once, rather than from widget geometry. A test caught this by having an unrealistic fake position, which is worth remembering: the fixture was wrong and the code was right, and the failure looked identical to the bug. (2) coord.time is the EVENT clock, an arbitrary 32-bit millisecond base, NOT GLib.get_monotonic_time() - mixing them makes prediction's velocity estimate explode. Only the difference from the current event is meaningful, so motion_history returns an AGE and _note_sample takes an age_ms; stamping a frame's worth of recovered samples as simultaneous would read as an infinite speed. Junk is dropped rather than drawn: a coord without the X/Y flags is not a position, and a wrapped 32-bit time would otherwise place a sample seconds in the past and drag the stroke to it (MOTION_HISTORY_MAX_AGE_MS). The whole thing is guarded like capture_raw_stroke - recovering extra samples is a bonus and failing to must never cost the user the stroke - which a mock-object event in an unrelated test proved was not theoretical. The press list is kept in LOCKSTEP with the points (a history sample with no pressure axis borrows the event's), because a length mismatch loses the taper for the whole stroke. INK_MAX_POINTS needed no change: it widens the resample SPACING rather than truncating, and the output count is a function of arc length, not of how many samples came in. STILL OPEN, and deliberately not sold as fixed by this: the newest sample is only as fresh as the frame it arrived on, and 33.4 ms is TWO frames, so the canvas renders at ~30 fps mid-stroke while an empty page manages 16.5 ms. That is the half the hand feels as lag, and it is a redraw-cost question. RE-MEASURE THE INK CONSTANTS AFTER THIS: every one of them was tuned against a 30 Hz capture of a 133 Hz pen, so the undersampling verdict (spacing/feature 0.337) and 'never strengthen the denoiser for small writing' were both conclusions about a sampling rate that no longer holds. CONFIRMED IN THE APP AND RE-MEASURED (2026-08-10, 23 fresh strokes). The canvas now sees 8.0 ms between stylus samples (125 Hz, range 7.6-8.8) against 33.4 ms before, and the raw sample spacing fell from 3.29 to 0.85 document units. User's verdict: 'wow, that made it way better'. THE RATIO THAT RETIRES A DOCUMENTED RULE: spacing/feature is now 0.091 - 'reasonable sampling' - against 0.337 (UNDERSAMPLED) before, and that is with a SMALLER hand (median feature 8.4 units against 14.2). So the conclusion recorded in row 139 and in CLAUDE.md, that small writing is an INFORMATION limit and the denoiser must never be strengthened for it, was a fact about the 30 Hz the canvas was RECEIVING and not about the pen. It is void; the method (measure the ratio, do not guess) is what survives. THE DENOISER IS NOW NEAR-INERT, measured rather than felt: across its whole range Taubin moves the committed ink 0.11% -> 0.16% of an x-height, against 0.44% -> 0.48% at 30 Hz, i.e. 0.0 and 1.0 now differ by 0.05% of an x-height. The user noticed this unprompted ('stroke smoothing does almost nothing because all is smooth already') and the numbers agree. WHY: the denoiser was never mainly removing tremor, it was cleaning up what INTERPOLATION invented between sparse samples, and there is little left to invent - the shaping is now done by resample_ink. Deliberately NOT changed as a result: no constant was retuned. The pipeline is in a good regime, an inert filter is harmless, and there is no measurement saying any change would improve anything. Do not widen the slider's range to make it 'do something'. PREDICTION RE-GRADED at the new rate: 13.5% recovery at 10 ms, 13.0% at 20, 3.5% at 40, still worse on 33-42% of samples. Better than the 0.1-4.4% it managed at 30 Hz but still not compelling, and it stays OFF by default. Its one remaining known defect is that PREDICT_SMOOTH is per-EVENT and so changes meaning with the report rate; fix that before judging it again. BUG FOUND BY THE USER'S SESSION LOG, fixed same day: gdk_event_get_history ASSERTS the event is MOTION_NOTIFY or SCROLL, and a finger drag delivers TOUCH_UPDATE - so every touch stroke printed a Gdk-CRITICAL per event. Nothing was lost (touch is not compressed, which is why a finger already arrived at full rate) but the console spam was real. motion_history now checks the event TYPE first. The test that was supposed to cover this passed for the WRONG REASON: its fake touch event had no get_history method at all, so the AttributeError guard caught it and the type check was never exercised. KEEP THE SMOOTHING SLIDER (user's call, 2026-08-10) even though it is now near-inert on this hardware: another panel may still be in the old sparse regime, where it is exactly the control that is needed. The future home is a separate/advanced settings page, not deletion. THE LATENCY HALF, MEASURED (2026-08-10). Every committed stroke is re-outlined and re-filled on EVERY frame - the page itself is cached as a surface and blitted, but the ink is not. Benchmarked on the user's own committed strokes (median 150 points after resampling): 0.164 ms per stroke, dead linear. 25 strokes 3.9 ms, 50 strokes 7.9, 100 strokes 16.4, 200 strokes 33.2, 400 strokes 67.9 - and that is stroke painting ALONE, before the page blit, images, lasso, anchors and the live pipeline. The 16.7 ms frame budget is gone at ~100 strokes. This matches the field data exactly: the session that measured 33.4 ms between samples saved 100 ink annotations, and the strokes that hit a full 60 Hz were all on an EMPTY page. So the felt lag is a redraw cost that grows with the notes on the page, which is also why it gets worse the longer a lecture goes on. NOT the live pipeline: measured at 2.0-2.5 ms per frame on a real stroke even now that motion_history feeds it ~4x the samples, and capped at 9.0 ms by LIVE_SMOOTH_MAX_PTS for an absurd 1800-sample stroke. THE FIX is to cache committed ink into its own surface beside _page_surface and blit it, so a frame costs O(1) plus the live stroke instead of O(strokes). Invalidation is the whole risk and it is the same list _page_surface already has, plus every ink mutation: add/erase/undo/redo/move/resize/recolour, page change, zoom (the surface is scale-dependent, hence _surface_scale). Compositing is safe - each stroke's alpha is baked into the layer, so overlapping translucent highlighter still double-darkens exactly as it does when painted one by one. SHIPPED (2026-08-10): committed ink is now a cached SURFACE beside _page_surface, blitted per frame. Measured on the user's own committed strokes: per-frame cost went from 4.1 / 16.4 / 65.6 ms at 25 / 100 / 400 strokes to a FLAT 1.42 / 1.50 / 1.78 ms - one blit plus a 0.03-0.39 ms fingerprint. TWO DESIGN DECISIONS WORTH KEEPING. (1) The cache is keyed on a FINGERPRINT of what would be painted, not on a list of invalidation call sites. There are a dozen ways a stroke changes here (draw, erase, undo, redo, move, resize, rotate, reshape, weld, recolour, grid re-space, paste) and a cache keyed on remembering all of them goes stale on the thirteenth - and a stale layer is the ONLY bug this design can have. The fingerprint samples INK_SIG_SAMPLES=5 points per stroke, which is what catches a control-point drag on an interior vertex; an endpoints-only signature misses exactly that. invalidate_ink() is the escape hatch. (2) An APPEND is detected as a prefix of the old signature and painted onto the existing layer. Found by measuring, not by reasoning: the first version repainted in full on every change, which is O(strokes) = 80 ms at 400 of them, arriving exactly when you LIFT THE PEN - i.e. it would have traded a steady lag for a hitch at the worst possible moment. Committing a stroke now costs 0.16 ms at 100 strokes and 0.55 ms at 400. Erase/undo/move/recolour/page/zoom still rebuild, correctly: a surface can only gain ink, not lose it. NOT DONE: the text sheet still repaints per frame - its ink is on an overlay with a different substrate, so this does not transfer for free. USER REPORT that this does NOT explain, and it is the honest limit of the whole thread: there is still delay on the FIRST line, on an empty page where the stroke cost is zero. That is the structural pipeline and mostly not ours - up to 7.5 ms waiting for the next pen report, up to 16.7 ms of frame quantisation, one to two frames of compositor latency, plus panel response, i.e. 30-60 ms end to end before Sidemark does anything wrong. The old capture confirms the app itself is already at the frame limit on an empty page (16.5 ms per sample = 60 fps). THE ONE SOFTWARE LEVER LEFT FOR IT IS PREDICTION, which makes this the moment its known defect is worth fixing: PREDICT_SMOOTH is an EMA weight per EVENT, so its time constant changes with the report rate - at 30 Hz it was ~100 ms, five times the lead it was smoothing. It should be a time constant in ms applied per elapsed dt. Fix that, re-grade with ink_replay --predict, and only then judge it in the hand. USER'S IDEA, built the same day: extras/latency_probe.py measures the end-to-end lag by chasing a moving dot. A dot sweeps at a known speed, the user keeps the pen TIP on it, and the time offset that best aligns the dot curve with the reported pen curve is the lag. The sweep is a SINE and not a triangle: a triangle's velocity flips instantly at each end, which no hand can follow, and the tracking error there would swamp the lag. WHAT IT HONESTLY MEASURES: the WHOLE loop - pen, app, compositor, panel, eye, hand, pen - and it cannot separate the display half from the input half. The user's own tracking is inside the number, and because a predictable sweep is one people ANTICIPATE, the result is closer to a lower bound on the machine than an upper one. So the number to trust is a DIFFERENCE: run it twice changing one thing (prediction on/off, a compositor setting, another machine) and the human term is roughly constant, so the delta is real even when the absolute is soft. It reports the fit RESIDUAL beside the lag for exactly this reason - a hand that was not really tracking must be visible as a bad fit rather than as a confident wrong number, and --selftest checks both halves against known lags without hardware (a wrong fit looks exactly like a fast machine). Deliberately imports nothing from sidemark, so it measures the platform and can be pointed at another toolkit or machine for comparison. INK LAYER VERIFIED IN THE APP (2026-08-10, the user's hand): erase, undo/redo, lasso-move with no ghost left behind, zoom in/out staying sharp and in register, page turn, and drawing on a page that already carries a lot of ink. The fingerprint-keyed cache holds. LATENCY PROBE, first run, and the bug it exposed in ITSELF: it exited immediately with '1 dot frames'. A UNIT MISMATCH - _now() returns MILLISECONDS (which is what the fit works in) while --seconds and --period arrive in SECONDS, so the run-length test fired on the second frame and closed the window after 4 ms. Worth recording for two reasons. First, the symptom reads as BROKEN HARDWARE rather than as a bug in the measuring tool, which is the failure mode every diagnostic has and the reason --selftest exists (it passed throughout - it exercises the fit, not the window). Second, it was found by REPRODUCING it under the headless weston the test suite already runs, not by reading the code: two plausible wrong guesses (the tick callback's argument count, an exception swallowed by GTK) were both eliminated in a minute by running them. The fix names the units in the fields themselves (_run_ms, _period_ms) so the two cannot be mixed again. Also fixed while there: the probe now reads get_history() too, or it would sample the pen once per FRAME and report the frame rate as the pen rate - the exact confusion this whole row is about - and it uses ONE controller, since a GestureDrag beside the motion one puts every sample into the fit twice. FIRST REAL RUN, and the user's correction to the METHOD (2026-08-10). The run fitted 93 ms with residual 0.263 - correctly self-rejected as a poor fit, which is the guard earning its place on the first try. The user's diagnosis was right and better than the design: a back-and-forth SWEEP changes speed through every cycle, fastest at the centre and stopping dead at each end, and a reversal is the hardest thing there is to track - so the hand's error peaks exactly where the measurement lives. Default path is now a CIRCLE at constant angular speed: no reversals, one speed, so the hand settles in and its error becomes a steady phase offset, which is precisely what the fit reads. The fit is now 2D (error is the distance, so both axes constrain the lag at once; a line degenerates to the axis that moves), the default period slowed from 1.6 s to 3.0 s and the amplitude from 380 to 260 px, and a faint GUIDE RING is drawn under the dot - you cannot anticipate a path you cannot see, and anticipation is exactly what keeps the human term out of the number. --path line keeps the old sweep. The report also prints the dot's speed in px/s and px per 10 ms, without which a lag in milliseconds is unreadable. SEPARATE FINDING FROM THE SAME RUN, worth chasing: the probe's own frame interval was 22.5 ms, ~44 fps, in a window drawing ONE DOT on a 60 Hz panel. That is not Sidemark - it is a trivial GTK4 window - so something in the compositor/renderer path is not presenting at the panel's rate, and it is 22.5 ms of the latency budget rather than the 16.7 assumed everywhere above. Worth re-running the probe against GSK_RENDERER variants and with Hyprland's tearing/direct-scanout options before concluding anything about prediction. MEASURED END TO END, AND MY OWN ESTIMATE WAS WRONG (2026-08-10). I had put the machine at 40-60 ms. Two independent methods agree it is about 110 ms. (1) The probe fitted 125 ms with residual 0.069, and the user reports staying ON the ball, so the human tracking term was near zero and the number is almost entirely machine. (2) THE USER'S OBSERVATION, which is the isolation the probe could not provide: the MOUSE CURSOR following the pen sits ~1 cm behind the nib, and behind the ball, with the same delay felt in Sidemark. At 64 px/cm (1920 px across a ~30 cm panel) and 569 px/s that is 64/569 = 112 ms. The two agree. WHY THE CURSOR OBSERVATION SETTLES OWNERSHIP: the cursor is drawn by HYPRLAND, not by Sidemark and not by GTK. If it lags the nib by as much as the ink does, the bulk of the latency is below the application and no work in Sidemark can recover it. It also suggests a SOFTWARE cursor - a hardware cursor plane updates at input rate and would not lag like this - so cursor:no_hardware_cursors is the first Hyprland setting to check. 110 ms is far more than a composited path should cost (a frame is 16.7 ms), so something there is buffering several frames deep. GSK RENDERER IS A FREE WIN, measured: the DEFAULT (Vulkan) ran the probe's window at 24.4 ms per frame (41 fps) drawing ONE DOT on a 60 Hz panel, while GSK_RENDERER=gl gave 16.9 ms (59 fps) and =cairo 16.7 ms (60 fps). End-to-end lag fell from 125 ms to 107/105. That is ~8 ms per frame the default renderer is losing on this hardware, and it applies to Sidemark for free - same toolkit, same compositor. NB ngl is a RENAMED value (it is gl now) and silently falls back, so a test using it is not testing what it says. CALIBRATION OF THE PROBE ITSELF, via --inject-lag: 0 ms -> 125, 100 ms -> 200, so a 100 ms injection moved the fit by 75. Deltas are real and directional but UNDER-REPORT by roughly 25%, and the mechanism is the hand: shown a visibly larger delay the user anticipates harder, and their own lag shrinks to absorb part of the injection. So the renderer's true benefit is likely nearer 25 ms than the 20 measured, and any future delta from this tool should be read as a floor. The capture now also counts motion EVENTS per stroke, so ink_replay reports the canvas's FRAME rate beside the pen's report rate. Samples arrive per pen report and events arrive per frame, so the two together say which of the two a stroke was limited by - and that is the measurement that settles a renderer or compositor A/B, which by feel is ambiguous (the user's verdict on GSK_RENDERER=gl in the app was 'feels similar, maybe a tiny bit faster', which on a ~20 ms change out of ~110 is exactly what a real but small improvement feels like and is not something to hardcode a renderer on). WHERE THE LATENCY LIVES, SETTLED (2026-08-10): UPSTREAM OF THE COMPOSITOR. Forcing a HARDWARE cursor plane (cursor:no_hardware_cursors 0, so the cursor is not composited at all) left the ~1 cm gap to the nib unchanged, and forcing SOFTWARE cursors (1) made no difference either. So the ~110 ms is spent before anything is drawn - the Goodix digitiser's own processing, libinput, and panel response - and neither Sidemark nor Hyprland can recover it. Note the option is a NEGATIVE (0 = use hardware cursors = fast, 1 = no hardware cursors = software, 2 = auto), which is easy to test backwards; the user's first run was at 1. PREDICTION CANNOT BRIDGE IT, and this closes the question rather than deferring it again. Re-graded on 133 Hz ink with the damping bug fixed: recovery of the lag error is 11% at a 10 ms lead, 10% at 20, 1% at 40, -11% at 60 and -29% at 80, and it makes matters worse on 33% of samples at 10 ms rising to 63% at 80. The lag needing masking is 110 ms, where a guess is far worse than none. THE REASON IS INFORMATION, NOT MODELLING: a pen stroke changes direction on a ~100 ms timescale and a letter takes 200-400 ms, so predicting 110 ms ahead is guessing the second half of a letter that has not been written - kinematics cannot know whether a curve is closing an 'o' or leaving into an 'l'. A Kalman, constant-acceleration or learned predictor fits more parameters to a future that is not in the data and hits the same wall. Prediction's realistic ceiling here is masking ~20 ms of 110, at the cost of overshoot at every cusp. Decision: keep it off, keep the bug fix, build nothing further. THE BUG FIX, which was worth making regardless: PREDICT_SMOOTH was an EMA weight applied PER EVENT, so its meaning drifted with the report rate - a ~92 ms time constant when the canvas saw 30 Hz, five times the lead it was damping, which is most of why prediction measured as useless. It is now PREDICT_SMOOTH_MS, a real time constant applied over elapsed dt, meaning the same thing at any rate; ink_replay applies the identical damping so it grades the filter the app actually runs. CHOOSING ITS VALUE EXPOSED A TRADE-OFF NO METRIC CAN SETTLE: swept at a 20 ms lead on real ink, recovery falls monotonically with damping - 41% undamped, 31% at 10 ms, 25% at 15, 19% at 20, 10% at 30 - so ACCURACY alone says use none. Damping exists for STABILITY, which no error metric measures, and was added because the tip visibly jittered - a complaint from the 30 Hz era, when consecutive guesses disagreed wildly. At 133 Hz they agree far better, so less is needed. Set to 15 ms with the sweep recorded in the comment; re-judge in the hand before moving it.
MEASURED 2026-08-10 on 100 real strokes captured with the device label (see row 139 for the harness). THE PEN'S SAMPLES ARRIVE AT HALF THE RATE OF A FINGER'S, THROUGH THE SAME CODE PATH: median interval between samples was 33.4 ms for the stylus (~30 Hz, 44 strokes), 7.3 ms for touch (~137 Hz, 34 strokes) and 49.5 ms for a mouse (17 strokes), on a 60 Hz display. This is what the user reported by hand and could not have been guessed: 'with the finger I do not need [prediction] at all as this is very quick, only the pen has delay'. THE STRUCTURE OF THE NUMBERS IS THE FINDING. Expressed in display frames, the stylus arrives at 2.00 frames per sample and the mouse at 2.97, each within 0.03-0.04 of a WHOLE frame, while touch sits at 0.44 frames with a deviation of 0.35 - i.e. pointer motion is locked to the frame clock and touch is not. GTK delivers a stylus as the LOGICAL POINTER (the row 135 finding), so the pen is subject to pointer motion compression and a finger is not. TWO THINGS THIS RULES OUT. (1) Our pipeline is not the ceiling: touch pushes ~137 Hz through the same canvas, the same live smoothing and the same redraw, so nothing downstream caps us at 30 Hz. (2) Live smoothing costs nothing measurable - the deliberate with/without groups came out at 34.0 ms and 33.5 ms, so the feature accepted the same day is not paying for this. AND ONE THING IT ESTABLISHES: the pen is not a 30 Hz device. The fastest stylus strokes, all on an empty page early in the session, came in at 16.5-16.7 ms - exactly ONE frame - so the hardware can deliver 60 Hz and 30 Hz is not a hardware ceiling. WHAT IS STILL OPEN, and the capture cannot answer it: _now_ms is wall-clock at HANDLING time and pointer events are frame-locked, so this measures DELIVERY. Whether pen samples are being DISCARDED by motion compression, or the device simply reports ~30 Hz most of the time, needs the raw device:
sudo libinput debug-events --device /dev/input/eventNwhile drawing (neither libinput nor evtest is installed here, and the user is not in theinputgroup, so an agent cannot run it). A weak correlation with ink already on the page (34 -> 30 -> 27 -> 30 Hz across the session) is NOT strong enough to claim the redraw degrades with stroke count; re-measure before believing it. WHY THIS OUTRANKS PREDICTION, and closes row 139's loop: prediction is a workaround for a low sample rate. Graded per device on this same capture, it recovers 6.3% of the lag at 20 ms on the stylus but is catastrophically counterproductive on touch (-1255% at 20 ms, worse on 88% of samples) - because at 7 ms per sample there is almost no lag left to cancel and any guess is larger than the error it replaces. That is precisely why a finger needs no prediction. Doubling the pen's rate would help the felt lag, the denoiser (spacing/feature was 0.337 = UNDERSAMPLED for the pen against 0.08 for touch) and prediction all at once, and is worth more than any tuning of the three. ANSWERED, and it is OURS (2026-08-10, extras/device_rate.py reading the evdev node directly, 15 s of continuous drawing each). RAW HARDWARE RATE: stylus 7.51 ms median (133 Hz), p10 7.37 / p90 7.65 - a tight hardware clock, 1753 motion reports; touch 10.00 ms (100 Hz), p10 9.80 / p90 10.20. So the PEN IS THE FASTER DEVICE at the panel, and the story is the exact inverse of how it feels: touch reaches the canvas at close to its full rate while the pen arrives at 30 Hz, i.e. WE DISCARD ABOUT 78% OF THE PEN'S SAMPLES. The panel is not the limit and there is nothing to fix in the hardware. CAUSE: GTK compresses POINTER motion to one event per frame and a stylus is delivered as the logical pointer (row 135), while touch events are not compressed - which is exactly the frames-per-sample signature measured earlier (stylus 2.00 frames, mouse 2.97, touch 0.44). TWO SEPARATE LOSSES, and they must not be confused. (1) RESOLUTION: everything between the frames is thrown away, which is the whole of the undersampling that every ink constant has been tuned around - spacing/feature 0.337 for the pen against 0.08 for touch. Gdk.Event.get_history() exists precisely for this: it returns the GdkTimeCoord trail that was compressed away since the last delivered event, and it is available in this GTK (verified). Reading it recovers the full 133 Hz for the stroke geometry. (2) LATENCY: the newest sample is still only as fresh as the frame it arrived on, and the measured 33.4 ms is TWO frames, so the canvas is running at ~30 fps during a stroke, not 60. The fastest strokes in the capture - all early, on an empty page - hit 16.5 ms, one frame, which points at redraw cost growing with ink on the page. get_history does NOT fix this half: it improves the SHAPE, not the age of the tip. Do not sell one as the other. HALF ONE SHIPPED (2026-08-10): motion_history() reads Gdk.Event.get_history() in both drawing routers and walks the recovered trail into current_stroke BEFORE the event's own point, so a stroke is captured at the pen's rate instead of the frame's. TWO TRAPS, both silent if wrong. (1) GdkTimeCoord axes are in SURFACE coordinates while a GestureDrag reports WIDGET ones, so every recovered sample would land offset by the canvas's position in the window - a header bar's height too high. The offset is derived from the EVENT's own position, which is available in both spaces at once, rather than from widget geometry. A test caught this by having an unrealistic fake position, which is worth remembering: the fixture was wrong and the code was right, and the failure looked identical to the bug. (2) coord.time is the EVENT clock, an arbitrary 32-bit millisecond base, NOT GLib.get_monotonic_time() - mixing them makes prediction's velocity estimate explode. Only the difference from the current event is meaningful, so motion_history returns an AGE and _note_sample takes an age_ms; stamping a frame's worth of recovered samples as simultaneous would read as an infinite speed. Junk is dropped rather than drawn: a coord without the X/Y flags is not a position, and a wrapped 32-bit time would otherwise place a sample seconds in the past and drag the stroke to it (MOTION_HISTORY_MAX_AGE_MS). The whole thing is guarded like capture_raw_stroke - recovering extra samples is a bonus and failing to must never cost the user the stroke - which a mock-object event in an unrelated test proved was not theoretical. The press list is kept in LOCKSTEP with the points (a history sample with no pressure axis borrows the event's), because a length mismatch loses the taper for the whole stroke. INK_MAX_POINTS needed no change: it widens the resample SPACING rather than truncating, and the output count is a function of arc length, not of how many samples came in. STILL OPEN, and deliberately not sold as fixed by this: the newest sample is only as fresh as the frame it arrived on, and 33.4 ms is TWO frames, so the canvas renders at ~30 fps mid-stroke while an empty page manages 16.5 ms. That is the half the hand feels as lag, and it is a redraw-cost question. RE-MEASURE THE INK CONSTANTS AFTER THIS: every one of them was tuned against a 30 Hz capture of a 133 Hz pen, so the undersampling verdict (spacing/feature 0.337) and 'never strengthen the denoiser for small writing' were both conclusions about a sampling rate that no longer holds. CONFIRMED IN THE APP AND RE-MEASURED (2026-08-10, 23 fresh strokes). The canvas now sees 8.0 ms between stylus samples (125 Hz, range 7.6-8.8) against 33.4 ms before, and the raw sample spacing fell from 3.29 to 0.85 document units. User's verdict: 'wow, that made it way better'. THE RATIO THAT RETIRES A DOCUMENTED RULE: spacing/feature is now 0.091 - 'reasonable sampling' - against 0.337 (UNDERSAMPLED) before, and that is with a SMALLER hand (median feature 8.4 units against 14.2). So the conclusion recorded in row 139 and in CLAUDE.md, that small writing is an INFORMATION limit and the denoiser must never be strengthened for it, was a fact about the 30 Hz the canvas was RECEIVING and not about the pen. It is void; the method (measure the ratio, do not guess) is what survives. THE DENOISER IS NOW NEAR-INERT, measured rather than felt: across its whole range Taubin moves the committed ink 0.11% -> 0.16% of an x-height, against 0.44% -> 0.48% at 30 Hz, i.e. 0.0 and 1.0 now differ by 0.05% of an x-height. The user noticed this unprompted ('stroke smoothing does almost nothing because all is smooth already') and the numbers agree. WHY: the denoiser was never mainly removing tremor, it was cleaning up what INTERPOLATION invented between sparse samples, and there is little left to invent - the shaping is now done by resample_ink. Deliberately NOT changed as a result: no constant was retuned. The pipeline is in a good regime, an inert filter is harmless, and there is no measurement saying any change would improve anything. Do not widen the slider's range to make it 'do something'. PREDICTION RE-GRADED at the new rate: 13.5% recovery at 10 ms, 13.0% at 20, 3.5% at 40, still worse on 33-42% of samples. Better than the 0.1-4.4% it managed at 30 Hz but still not compelling, and it stays OFF by default. Its one remaining known defect is that PREDICT_SMOOTH is per-EVENT and so changes meaning with the report rate; fix that before judging it again. BUG FOUND BY THE USER'S SESSION LOG, fixed same day: gdk_event_get_history ASSERTS the event is MOTION_NOTIFY or SCROLL, and a finger drag delivers TOUCH_UPDATE - so every touch stroke printed a Gdk-CRITICAL per event. Nothing was lost (touch is not compressed, which is why a finger already arrived at full rate) but the console spam was real. motion_history now checks the event TYPE first. The test that was supposed to cover this passed for the WRONG REASON: its fake touch event had no get_history method at all, so the AttributeError guard caught it and the type check was never exercised. KEEP THE SMOOTHING SLIDER (user's call, 2026-08-10) even though it is now near-inert on this hardware: another panel may still be in the old sparse regime, where it is exactly the control that is needed. The future home is a separate/advanced settings page, not deletion. THE LATENCY HALF, MEASURED (2026-08-10). Every committed stroke is re-outlined and re-filled on EVERY frame - the page itself is cached as a surface and blitted, but the ink is not. Benchmarked on the user's own committed strokes (median 150 points after resampling): 0.164 ms per stroke, dead linear. 25 strokes 3.9 ms, 50 strokes 7.9, 100 strokes 16.4, 200 strokes 33.2, 400 strokes 67.9 - and that is stroke painting ALONE, before the page blit, images, lasso, anchors and the live pipeline. The 16.7 ms frame budget is gone at ~100 strokes. This matches the field data exactly: the session that measured 33.4 ms between samples saved 100 ink annotations, and the strokes that hit a full 60 Hz were all on an EMPTY page. So the felt lag is a redraw cost that grows with the notes on the page, which is also why it gets worse the longer a lecture goes on. NOT the live pipeline: measured at 2.0-2.5 ms per frame on a real stroke even now that motion_history feeds it ~4x the samples, and capped at 9.0 ms by LIVE_SMOOTH_MAX_PTS for an absurd 1800-sample stroke. THE FIX is to cache committed ink into its own surface beside _page_surface and blit it, so a frame costs O(1) plus the live stroke instead of O(strokes). Invalidation is the whole risk and it is the same list _page_surface already has, plus every ink mutation: add/erase/undo/redo/move/resize/recolour, page change, zoom (the surface is scale-dependent, hence _surface_scale). Compositing is safe - each stroke's alpha is baked into the layer, so overlapping translucent highlighter still double-darkens exactly as it does when painted one by one. SHIPPED (2026-08-10): committed ink is now a cached SURFACE beside _page_surface, blitted per frame. Measured on the user's own committed strokes: per-frame cost went from 4.1 / 16.4 / 65.6 ms at 25 / 100 / 400 strokes to a FLAT 1.42 / 1.50 / 1.78 ms - one blit plus a 0.03-0.39 ms fingerprint. TWO DESIGN DECISIONS WORTH KEEPING. (1) The cache is keyed on a FINGERPRINT of what would be painted, not on a list of invalidation call sites. There are a dozen ways a stroke changes here (draw, erase, undo, redo, move, resize, rotate, reshape, weld, recolour, grid re-space, paste) and a cache keyed on remembering all of them goes stale on the thirteenth - and a stale layer is the ONLY bug this design can have. The fingerprint samples INK_SIG_SAMPLES=5 points per stroke, which is what catches a control-point drag on an interior vertex; an endpoints-only signature misses exactly that. invalidate_ink() is the escape hatch. (2) An APPEND is detected as a prefix of the old signature and painted onto the existing layer. Found by measuring, not by reasoning: the first version repainted in full on every change, which is O(strokes) = 80 ms at 400 of them, arriving exactly when you LIFT THE PEN - i.e. it would have traded a steady lag for a hitch at the worst possible moment. Committing a stroke now costs 0.16 ms at 100 strokes and 0.55 ms at 400. Erase/undo/move/recolour/page/zoom still rebuild, correctly: a surface can only gain ink, not lose it. NOT DONE: the text sheet still repaints per frame - its ink is on an overlay with a different substrate, so this does not transfer for free. USER REPORT that this does NOT explain, and it is the honest limit of the whole thread: there is still delay on the FIRST line, on an empty page where the stroke cost is zero. That is the structural pipeline and mostly not ours - up to 7.5 ms waiting for the next pen report, up to 16.7 ms of frame quantisation, one to two frames of compositor latency, plus panel response, i.e. 30-60 ms end to end before Sidemark does anything wrong. The old capture confirms the app itself is already at the frame limit on an empty page (16.5 ms per sample = 60 fps). THE ONE SOFTWARE LEVER LEFT FOR IT IS PREDICTION, which makes this the moment its known defect is worth fixing: PREDICT_SMOOTH is an EMA weight per EVENT, so its time constant changes with the report rate - at 30 Hz it was ~100 ms, five times the lead it was smoothing. It should be a time constant in ms applied per elapsed dt. Fix that, re-grade with ink_replay --predict, and only then judge it in the hand. USER'S IDEA, built the same day: extras/latency_probe.py measures the end-to-end lag by chasing a moving dot. A dot sweeps at a known speed, the user keeps the pen TIP on it, and the time offset that best aligns the dot curve with the reported pen curve is the lag. The sweep is a SINE and not a triangle: a triangle's velocity flips instantly at each end, which no hand can follow, and the tracking error there would swamp the lag. WHAT IT HONESTLY MEASURES: the WHOLE loop - pen, app, compositor, panel, eye, hand, pen - and it cannot separate the display half from the input half. The user's own tracking is inside the number, and because a predictable sweep is one people ANTICIPATE, the result is closer to a lower bound on the machine than an upper one. So the number to trust is a DIFFERENCE: run it twice changing one thing (prediction on/off, a compositor setting, another machine) and the human term is roughly constant, so the delta is real even when the absolute is soft. It reports the fit RESIDUAL beside the lag for exactly this reason - a hand that was not really tracking must be visible as a bad fit rather than as a confident wrong number, and --selftest checks both halves against known lags without hardware (a wrong fit looks exactly like a fast machine). Deliberately imports nothing from sidemark, so it measures the platform and can be pointed at another toolkit or machine for comparison. INK LAYER VERIFIED IN THE APP (2026-08-10, the user's hand): erase, undo/redo, lasso-move with no ghost left behind, zoom in/out staying sharp and in register, page turn, and drawing on a page that already carries a lot of ink. The fingerprint-keyed cache holds. LATENCY PROBE, first run, and the bug it exposed in ITSELF: it exited immediately with '1 dot frames'. A UNIT MISMATCH - _now() returns MILLISECONDS (which is what the fit works in) while --seconds and --period arrive in SECONDS, so the run-length test fired on the second frame and closed the window after 4 ms. Worth recording for two reasons. First, the symptom reads as BROKEN HARDWARE rather than as a bug in the measuring tool, which is the failure mode every diagnostic has and the reason --selftest exists (it passed throughout - it exercises the fit, not the window). Second, it was found by REPRODUCING it under the headless weston the test suite already runs, not by reading the code: two plausible wrong guesses (the tick callback's argument count, an exception swallowed by GTK) were both eliminated in a minute by running them. The fix names the units in the fields themselves (_run_ms, _period_ms) so the two cannot be mixed again. Also fixed while there: the probe now reads get_history() too, or it would sample the pen once per FRAME and report the frame rate as the pen rate - the exact confusion this whole row is about - and it uses ONE controller, since a GestureDrag beside the motion one puts every sample into the fit twice. FIRST REAL RUN, and the user's correction to the METHOD (2026-08-10). The run fitted 93 ms with residual 0.263 - correctly self-rejected as a poor fit, which is the guard earning its place on the first try. The user's diagnosis was right and better than the design: a back-and-forth SWEEP changes speed through every cycle, fastest at the centre and stopping dead at each end, and a reversal is the hardest thing there is to track - so the hand's error peaks exactly where the measurement lives. Default path is now a CIRCLE at constant angular speed: no reversals, one speed, so the hand settles in and its error becomes a steady phase offset, which is precisely what the fit reads. The fit is now 2D (error is the distance, so both axes constrain the lag at once; a line degenerates to the axis that moves), the default period slowed from 1.6 s to 3.0 s and the amplitude from 380 to 260 px, and a faint GUIDE RING is drawn under the dot - you cannot anticipate a path you cannot see, and anticipation is exactly what keeps the human term out of the number. --path line keeps the old sweep. The report also prints the dot's speed in px/s and px per 10 ms, without which a lag in milliseconds is unreadable. SEPARATE FINDING FROM THE SAME RUN, worth chasing: the probe's own frame interval was 22.5 ms, ~44 fps, in a window drawing ONE DOT on a 60 Hz panel. That is not Sidemark - it is a trivial GTK4 window - so something in the compositor/renderer path is not presenting at the panel's rate, and it is 22.5 ms of the latency budget rather than the 16.7 assumed everywhere above. Worth re-running the probe against GSK_RENDERER variants and with Hyprland's tearing/direct-scanout options before concluding anything about prediction. MEASURED END TO END, AND MY OWN ESTIMATE WAS WRONG (2026-08-10). I had put the machine at 40-60 ms. Two independent methods agree it is about 110 ms. (1) The probe fitted 125 ms with residual 0.069, and the user reports staying ON the ball, so the human tracking term was near zero and the number is almost entirely machine. (2) THE USER'S OBSERVATION, which is the isolation the probe could not provide: the MOUSE CURSOR following the pen sits ~1 cm behind the nib, and behind the ball, with the same delay felt in Sidemark. At 64 px/cm (1920 px across a ~30 cm panel) and 569 px/s that is 64/569 = 112 ms. The two agree. WHY THE CURSOR OBSERVATION SETTLES OWNERSHIP: the cursor is drawn by HYPRLAND, not by Sidemark and not by GTK. If it lags the nib by as much as the ink does, the bulk of the latency is below the application and no work in Sidemark can recover it. It also suggests a SOFTWARE cursor - a hardware cursor plane updates at input rate and would not lag like this - so cursor:no_hardware_cursors is the first Hyprland setting to check. 110 ms is far more than a composited path should cost (a frame is 16.7 ms), so something there is buffering several frames deep. GSK RENDERER IS A FREE WIN, measured: the DEFAULT (Vulkan) ran the probe's window at 24.4 ms per frame (41 fps) drawing ONE DOT on a 60 Hz panel, while GSK_RENDERER=gl gave 16.9 ms (59 fps) and =cairo 16.7 ms (60 fps). End-to-end lag fell from 125 ms to 107/105. That is ~8 ms per frame the default renderer is losing on this hardware, and it applies to Sidemark for free - same toolkit, same compositor. NBnglis a RENAMED value (it isglnow) and silently falls back, so a test using it is not testing what it says. CALIBRATION OF THE PROBE ITSELF, via --inject-lag: 0 ms -> 125, 100 ms -> 200, so a 100 ms injection moved the fit by 75. Deltas are real and directional but UNDER-REPORT by roughly 25%, and the mechanism is the hand: shown a visibly larger delay the user anticipates harder, and their own lag shrinks to absorb part of the injection. So the renderer's true benefit is likely nearer 25 ms than the 20 measured, and any future delta from this tool should be read as a floor. The capture now also counts motion EVENTS per stroke, so ink_replay reports the canvas's FRAME rate beside the pen's report rate. Samples arrive per pen report and events arrive per frame, so the two together say which of the two a stroke was limited by - and that is the measurement that settles a renderer or compositor A/B, which by feel is ambiguous (the user's verdict on GSK_RENDERER=gl in the app was 'feels similar, maybe a tiny bit faster', which on a ~20 ms change out of ~110 is exactly what a real but small improvement feels like and is not something to hardcode a renderer on). WHERE THE LATENCY LIVES, SETTLED (2026-08-10): UPSTREAM OF THE COMPOSITOR. Forcing a HARDWARE cursor plane (cursor:no_hardware_cursors 0, so the cursor is not composited at all) left the ~1 cm gap to the nib unchanged, and forcing SOFTWARE cursors (1) made no difference either. So the ~110 ms is spent before anything is drawn - the Goodix digitiser's own processing, libinput, and panel response - and neither Sidemark nor Hyprland can recover it. Note the option is a NEGATIVE (0 = use hardware cursors = fast, 1 = no hardware cursors = software, 2 = auto), which is easy to test backwards; the user's first run was at 1. PREDICTION CANNOT BRIDGE IT, and this closes the question rather than deferring it again. Re-graded on 133 Hz ink with the damping bug fixed: recovery of the lag error is 11% at a 10 ms lead, 10% at 20, 1% at 40, -11% at 60 and -29% at 80, and it makes matters worse on 33% of samples at 10 ms rising to 63% at 80. The lag needing masking is 110 ms, where a guess is far worse than none. THE REASON IS INFORMATION, NOT MODELLING: a pen stroke changes direction on a ~100 ms timescale and a letter takes 200-400 ms, so predicting 110 ms ahead is guessing the second half of a letter that has not been written - kinematics cannot know whether a curve is closing an 'o' or leaving into an 'l'. A Kalman, constant-acceleration or learned predictor fits more parameters to a future that is not in the data and hits the same wall. Prediction's realistic ceiling here is masking ~20 ms of 110, at the cost of overshoot at every cusp. Decision: keep it off, keep the bug fix, build nothing further. THE BUG FIX, which was worth making regardless: PREDICT_SMOOTH was an EMA weight applied PER EVENT, so its meaning drifted with the report rate - a ~92 ms time constant when the canvas saw 30 Hz, five times the lead it was damping, which is most of why prediction measured as useless. It is now PREDICT_SMOOTH_MS, a real time constant applied over elapsed dt, meaning the same thing at any rate; ink_replay applies the identical damping so it grades the filter the app actually runs. CHOOSING ITS VALUE EXPOSED A TRADE-OFF NO METRIC CAN SETTLE: swept at a 20 ms lead on real ink, recovery falls monotonically with damping - 41% undamped, 31% at 10 ms, 25% at 15, 19% at 20, 10% at 30 - so ACCURACY alone says use none. Damping exists for STABILITY, which no error metric measures, and was added because the tip visibly jittered - a complaint from the 30 Hz era, when consecutive guesses disagreed wildly. At 133 Hz they agree far better, so less is needed. Set to 15 ms with the sweep recorded in the comment; re-judge in the hand before moving it.