SLICE-1.3 Design Notes — Encoder-Rate Motion
- Slice: SLICE-1.3
- Implementation status: Completed (2026-04-30, criterion 7 amended)
- Audience: anyone modifying the encoder pipeline, the simulator timer mechanics, or the canonical store's relationship with high-rate streams
This doc explains how the encoder pipeline actually works in code — the class shapes, the runtime flow, and the load-bearing decisions that the spec intentionally abstracted. Read this if you're planning to extend the encoder stream, port it to a real driver, or borrow its design for a new high-rate subsystem (Phase 2.3 lift-out is the obvious next consumer).
1. Quick reference
Key types:
| Type | Project | Role |
|---|---|---|
EncoderAxis (enum) | Application | X, Y — sized for future Z/theta/focus extension |
EncoderSample (record) | Application | One time-stamped position sample for one axis |
EncoderSnapshot (record) | Application | Immutable batch — one snapshot per producer tick |
IEncoderStream | Application | The bounded read-only contract the consumer holds |
SimulatedEncoderSource | Infrastructure | The producer; IHostedService + IEncoderStream |
EncoderStreamPipelineService | Application | The consumer; BackgroundService |
WinMmTimePeriod | Infrastructure | P/Invoke wrapper for winmm!timeBeginPeriod(1) |
SimulatorEncoderOptions | Infrastructure | Simulator:Encoder config block |
Key files:
src/InspectionPrototype.Application/State/EncoderAxis.cs
src/InspectionPrototype.Application/State/EncoderSample.cs
src/InspectionPrototype.Application/State/EncoderSnapshot.cs
src/InspectionPrototype.Application/Abstractions/IEncoderStream.cs
src/InspectionPrototype.Application/Services/EncoderStreamPipelineService.cs
src/InspectionPrototype.Infrastructure/Simulator/SimulatedEncoderSource.cs
src/InspectionPrototype.Infrastructure/Simulator/Interop/WinMmTimePeriod.cs
src/InspectionPrototype.Infrastructure/Simulator/SimulatorEncoderOptions.cs
src/InspectionPrototype.Infrastructure/Simulator/SimulatorEncoderOptionsValidator.csKey tests (all in tests/InspectionPrototype.Tests/):
| Test | Asserts |
|---|---|
EncoderStreamPipelineServiceTests.UpdateCount_Equals_Zero | Load-bearing. Pipeline drains 10 snapshots; IAppStateStore.Update is called 0 times. If this test ever fails, the slice's whole architectural point is broken. |
SimulatedEncoderSourceTests.ProduceAsync_At200Hz_* | 1-second integration; ≥ 80% of 200 expected samples per axis |
WinMmTimePeriodTests | Acquire-then-dispose × 10 back-to-back doesn't throw |
SimulatorEncoderOptionsValidatorTests | Empty axes, duplicate axes, missing noise, unknown noise.Kind |
Key metrics (on the InspectionPrototype meter):
| Counter | Dimension | Emitted by |
|---|---|---|
encoder.samples.ingested | axis | EncoderStreamPipelineService (consumer) |
encoder.samples.coalesced | axis | SimulatedEncoderSource (producer) |
The consumer counts ingested samples. The producer counts dropped snapshots. This split is intentional — see decision (a) below.
2. Class shape
┌─────────────────────────────┐
│ appsettings.json │
│ "Simulator:Encoder": { │
│ "Axes": [ │
│ { Axis: "X", Noise }, │
│ { Axis: "Y", Noise } │
│ ] │
│ } │
└──────────────┬──────────────┘
│ binds via
│ IOptionsMonitor<SimulatorEncoderOptions>
▼
┌─────────────────────────────┐
│ SimulatorEncoderOptionsValidator │ ── ValidateOnStart
└──────────────┬──────────────┘
│
▼
┌──────────────┐ ┌──────────────────────┐
│ IMotion- │ │ ISimulator- │
│ Controller │── CurrentX / CurrentY (lock-protected)─│ ProfileProvider │
│ │ │ .CurrentProfile │
│ (existing) │ │ .EncoderIntervalMs │
└──────┬───────┘ └──────────┬───────────┘
│ │
│ ┌───────────────────────────────────────────────┐ │
└──▶│ SimulatedEncoderSource │◀──────┘
│ (Infrastructure.Simulator) │
│ │
│ Implements: IEncoderStream + IHostedService │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ PeriodicTimer(EncoderIntervalMs) │ │
│ │ inside ProduceAsync loop │ │
│ └─────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ PublishSnapshot: │ │
│ │ - read commanded X, Y from IMotion │ │
│ │ - per axis: noise = Evaluate(...) │ │
│ │ - build ImmutableArray<EncoderSample>│ │
│ │ - channel.Writer.TryWrite(snapshot) │ │
│ └─────────────────┬───────────────────────┘ │
│ │ │
│ ┌─────────────────▼───────────────────────┐ │
│ │ Channel<EncoderSnapshot> │ │
│ │ capacity = 1, DropOldest, │ │
│ │ single writer + single reader │ │
│ └─────────────────┬───────────────────────┘ │
└────────────────────┼──────────────────────────┘
│ IEncoderStream.Reader
│
▼
┌─────────────────────────────────────────────┐
│ EncoderStreamPipelineService │
│ (Application.Services) │
│ │
│ : BackgroundService │
│ │
│ ExecuteAsync drains the channel and emits │
│ AppMetrics.EncoderSamplesIngested per │
│ sample, tagged with axis dimension. │
│ │
│ ★ Does NOT call IAppStateStore.Update ★ │
└─────────────────────────────────────────────┘
Two sibling P/Invoke helpers, used only by SimulatedEncoderSource:
┌────────────────────────────────────┐
│ WinMmTimePeriod (IDisposable) │
│ ctor: timeBeginPeriod(1) │
│ Dispose: timeEndPeriod(1) │
│ Acquired in StartAsync, │
│ released in StopAsync. │
└────────────────────────────────────┘3. Lifecycle
SimulatedEncoderSource is registered as both IEncoderStream and IHostedService. Its lifetime is bound to the host:
host startup host shutdown
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Created │───▶│ Idle │───▶│ Producing │───▶│ Stopped │
│ (ctor runs; │ │ (StartAsync │ │ (ProduceAsync│ │ (StopAsync │
│ axes built; │ │ acquires │ │ loop ticks │ │ cancels CTS;│
│ channel │ │ WinMm │ │ EncoderInt- │ │ awaits task │
│ created) │ │ boost; │ │ ervalMs) │ │ ≤ 5 s; │
│ │ │ starts │ │ │ │ disposes │
│ │ │ producer │ │ │ │ WinMm boost;│
│ │ │ Task) │ │ │ │ completes │
│ │ │ │ │ │ │ channel) │
└──────────────┘ └──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
│ profile change │
│ (EncoderInt- │
│ ervalMs differs)│
│ │
│ ┌──────────────┘
│ │
│ ▼
│ ┌─────────────────────┐
│ │ Rebuild timer │
└──┤ (inner loop break; │
│ outer loop creates│
│ new PeriodicTimer)│
└─────────────────────┘Profile-change detection lives inside the producer loop, polled once per tick. The producer doesn't subscribe to a ProfileChanged event — it reads _profileProvider.CurrentProfile.EncoderIntervalMs after each WaitForNextTickAsync and compares. If the value changed, the inner loop breaks and the outer loop disposes the current PeriodicTimer and creates a new one. This is simpler than event-driven cadence change because it doesn't need synchronization between the event handler and the producer task.
StopAsync is idempotent (Interlocked.Exchange(ref _stopped, 1) != 0 early-return), with a 5-second timeout on the producer task. If the producer doesn't drain in 5 s the host logs a warning and forces shutdown — important because WinMmTimePeriod raises system-wide timer resolution and a hung producer would leak the boost.
4. Runtime flow
The headline flow — one tick produces one snapshot, one snapshot becomes two metric increments:
Producer task Channel Pipeline service AppMetrics
───────────── ─────── ──────────────── ──────────
│ │
┌────┴────┐ │
│ Periodi-│ │
│ cTimer │ │
│ tick │ │
└────┬────┘ │
│ profile.EncoderIntervalMs unchanged? │
│ yes ──┐ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ PublishSnapshot: │ │
│ │ CurrentX/Y from │ │
│ │ IMotionController │ │
│ │ │ │
│ │ for axis in [X,Y]: │ │
│ │ noise = Eval( │ │
│ │ _refState[ │ │
│ │ axis], ...) │ │
│ │ sample = (axis, │ │
│ │ cmd + noise) │ │
│ │ │ │
│ │ snapshot = Imm- │ │
│ │ utableArray of 2 │ │
│ │ samples (X, Y) │ │
│ └────────┬────────────┘ │
│ │ TryWrite(snapshot) │
│ ▼ │
│ ┌──────────────┐ │
│ │ Bounded=1, │ │
│ │ DropOldest: │ │
│ │ │ │
│ │ ✓ written: │ ── ReadAllAsync ────▶┌──────────────────┐ │
│ │ pass │ │ for sample in │ │
│ │ │ │ snapshot.Samples │
│ │ ✗ replaced: │ │ │ │
│ │ coalesce↑ │ │ EncoderSamples-│──────▶│
│ │ (per-axis) │ │ Ingested.Add( │ +1 per
│ │ │ │ 1, axis=...) │ sample
│ └──────────────┘ └──────────────────┘ (per axis)
│
┌────┴────┐
│ next │
│ tick │
└─────────┘The producer's drop path also writes a per-axis metric (EncoderSamplesCoalesced), increments an internal counter, and rate-limits a Warning log to once per second. The drop path runs entirely in the producer task — the consumer never sees the dropped snapshot, so the EncoderSamplesIngested count is naturally correct (consumer-side count = received count).
The producer holds no shared state with the consumer beyond the channel. Per-axis noise ref-state lives in a Dictionary<EncoderAxis, double> owned by the producer; per-axis coalesce counts live in a ConcurrentDictionary accessed only from the producer task in practice (the IEncoderStream.PerAxisCoalescedCount getter is read from outside, hence the concurrent collection — but the writes are single-threaded).
5. Decisions made during implementation
These are choices the spec intentionally abstracted, made concrete by the code.
(a) Producer counts drops; consumer counts ingestions. The two counters seem redundant but they serve different debugging purposes. encoder.samples.ingested (consumer-side) is the authoritative receiver rate — what the criterion-7 measurement actually measures. encoder.samples.coalesced (producer-side) is the back-pressure indicator — non-zero means the consumer is lagging. If both rise together, the consumer is so far behind that even drops are visible. If only ingested rises, the channel is healthy. If only coalesced rises while ingested stays low, something is starving the consumer (CPU saturation, host swap-out).
(b) WinMmTimePeriod.AcquireOrFallback instead of new WinMmTimePeriod(1). The producer must run on non-Windows hosts (CI, Linux dev machines) without crashing. AcquireOrFallback catches the P/Invoke failure, logs a Warning that criterion 7 cannot be met, and returns a NoOpDisposable. The producer keeps running; it just ticks at the default OS resolution (~15.6 ms = ~64 Hz on Windows). On Windows production hosts the boost is acquired normally. The fallback is never silent — the Warning log is the surface that tells operators why their capture rate is suspiciously low.
(c) Bounded channel capacity = 1. The right choice for a high-rate stream where freshness matters more than completeness. With capacity = 1 and DropOldest, the consumer always reads the most recent snapshot and back-pressure shows up as a coalesce count rather than as memory growth. The alternative (larger buffer) would defer the back-pressure signal and consume more memory under sustained lag.
(d) Profile-change detection by polling, not by event. The producer reads _profileProvider.CurrentProfile.EncoderIntervalMs once per tick and breaks the inner loop on change. This trades minimal per-tick overhead (one struct property read) for avoiding cross-thread synchronization between an event handler and the producer task. Given the producer runs at 200–1000 Hz, a one-tick latency to detect a profile change is irrelevant.
(e) Per-axis ref-state is preserved across profile changes. When the profile changes mid-run, the new PeriodicTimer is rebuilt but the _axisRefState dictionary stays. This means the random-walk noise looks visibly continuous — switching from Normal to EncoderRate doesn't reset the noise to baseline. Important for any future operator UI that visualizes the encoder stream live.
(f) Single allocation per tick: ImmutableArray.Builder<EncoderSample>(2). The producer allocates one builder per tick, fills two EncoderSample records, and calls ToImmutable(). Total allocation per tick: ~64 bytes for the array + 2× ~40 bytes for the samples ≈ 144 bytes per tick. At 1 kHz this is ~144 KB/sec — small enough that Gen-0 GC handles it without escalation. Pooling these objects is a Phase-2 follow-up if the soak data ever shows it matters; SLICE-1.3's slice-1-4-soak-8h row didn't surface any pressure here.
6. Invariants and traps
Things to know before changing this code.
WinMmTimePeriod is system-wide. timeBeginPeriod(1) raises Windows' timer resolution for every process on the machine, not just this one. While the boost is held, other processes also see ~1 ms timer accuracy — improving their behavior at a small CPU cost. The Dispose() call restores the previous resolution (the OS reference-counts the period requests). If the producer hangs without disposing, the system stays in 1 ms mode until the process exits. The 5-second StopAsync timeout exists specifically to prevent this leak. Do not remove it.
Consumer must never write through AppState. This is the slice's whole point. The EncoderStreamPipelineServiceTests.UpdateCount_Equals_Zero test catches direct violations (a _store.Update(...) call inside ExecuteAsync). It does NOT catch indirect violations — for example, if a future change subscribes to IEncoderStream from MainViewModel and projects samples into an observable property that touches AppState, the test passes but the slice's design is broken. Don't subscribe to IEncoderStream from anything that ends up writing to AppState. Phase 2.3 (data-plane lift-out) is the right place to add observable-stream surfaces; until then, only the metric is consumed.
The EncoderRate profile sets EncoderIntervalMs = 1 (1 kHz target). The actual achieved rate is ~657 Hz on Windows (criterion 7 amended to documented-not-gated). The cause is not in this slice's code — it's the platform-level PeriodicTimer + winmm.timeBeginPeriod(1) ceiling. If you re-derive criterion 7 on a host with better real-time properties (RTOS, Linux with PREEMPT_RT), expect to see closer to the original 1 kHz target. The follow-up filed in roadmap-progress.md (encoder-cadence remediation: Stopwatch-busy-yield / timeSetEvent / CreateWaitableTimerEx) would address this on Windows specifically.
Random.Shared is not used in the producer. The producer holds its own private readonly Random _rng = new(). This is deliberate — Random.Shared is contended across threads (ConcurrentTagSource emitters use it), and a high-rate producer hammering shared state is a measurable allocation source. The dedicated Random is single-threaded by construction (only the producer task touches it).
AcquireOrFallback on a non-Windows host produces a no-op disposable. The producer keeps running but at the default OS timer resolution. On Linux that's 1 ms by default (CONFIG_HZ=1000) — actually fine for the 5 ms EncoderRate profile. On Windows without the boost it's 15.6 ms — too coarse for any encoder profile shorter than ~16 ms. If a future capture surprises by reading ~64 Hz on a Windows host where it should be 200 Hz, check the startup log for the AcquireOrFallback warning — that's the symptom of a P/Invoke failure (winmm.dll missing, sandboxed environment, etc.).
The encoder.x.counts and encoder.y.counts tags in Simulator:Tags are unrelated to the encoder stream. They share the word "encoder" coincidentally. The tags are part of the multi-tag telemetry (SLICE-1.1) that flows through AppState.LatestTagValues. The encoder stream introduced in SLICE-1.3 is a separate channel that bypasses AppState. The two coexist in the same simulator and produce different metric counters (samples.ingested{tag.name} vs encoder.samples.ingested{axis}). Do not conflate them when reading captures or reasoning about per-axis encoder accuracy.
7. Test surface
What's covered, what isn't, and why.
Covered by unit tests (tests/InspectionPrototype.Tests/):
EncoderStreamPipelineServicedrains aFakeEncoderStreamand emits the metric correctly, and does not callIAppStateStore.Update. The latter is the architectural assertion — see invariant note above.SimulatedEncoderSourceproduces ≥ 80% of expected samples per axis in a 1-second integration test (loose because the host's timer resolution varies; the hard ±2% bar is in the 10-min capture, not the unit test).WinMmTimePeriodacquire-then-dispose × 10 back-to-back doesn't throw (regression on the disposal idempotency).SimulatorEncoderOptionsValidatorrejects empty axes, duplicate axis values, missing noise blocks, and unknown noise.Kind values — config errors fail at startup, not at runtime.
Covered by capture (docs/captures/slice-1-3-encoder-rate-2026-04-30.csv row block):
- The end-to-end achieved rate at 1 ms target on Windows: 656.6 Hz on both axes (the receiver-rate evidence behind the criterion-7 amendment).
- That
runs.faulted = 0andframes.dropped = 0while the encoder is running at full tilt — confirms the encoder doesn't destabilize the workflow state machine or starve the frame pipeline. - That
gen-2 GC count = 50andLOH-alloc-rate = 23 KB/s— confirms the per-tick allocation is small enough not to cause GC pressure even at high tick rates.
Not covered (intentional gaps):
- Profile-change-mid-run timer rebuild is exercised manually via the Pass-1 smoke test (switch profiles, verify no leaks, no log spam) but has no automated test. Adding a test would require mock-time infrastructure or a
ProduceAsyncthat exposes the inner-loop iteration count for assertions. Filed implicitly under "spec criterion 12 amendment pattern" — if Phase 2 surfaces a bug here, write the test then. - The fallback-disposable path on non-Windows hosts.
AcquireOrFallbackis tested for the success path (acquire works, dispose works); the failure path is reached only on hosts wherewinmm.dllis absent. CI runs on Windows; the failure path hasn't been exercised. If the project ever supports a non-Windows CI runner this becomes important. - High-contention scenarios — what happens if 10 different processes all hold
WinMmTimePeriod(1)boosts simultaneously? Probably nothing surprising (Windows reference-counts the period requests) but no test verifies this. Out of scope unless a real deployment surfaces it.
Notably absent test: there is no test for "consumer crashes; producer keeps going; system survives." The BackgroundService framework handles consumer crashes by terminating the host (default behavior). If a consumer exception should not take down the app, that's a Phase-2 hardening change requiring different exception handling in EncoderStreamPipelineService.ExecuteAsync. The spec-time decision was that an encoder-pipeline crash is a critical fault that should terminate the app; revisit this if the soak data ever shows it.