Skip to content

TASK-1.3: Implement Encoder-Rate Motion

Objective

Introduce a 1 kHz encoder-position stream (IEncoderStream) that runs alongside — but separate from — the existing 20 Hz UI position feed. Producer reads commanded position from IMotionController.CurrentX/CurrentY, adds per-axis RandomWalkNoise, and publishes EncoderSnapshots to a bounded channel. A pipeline service drains the channel and increments per-axis metrics without touching AppState — that bypass is the slice's load-bearing design. Capture a 10-minute EncoderRate row block and prove the receiver sees 1 kHz ± 2% per axis.

Scope

  • new domain shapes: EncoderAxis, EncoderSample, EncoderSnapshot
  • new abstraction IEncoderStream + SimulatedEncoderSource producer
  • new pipeline service EncoderStreamPipelineService (drains; metric only; no AppState write)
  • new infra config: Simulator:Encoder block, SimulatorProfile.EncoderIntervalMs, validators
  • WinMmTimePeriod P/Invoke wrapper to enable real 1 ms tick on Windows
  • new seed profile EncoderRate in appsettings.json; existing profiles updated with EncoderIntervalMs: 5
  • new AppMetrics counters: encoder.samples.ingested (axis dim), encoder.samples.coalesced (axis dim)
  • new measurement helper Get-EncoderRatePerAxis in tools/MeasurementExtraction.psm1; ConvertTo-MeasurementRow emits a 20-metric block when encoder rows are present
  • new runbook §4.4 (encoder-rate soak)
  • 10-minute EncoderRate capture under the existing MultiTagSoakFlaUi scenario, row block in docs/reviews/phase-1-measurements.md
  • tests: shape round-trip, producer cadence (loose 1-s integration), validator rejection cases, pipeline service does-not-touch-AppState, profile-switch rebuild

Non-Scope

  • UI plot or chart of the encoder stream (Phase 2 / 3)
  • buffer pooling for EncoderSample / EncoderSnapshot (Phase 2)
  • writing encoder samples into AppState — explicitly forbidden by the spec
  • closed-loop control or any use of the encoder stream beyond rate measurement
  • modifying IMotionController interface or SimulatedMotionController.InterpolateAsync behavior
  • new FlaUI scenario class — MultiTagSoakFlaUi with -Profile EncoderRate is the capture path
  • additional axes (Z, theta, focus) — only X and Y in this slice
  • adapting Normal / MultiTag / HighFrameRate profiles' encoder rate beyond the seed EncoderIntervalMs: 5 default
  • altering the existing encoder.x.counts / encoder.y.counts tags in the multi-tag registry (those are unrelated despite the naming overlap; see the "Naming overlap" section of SLICE-1.3)

Touched Projects

  • src/InspectionPrototype.ApplicationEncoderAxis, EncoderSample, EncoderSnapshot, IEncoderStream, EncoderStreamPipelineService, AppMetrics counters
  • src/InspectionPrototype.InfrastructureSimulatedEncoderSource, Interop/WinMmTimePeriod, SimulatorProfile.EncoderIntervalMs, SimulatorEncoderOptions, SimulatorEncoderOptionsValidator, SimulatorProfilesValidator extension, DI wiring in InfrastructureServiceCollectionExtensions
  • src/InspectionPrototype.Appappsettings.json (Simulator:Encoder block + EncoderIntervalMs per profile + new EncoderRate profile)
  • tests/InspectionPrototype.TestsEncoderSampleTests, SimulatedEncoderSourceTests, EncoderStreamPipelineServiceTests, SimulatorEncoderOptionsValidatorTests, SimulatorProfilesValidatorEncoderTests, WinMmTimePeriodTests, FakeEncoderStream stub
  • tools/MeasurementExtraction.psm1Get-EncoderRatePerAxis helper, ConvertTo-MeasurementRow 20-metric output
  • tests/Tools/MeasurementExtraction.Tests.ps1 — Pester tests for the new helper
  • docs/runbook/capturing-measurements.md — new §4.4
  • docs/reviews/phase-1-measurements.md — new row block
  • docs/captures/ — the new CSV evidence
  • (no changes to) src/InspectionPrototype.Application/Abstractions/IMotionController.cs
  • (no changes to) src/InspectionPrototype.Infrastructure/Simulator/SimulatedMotionController.cs core behavior
  • (no changes to) src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs motion subscription

AI Tool Guidance

Three Copilot passes; one-pass-per-session protocol as in TASK-1.2.

  1. Domain + producer + Win32 timer + validatorsEncoderAxis / EncoderSample / EncoderSnapshot, IEncoderStream, SimulatedEncoderSource, WinMmTimePeriod P/Invoke, Simulator:Encoder config + validators, SimulatorProfile.EncoderIntervalMs + validator extension, seed appsettings.json with EncoderRate profile and EncoderIntervalMs: 5 on existing profiles. Tests for the simulator path. NO pipeline service, NO metrics, NO measurement-extraction work.
  2. Pipeline service + metrics + measurement extractionEncoderStreamPipelineService drain loop, AppMetrics.EncoderSamplesIngested / EncoderSamplesCoalesced, Get-EncoderRatePerAxis Pester-tested helper, ConvertTo-MeasurementRow 20-metric output. NO captures, NO new FlaUI tests.
  3. Capture + row block + runbook §4.4 — Run the 10-min EncoderRate scenario via existing MultiTagSoakFlaUi, append the 20-metric row block, write §4.4, update CLAUDE.md / roadmap-progress. NO code changes.

Acceptance Criteria Mapping

The implementation must satisfy all acceptance criteria from SLICE-1.3:

  • Pass 1 covers criteria 1, 2, 4, 5, 6, and the simulator/validator portions of 12 and 13
  • Pass 2 covers criteria 3, 11, and the pipeline / metric / extraction portions of 12
  • Pass 3 covers criteria 7, 8, 9, 10

Copilot Agent Prompts

Pass 1 — Domain + producer + Win32 timer + validators

You are implementing Pass 1 of TASK-1.3 in this repository: introduce the
encoder-stream domain shapes, the producer (SimulatedEncoderSource), the
Windows timer-resolution P/Invoke wrapper, and the configuration + validators.
NO pipeline service, NO AppMetrics changes, NO measurement-extraction work.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.3-encoder-rate-motion.md          (the requirements)
- docs/tasks/TASK-1.3-implement-encoder-rate-motion.md (this task)
- src/InspectionPrototype.Application/Abstractions/IMotionController.cs
- src/InspectionPrototype.Application/Abstractions/ITagStream.cs        (the parallel pattern)
- src/InspectionPrototype.Application/State/TagSnapshot.cs
- src/InspectionPrototype.Application/State/TagSample.cs
- src/InspectionPrototype.Application/State/NoiseModel.cs
- src/InspectionPrototype.Application/State/NoiseModelEvaluator.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedTagSource.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorTagsValidator.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesOptions.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesValidator.cs
- src/InspectionPrototype.Application/State/SimulatorProfile.cs
- src/InspectionPrototype.App/appsettings.json
- src/InspectionPrototype.Infrastructure/InfrastructureServiceCollectionExtensions.cs

Spec acceptance criteria 1, 2, 4, 5, 6, and the simulator/validator portions of
12 and 13 are the definition of done for this pass.

## Scope of this pass

Domain shapes, producer + interface, P/Invoke wrapper, profile field, encoder
config + validators, seed config, simulator tests. NO EncoderStreamPipelineService
class. NO AppMetrics counters. NO MeasurementExtraction.psm1 changes.

## Deliverables

1. Domain shapes (src/InspectionPrototype.Application/State/):
   - EncoderAxis enum: X, Y
   - EncoderSample record:
       EncoderAxis Axis,
       DateTimeOffset Timestamp,
       double PositionUnits,
       TagQuality Quality
   - EncoderSnapshot record:
       ImmutableArray<EncoderSample> Samples
       (use System.Collections.Immutable; size will be exactly Axes.Count
        per snapshot in this slice — typically 2)

2. Abstraction (src/InspectionPrototype.Application/Abstractions/IEncoderStream.cs):
   - mirror ITagStream:
       ChannelReader<EncoderSnapshot> Reader { get; }
       long SnapshotCoalescedCount { get; }
       long PerAxisCoalescedCount(EncoderAxis axis)
       IReadOnlyCollection<EncoderAxis> Axes { get; }
   - same XML-doc style as ITagStream

3. Encoder configuration (src/InspectionPrototype.Infrastructure/Simulator/):
   - SimulatorEncoderOptions: ImmutableArray<SimulatorEncoderAxisOptions> Axes
   - SimulatorEncoderAxisOptions: EncoderAxis Axis, NoiseOptions Noise
       (reuse the existing NoiseOptions block from SLICE-1.1; do not redefine)
   - SimulatorEncoderOptionsValidator: implements IValidateOptions<SimulatorEncoderOptions>:
       * Reject empty Axes
       * Reject duplicate axis values across the array
       * Reject any axis with a missing Noise block
       * Defer Noise.Kind validation to the existing NoiseOptionsValidator pattern
       * Validation messages name the offending axis
   - SimulatorProfilesValidator (existing): extend to also reject
     EncoderIntervalMs < 1 or > 1000 with a message naming the profile and field

4. SimulatorProfile (src/InspectionPrototype.Application/State/SimulatorProfile.cs):
   - add property: int EncoderIntervalMs (default 5)
   - update SimulatorProfilesOptions JSON-binding shape to include the field

5. WinMmTimePeriod P/Invoke wrapper
   (src/InspectionPrototype.Infrastructure/Simulator/Interop/WinMmTimePeriod.cs):
   - Sealed class implementing IDisposable
   - On construction: P/Invoke winmm!timeBeginPeriod(uint period). Default
     ctor takes period=1 (1 ms).
   - On Dispose: P/Invoke winmm!timeEndPeriod(period) exactly once
     (Interlocked guard against double-dispose, mirroring the SLICE-1.5.1
     SimulatedTagSource pattern).
   - Use [LibraryImport("winmm.dll")] (Windows-only). The class lives in
     Infrastructure which already targets net10.0-windows for WPF; no extra
     project changes needed.
   - Static method WinMmTimePeriod.AcquireOrFallback(int periodMs, ILogger logger):
     attempts the P/Invoke; on a non-zero return code logs a warning and
     returns a no-op IDisposable so the producer still constructs but the OS
     timer is not boosted (the 10-minute capture criterion 7 will then fail
     with a clear receiver-rate gap, which is what we want — silent fallback
     would mask the issue).

6. SimulatedEncoderSource
   (src/InspectionPrototype.Infrastructure/Simulator/SimulatedEncoderSource.cs):
   - Implements IEncoderStream and IHostedService
   - Constructor takes: IMotionController, ISimulatorProfileProvider,
     IOptionsMonitor<SimulatorEncoderOptions>, NoiseModelEvaluator (or build
     one — match what SimulatedTagSource does), ILogger<SimulatedEncoderSource>
   - Channel: BoundedChannelOptions { Capacity = 1, FullMode = DropOldest,
     SingleReader = true, SingleWriter = true }
   - Per-axis ref state: a Dictionary<EncoderAxis, NoiseRefState> initialized
     from the configured noise models
   - StartAsync:
       * Acquire WinMmTimePeriod (period = 1)
       * Build PeriodicTimer(TimeSpan.FromMilliseconds(profile.EncoderIntervalMs))
       * Start the producer Task (do NOT block StartAsync — fire-and-forget
         pattern as in SimulatedTagSource)
   - StopAsync: cancel the producer CTS, await its completion with a 5s
     timeout, dispose the WinMmTimePeriod.
   - Producer loop: while !ct.IsCancellationRequested, await
     timer.WaitForNextTickAsync(ct):
       * Read motion.CurrentX, motion.CurrentY
       * For each axis in Axes (order: X then Y), compute
         noise = NoiseModelEvaluator.Sample(noiseModel, ref refState[axis], elapsed),
         then sample = new EncoderSample(axis, DateTimeOffset.UtcNow,
           commanded[axis] + noise, TagQuality.Good)
       * Build EncoderSnapshot from a fresh ImmutableArray of those samples
       * channel.Writer.TryWrite(snapshot). If false (channel was full and
         DropOldest replaced), increment SnapshotCoalescedCount and per-axis
         counters; rate-limit a Warning log (no more than once per second
         using a _lastDropLogUtc field with TimeSpan.FromSeconds(1) threshold)
   - Subscribe to ISimulatorProfileProvider.ProfileChanged: on change, dispose
     the current PeriodicTimer and rebuild with the new EncoderIntervalMs.
     The per-axis ref state is preserved across profile changes.
   - The producer must NOT increment AppMetrics counters — that is Pass 2's
     responsibility. For Pass 1 the counter increments live in the producer
     stub but only update SnapshotCoalescedCount / PerAxisCoalescedCount;
     `samples.ingested` etc. are wired in Pass 2.

7. DI registration (InfrastructureServiceCollectionExtensions):
   - services.AddOptions<SimulatorEncoderOptions>()
       .Bind(config.GetSection("Simulator:Encoder"))
       .ValidateOnStart() (uses the new validator above)
   - services.AddSingleton<SimulatedEncoderSource>()
   - services.AddSingleton<IEncoderStream>(sp => sp.GetRequiredService<SimulatedEncoderSource>())
   - services.AddHostedService(sp => sp.GetRequiredService<SimulatedEncoderSource>())

8. appsettings.json:
   - Add EncoderIntervalMs: 5 to Normal, Demo, HighDefect, MultiTag, HighFrameRate
   - Add a new EncoderRate profile (after HighFrameRate):
       Name: "EncoderRate"
       MotionSpeedUnitsPerSecond: 20.0
       TelemetryIntervalMs: 50
       FrameIntervalMs: 500
       FrameWidth: 640
       FrameHeight: 480
       BytesPerPixel: 1
       EncoderIntervalMs: 1
       DefectProbabilityPerFrame: 0.05
       ConnectionFailureProbability: 0.05
   - Add a "Simulator:Encoder" block (sibling of "Simulator:Tags") with:
       Axes:
         - Axis: "X", Noise: { Kind: "RandomWalk", Baseline: 0.0,
                               StepStdDev: 0.0005, ClampMin: -0.01, ClampMax: 0.01 }
         - Axis: "Y", Noise: { Kind: "RandomWalk", Baseline: 0.0,
                               StepStdDev: 0.0005, ClampMin: -0.01, ClampMax: 0.01 }

9. Tests under tests/InspectionPrototype.Tests/:
   - EncoderSampleTests:
       round-trip for the record fields; equality semantics of EncoderSnapshot
   - SimulatorEncoderOptionsValidatorTests:
       reject empty Axes, duplicate axes, missing Noise, unknown Noise.Kind
       (the last case via the existing NoiseOptionsValidator path)
   - SimulatorProfilesValidatorEncoderTests (or extend the existing
     SimulatorProfilesValidatorTests): reject EncoderIntervalMs of 0, -1, 1001
   - WinMmTimePeriodTests:
       (1) Acquire-then-dispose does not throw
       (2) Acquire/dispose 10× back-to-back does not throw
       (3) AcquireOrFallback returns a non-null IDisposable for periodMs in [1, 16]
       (Use [Fact] decorated for Windows-only. If the test project supports
       OperatingSystem.IsWindows() guards, prefer those.)
   - SimulatedEncoderSourceTests (integration-style, single-second cadence
     measurement):
       * Construct with FakeMotionController (CurrentX = 10.0, CurrentY = 20.0),
         FakeSimulatorProfileProvider (EncoderIntervalMs = 5, so 200 Hz on
         non-Windows-boosted timer), default seed encoder options
       * StartAsync, drain channel for ~1 s, count samples per axis
       * Assert per-axis count >= 160 (200 expected × 0.80 to absorb CI flake;
         the hard ±2% bar is in the 10-minute Pass 3 capture)
       * Assert position values are within commanded ± 0.01 (the noise envelope)
       * StopAsync cleanly within 5 seconds
   - FakeEncoderStream stub (tests/InspectionPrototype.Tests/Stubs/):
       Bounded channel, configurable count of pre-loaded snapshots, public
       writer for tests that want to push more during the test. Will be
       consumed by Pass 2's pipeline-service tests.

## Constraints

- Do NOT change IMotionController. Verify by checking the file is unchanged
  in the diff.
- Do NOT change SimulatedMotionController.InterpolateAsync, _currentX, or
  PositionChanged. Adding a new public CurrentX/CurrentY *getter* is already
  present and is what the encoder source consumes.
- Do NOT add a new InspectionPrototype counter in this pass. AppMetrics.cs
  is unchanged.
- Do NOT modify EncoderStreamPipelineService — it does not exist yet; that's
  Pass 2.
- Do NOT add the encoder stream to AppState. The AppState record stays unchanged.
- Do NOT modify MainViewModel. The 20 Hz UI position path is untouched.
- Do NOT pool EncoderSample arrays. Allocate fresh per snapshot.
- The integration test must dispose the WinMmTimePeriod boost cleanly. If the
  encoder source doesn't acquire the boost in StartAsync (because the test
  configures EncoderIntervalMs = 5 ≥ 16ms-or-default-tick-OK), that is fine —
  but if it does acquire, it must release on StopAsync.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Manual smoke test:
  - launch interactively (no --scenario flag)
  - app starts with Normal profile (EncoderIntervalMs = 5); no warnings or
    crashes about missing Simulator:Encoder config
  - switch via UI to EncoderRate profile; observe (in dotnet-counters monitor):
    no counters yet (Pass 2 wires them) — but the app does not crash, the
    producer ticker is alive, no diagnostics-warning log spam
  - switch back to Normal; producer rebuilds the timer; no leaks

## Report format when finished

- files created and modified
- confirmation that all existing tests pass plus new shape/validator/timer/producer tests
- a single commit hash
- commit message: "feat(sim): add encoder-rate stream producer + Win32 timer wrapper (pass 1/3 of TASK-1.3)"

Pass 2 — Pipeline service + metrics + measurement extraction

You are implementing Pass 2 of TASK-1.3. Pass 1 (encoder shapes, IEncoderStream,
SimulatedEncoderSource, WinMmTimePeriod, validators, EncoderRate seed profile)
is already merged. This pass adds the pipeline service that drains the channel,
the AppMetrics counters with axis dimension, and the MeasurementExtraction
helper Get-EncoderRatePerAxis so captures produce 20-metric blocks.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.3-encoder-rate-motion.md   (criteria 3, 11; verification on no-AppState-write)
- src/InspectionPrototype.Application/Abstractions/IEncoderStream.cs    (Pass 1)
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedEncoderSource.cs  (Pass 1)
- src/InspectionPrototype.Application/Diagnostics/AppMetrics.cs
- src/InspectionPrototype.Application/Services/TagStreamPipelineService.cs
  (the parallel pattern for a channel-draining BackgroundService)
- tools/MeasurementExtraction.psm1   (existing ConvertTo-MeasurementRow with 18-metric output)
- tests/Tools/MeasurementExtraction.Tests.ps1
- docs/captures/slice-1-2-high-fps-2026-04-27.csv
  (a fixture CSV; does NOT contain encoder counters yet — ConvertTo-MeasurementRow
   must emit "—" for the new rows when the input lacks them)

Pass 1 must be merged. Confirm by inspecting IEncoderStream and a fresh build
of SimulatedEncoderSource before starting.

## Scope of this pass

Pipeline service, AppMetrics extension, measurement-extraction helper, Pester
tests. NO captures, NO simulator-side changes, NO new FlaUI tests.

## Deliverables

1. AppMetrics (src/InspectionPrototype.Application/Diagnostics/AppMetrics.cs):
   - Add two Counter<long> properties:
       EncoderSamplesIngested  = _meter.CreateCounter<long>("encoder.samples.ingested")
       EncoderSamplesCoalesced = _meter.CreateCounter<long>("encoder.samples.coalesced")
   - Update the XML-doc summary to mention the axis dimension on the new counters

2. EncoderStreamPipelineService
   (src/InspectionPrototype.Application/Services/EncoderStreamPipelineService.cs):
   - Mirror TagStreamPipelineService structurally (BackgroundService base,
     ExecuteAsync drain loop, constructor takes IEncoderStream + AppMetrics +
     ILogger).
   - In ExecuteAsync:
       await foreach (var snapshot in _stream.Reader.ReadAllAsync(stoppingToken))
       {
           foreach (var sample in snapshot.Samples)
           {
               _metrics.EncoderSamplesIngested.Add(
                   1,
                   new KeyValuePair<string, object?>("axis", sample.Axis.ToString()));
           }
       }
   - Do NOT call _store.Update or otherwise touch IAppStateStore.
   - On the channel's CompleteAsync (host shutdown), exit the loop cleanly.

3. SimulatedEncoderSource Pass 2 wire-up:
   - The producer's existing snapshot-drop path (added in Pass 1 with only
     SnapshotCoalescedCount + PerAxisCoalescedCount updates) now also calls
     _metrics.EncoderSamplesCoalesced.Add(1, KeyValuePair("axis", "X"))
     and ("axis", "Y") on each dropped snapshot.
   - The producer's per-tick produce path does NOT increment EncoderSamplesIngested
     — that is the pipeline's job (so we measure the receiver-side rate, which
     is what criterion 7 demands). If a snapshot is dropped, the consumer never
     sees it, and EncoderSamplesIngested stays accurate.

4. DI registration (InfrastructureServiceCollectionExtensions or
   ApplicationServiceCollectionExtensions, wherever TagStreamPipelineService
   is registered — match the existing convention):
   - services.AddSingleton<EncoderStreamPipelineService>()
   - services.AddHostedService(sp => sp.GetRequiredService<EncoderStreamPipelineService>())

5. tools/MeasurementExtraction.psm1:
   - Add and export Get-EncoderRatePerAxis:
       function Get-EncoderRatePerAxis {
           [CmdletBinding()]
           param(
               [Parameter(Mandatory)][object[]] $Csv,
               [Parameter(Mandatory)][double]   $DurationSeconds
           )
           # Group encoder.samples.ingested rows by the 'Tags' (axis dimension) column
           $rows = $Csv | Where-Object {
               $_.'Counter Name' -match 'encoder\.samples\.ingested'
           }
           $perAxis = [ordered]@{}
           foreach ($axis in @('X', 'Y')) {
               $axisRows = $rows | Where-Object {
                   $_.Tags -match "axis=$axis"
               }
               if ($axisRows.Count -eq 0) {
                   $perAxis[$axis] = $null
                   continue
               }
               # Counter is a Rate (samples-per-update-interval). Sum the
               # Mean/Increment column and divide by the capture duration to
               # get average Hz across the run. (dotnet-counters CSV emits
               # one row per update interval per dimension; the column is
               # the increment over that interval.)
               $totalSamples = ($axisRows | Measure-Object -Property 'Mean/Increment' -Sum).Sum
               $perAxis[$axis] = [math]::Round([double]$totalSamples / $DurationSeconds, 1)
           }
           return $perAxis
       }

   - Update ConvertTo-MeasurementRow to call the helper (it must already accept
     a duration parameter — if not, add one) and append two rows:
       | encoder-rate-x (Hz)     | <perAxis['X'] or "—"> |
       | encoder-rate-y (Hz)     | <perAxis['Y'] or "—"> |
   - Use the same "—" sentinel pattern from SLICE-1.2 for absent rows; do not
     emit "0" when the CSV lacks encoder counters.

6. tests/Tools/MeasurementExtraction.Tests.ps1:
   - Add four Pester tests:
       Test "EncoderRatePerAxis_OnFixtureWithKnownCount_ReturnsCorrectHz":
         synthetic CSV with 600 rows (300 axis=X, 300 axis=Y) over 600 s,
         each row Mean/Increment = 1000 (1000 samples per 1-s update interval).
         Expect both axes ≈ 1000.0.
       Test "EncoderRatePerAxis_OnEmptyCsv_ReturnsNullPerAxis":
         pass [] with duration 10. Expect $perAxis['X'] -eq $null,
         $perAxis['Y'] -eq $null.
       Test "ConvertTo-MeasurementRow_AppendsTwoEncoderRows_WhenCsvHasEncoderCounters":
         feed a fixture CSV with both encoder.samples.ingested rows; assert
         the markdown output contains "encoder-rate-x" and "encoder-rate-y" rows
         with non-"—" numeric values.
       Test "ConvertTo-MeasurementRow_EmitsDashWhenEncoderMetricsAbsent":
         feed a fixture CSV with only InspectionPrototype non-encoder rows
         (e.g. the existing slice-1-2-high-fps fixture); assert both rows
         show "—".

7. tests/InspectionPrototype.Tests/EncoderStreamPipelineServiceTests.cs:
   - Use FakeEncoderStream from Pass 1 to push N=10 snapshots with 2 samples
     each. Use a recording IAppStateStore stub that increments a "Update was
     called" counter on every Update call.
   - Build EncoderStreamPipelineService with the fake stream, a real AppMetrics
     instance (or a counter-recorder), and a NullLogger.
   - StartAsync, wait until counter equals 20 (10×2), then StopAsync.
   - Assert: AppMetrics.EncoderSamplesIngested has been incremented 20 times
     across both axis dimensions (10 axis=X, 10 axis=Y) — verify via a
     MeterListener or via the AppMetrics-test pattern already in
     AppMetricsTagDimensionTests.
   - Assert: the recording IAppStateStore.UpdateCount == 0. This is the
     load-bearing test for criterion 3.

8. tests/InspectionPrototype.Tests/AppMetricsEncoderDimensionTests.cs (or
   extend AppMetricsTagDimensionTests):
   - Verify EncoderSamplesIngested and EncoderSamplesCoalesced exist as named
     counters on the InspectionPrototype meter, and that emitting a sample
     with axis="X" and another with axis="Y" produces two distinct
     dimension series in a MeterListener readback.

## Constraints

- Do NOT change SimulatedMotionController, IMotionController, or any UI code.
  This pass touches Application + Infrastructure DI + tools + tests only.
- Do NOT call IAppStateStore.Update from EncoderStreamPipelineService. A test
  asserts this directly; if it fails the slice's load-bearing design is
  violated.
- Do NOT count produced-but-dropped samples in EncoderSamplesIngested. Only
  consumed (i.e., drained-from-channel) samples are counted on the receiver
  side. This is what makes criterion 7 a meaningful end-to-end test.
- Do NOT modify Capture-Measurements.ps1 in this pass beyond what's required
  for ConvertTo-MeasurementRow's new output. The orchestrator already calls
  the function; an updated function automatically flows through.
- Pester tests must NOT depend on a real CSV in docs/captures/. Use synthetic
  in-memory PSCustomObject arrays as the existing tests do.
- The test-side counter-readback for AppMetrics.EncoderSamplesIngested must
  use the same MeterListener pattern as AppMetricsTagDimensionTests; do not
  introduce a new test infrastructure pattern.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Plus:
  - Pester: Invoke-Pester tests/Tools/MeasurementExtraction.Tests.ps1
    All four new tests pass plus the existing tests (gc-pause, LOH-alloc,
    partial-CSV).
  - Manual smoke capture (60 seconds, EncoderRate profile):
      tools/Capture-Measurements.ps1 -Scenario MultiTagSoak `
        -DurationSeconds 60 -Profile EncoderRate `
        -OutputCsv docs/captures/_smoke.csv `
        -CommitHash $(git rev-parse --short HEAD) -AllowDirty
    Verify:
      * exit code 0
      * the printed row block has 20 metrics, including encoder-rate-x and
        encoder-rate-y
      * encoder-rate-x and encoder-rate-y are between 950 and 1020 Hz
        (slightly looser than the 10-min ±2% bar to absorb 60-s warm-up
        noise — the hard bar applies to Pass 3)
    Delete the smoke CSV before commit.

## Report format when finished

- files created and modified
- confirmation all C# tests + Pester tests pass
- the smoke-capture stdout (the 20-metric markdown block included as evidence)
- a single commit hash
- commit message: "feat(app,tools): drain encoder stream into per-axis metrics; 20-metric capture row (pass 2/3 of TASK-1.3)"

Pass 3 — Capture + row block + runbook §4.4

You are implementing Pass 3 of TASK-1.3, the final pass. Passes 1 and 2 are
merged. This pass runs the 10-min EncoderRate capture, appends the row block,
writes runbook §4.4, and updates session-handoff documents. NO code changes —
Passes 1 and 2 own those.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-1.3-encoder-rate-motion.md   (criteria 7, 8, 9, 10)
- docs/runbook/capturing-measurements.md        (existing §3a, §4.1, §4.2, §4.3
                                                  to mirror for §4.4)
- docs/reviews/phase-1-measurements.md          (slice-1-2-real-frame-payloads
                                                  row to mirror)
- CLAUDE.md, docs/reviews/roadmap-progress.md
- tools/Capture-Measurements.ps1                (the orchestrator that runs the capture)

## Scope of this pass

Capture, table edit, runbook §4.4, session-handoff updates.

## Deliverables

1. Disable system sleep BEFORE capturing (TASK-1.5.1 follow-up runbook
   discipline; reaffirmed in TASK-1.2 Pass 3). On the capture machine:
       powercfg /change standby-timeout-ac 0
       powercfg /change monitor-timeout-ac 0
   Note the previous values in the session-log entry so they can be restored
   after the capture.

2. Run the 10-minute EncoderRate capture:
       $date = Get-Date -Format 'yyyy-MM-dd'
       tools/Capture-Measurements.ps1 -Scenario MultiTagSoak `
         -DurationSeconds 600 -Profile EncoderRate `
         -OutputCsv "docs/captures/slice-1-3-encoder-rate-$date.csv" `
         -CommitHash $(git rev-parse --short HEAD) `
         -SliceTag slice-1-3-encoder-rate-motion
   Confirm:
       * app exit code 0
       * encoder-rate-x and encoder-rate-y both in [980, 1020] Hz
         (criterion 7 — ±2% of 1000 Hz)
       * AppState change rate stays at or below ~25 Hz averaged over the run
         (criterion 8). If diagnostics has a counter for this, read it; if
         not, this is verified by a one-off note in the row block citing the
         tag-snapshot publisher's TelemetryIntervalMs (50ms = 20 Hz nominal).
       * frames.dropped == 0 (UI-side regression check; the encoder stream
         must not starve the frame pipeline)
       * tags.active == 50 (workdir-bug regression check from TASK-1.1 Pass 3)
       * 20 metrics in the printed row block, including encoder-rate-x and
         encoder-rate-y (criterion 9)

   If any of these fails, STOP — do not proceed to the table edit. Capture
   the failure mode (especially: encoder rate below 980 Hz typically means
   the WinMmTimePeriod boost did not take; check the producer's startup log
   for the AcquireOrFallback warning), file an issue, hand off.

3. Append row block to docs/reviews/phase-1-measurements.md:
   - new "### Row — slice-1-3-encoder-rate-motion" subsection AFTER the
     existing "### Row — slice-1-2-real-frame-payloads" subsection
   - mirror the SLICE-1.2 row's header block (Scenario / Capture / Commit /
     Date / Profile)
   - 20-metric table:
       Slice = "slice-1-3-encoder-rate-motion"
       Baseline = slice-1-2-real-frame-payloads values for the 18 metrics +
                  "—" for encoder-rate-x and encoder-rate-y (the SLICE-1.2
                  row predates these metrics)
       After = the captured values
       Delta = after − baseline (or after ÷ baseline for rates), "—" where
              Baseline is "—"
   - "### Notes on slice-1-3-encoder-rate-motion" subsection with four points:
       (a) Why slice-1-2-real-frame-payloads is the baseline reference
           (most recent FlaUI-captured row) and what the deltas isolate.
       (b) The new encoder-rate-x and encoder-rate-y metrics — establishing
           the receiver-side cadence baseline that future encoder-stream
           refactors (Phase 2 data-plane lift-out) will delta against.
       (c) The Win32 timeBeginPeriod(1) implication: this capture (and any
           future EncoderRate capture) raises system timer resolution while
           the app is running. Note any observed cross-app effects (none
           expected; document the absence).
       (d) Whether AppState change rate stayed within the criterion-8 bound.
           If anything else surprised — gen-2 GC delta from the 1 kHz path,
           working-set change, runs.completed throughput, etc — note it here.

4. Add §4.4 to docs/runbook/capturing-measurements.md:
   - title: "### 4.4 Encoder-rate soak — slice-1.3, `EncoderRate` profile"
   - placement: between §4.3 (high-frame-rate soak) and "§4.5+ — pending
     Phase 1 scenarios" (which becomes the storm/soak placeholder for
     SLICE-1.4)
   - content covers:
       * one-paragraph rationale (links back to SLICE-1.3 spec)
       * 10-minute step list mirroring §4.3 but with profile = EncoderRate
       * sanity checks: tags.active == 50, frames.dropped == 0,
         encoder-rate-x and encoder-rate-y both in [980, 1020] Hz,
         AppState change rate ≤ 25 Hz
       * the row block is 20-metric, not 18 — name encoder-rate-x and
         encoder-rate-y and where they come from (axis dimension on
         encoder.samples.ingested)
       * Win32 timer-resolution note: this scenario raises system timer
         resolution to 1 ms while the app runs. If the host machine is
         shared with other latency-sensitive workloads, prefer to capture
         on a dedicated session.
       * "Implemented by: MultiTagSoakFlaUi with --profile EncoderRate"
         (no new IScenario or new FlaUI test class)

5. Update CLAUDE.md "Current position" block:
   - Phase: 1 (Simulator to scale) — SLICE-1.3 complete
   - Last completed action: TASK-1.3 Pass 3 — captured 10-min EncoderRate
     soak, encoder-rate-x = <X> Hz, encoder-rate-y = <Y> Hz, 20-metric row
     block appended, runbook §4.4 added; commit <hash>
   - Next action: SLICE-1.4 (Storm & soak profiles); spec needs to be written
   - Blocked on: nothing
   - Last updated: <today's date>

6. Append a session-log entry to docs/reviews/roadmap-progress.md under
   today's date naming the CSV path, encoder-rate-x value, encoder-rate-y
   value, AppState change-rate observed value, runs.completed count, and
   the commit hash. Mark SLICE-1.3 as Completed in the progress table.

7. Restore powercfg settings after capture finishes:
       powercfg /change standby-timeout-ac <previous_value_in_minutes>
       powercfg /change monitor-timeout-ac <previous_value_in_minutes>

## Constraints

- Do NOT make any code or test changes in this pass.
- Do NOT modify the SLICE-1.3 spec — the row block is the slice's exit gate
  evidence, not an opportunity to amend the spec.
- Do NOT skip the 10-minute capture. A 60-second smoke run does not satisfy
  criterion 7's ±2% bar — that needs the longer averaging window.
- Do NOT capture without disabling system sleep first (deliverable 1).
- Do NOT capture with another high-CPU workload running on the host. The
  WinMmTimePeriod boost lowers system-wide timer slack; competing workloads
  can starve the producer's tick loop and depress the receiver rate below
  the ±2% bar.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release

Plus:
  - the docs/captures/slice-1-3-encoder-rate-<date>.csv file exists and is committed
  - the row block is in docs/reviews/phase-1-measurements.md with all 20
    metrics filled, encoder-rate-x and encoder-rate-y both in [980, 1020] Hz
  - §4.4 renders correctly (no broken markdown tables or links)
  - CLAUDE.md current-position block reflects SLICE-1.3 closure

## Report format when finished

- files created and modified
- the captured row block (the 20-metric markdown table) included in the report
- encoder-rate-x and encoder-rate-y values, with one-sentence interpretation
  (e.g. "Receiver-side rate held at X Hz on the X axis and Y Hz on the Y axis
  across the 10-min run, both within the criterion-7 ±2% bar around 1000 Hz.")
- a single commit hash
- commit message: "feat(measurements): close SLICE-1.3 with encoder-rate row block; runbook §4.4 (pass 3/3 of TASK-1.3)"

Operator notes

  • One pass per Copilot session. Same protocol as TASK-1.2 / TASK-1.5.
  • Pass 1's WinMmTimePeriod is non-optional for criterion 7. A naive PeriodicTimer(1ms) without timeBeginPeriod(1) ticks at the default Windows ~15.6 ms cadence — receiver rate would land near 64 Hz, not 1000 Hz. Reviewers must confirm the P/Invoke is actually wired and acquired in StartAsync. The AcquireOrFallback warning-then-no-op fallback exists so the app runs on a non-Windows host, not so the criterion can be silently satisfied.
  • Pass 2's load-bearing test is "no IAppStateStore.Update call". This is the slice's whole architectural point. If a future maintainer "helpfully" routes the encoder samples through AppState, the EncoderRate capture's receiver rate will collapse (channel coalesce dominates) and SLICE-2.3 loses its evidence base. The recording-store test exists to prevent this drift.
  • Pass 3 must run with system sleep disabled. Without that discipline, the receiver rate is diluted by any sleep duration, and ±2% becomes mathematically impossible to demonstrate.
  • Naming overlap is intentional but easy to miss. The seed Simulator:Tags includes encoder.x.counts and encoder.y.counts from SLICE-1.1 — those are unrelated to the new IEncoderStream. Reviewers and future maintainers must not conflate the two streams; SLICE-1.3's "Naming overlap" section exists to prevent that drift.
  • The EncoderRate profile is a measurement profile, not a production profile. Existing Normal, Demo, MultiTag, HighFrameRate profiles ship EncoderIntervalMs: 5 so prior rows (0/0a/0b/slice-1-1-multi-tag-telemetry/slice-1-2-real-frame-payloads) remain reproducible.
  • Update the index files only at the end of the phase, not per-slice. Same rationale as earlier tasks.

Docs-first project memory for AI-assisted implementation.