TASK-1.3: Implement Encoder-Rate Motion
- Status: Proposed (no passes started)
- Date: 2026-04-30
- Spec: SLICE-1.3: Encoder-Rate Motion
- Depends on: TASK-1.1: Implement Multi-Tag Telemetry, TASK-1.2: Implement Real Frame Payloads, TASK-1.6: FlaUI Capture
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+SimulatedEncoderSourceproducer - new pipeline service
EncoderStreamPipelineService(drains; metric only; noAppStatewrite) - new infra config:
Simulator:Encoderblock,SimulatorProfile.EncoderIntervalMs, validators WinMmTimePeriodP/Invoke wrapper to enable real 1 ms tick on Windows- new seed profile
EncoderRateinappsettings.json; existing profiles updated withEncoderIntervalMs: 5 - new
AppMetricscounters:encoder.samples.ingested(axis dim),encoder.samples.coalesced(axis dim) - new measurement helper
Get-EncoderRatePerAxisintools/MeasurementExtraction.psm1;ConvertTo-MeasurementRowemits a 20-metric block when encoder rows are present - new runbook §4.4 (encoder-rate soak)
- 10-minute
EncoderRatecapture under the existingMultiTagSoakFlaUiscenario, row block indocs/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
IMotionControllerinterface orSimulatedMotionController.InterpolateAsyncbehavior - new FlaUI scenario class —
MultiTagSoakFlaUiwith-Profile EncoderRateis the capture path - additional axes (Z, theta, focus) — only X and Y in this slice
- adapting
Normal/MultiTag/HighFrameRateprofiles' encoder rate beyond the seedEncoderIntervalMs: 5default - altering the existing
encoder.x.counts/encoder.y.countstags 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.Application—EncoderAxis,EncoderSample,EncoderSnapshot,IEncoderStream,EncoderStreamPipelineService,AppMetricscounterssrc/InspectionPrototype.Infrastructure—SimulatedEncoderSource,Interop/WinMmTimePeriod,SimulatorProfile.EncoderIntervalMs,SimulatorEncoderOptions,SimulatorEncoderOptionsValidator,SimulatorProfilesValidatorextension, DI wiring inInfrastructureServiceCollectionExtensionssrc/InspectionPrototype.App—appsettings.json(Simulator:Encoderblock +EncoderIntervalMsper profile + newEncoderRateprofile)tests/InspectionPrototype.Tests—EncoderSampleTests,SimulatedEncoderSourceTests,EncoderStreamPipelineServiceTests,SimulatorEncoderOptionsValidatorTests,SimulatorProfilesValidatorEncoderTests,WinMmTimePeriodTests,FakeEncoderStreamstubtools/MeasurementExtraction.psm1—Get-EncoderRatePerAxishelper,ConvertTo-MeasurementRow20-metric outputtests/Tools/MeasurementExtraction.Tests.ps1— Pester tests for the new helperdocs/runbook/capturing-measurements.md— new §4.4docs/reviews/phase-1-measurements.md— new row blockdocs/captures/— the new CSV evidence- (no changes to)
src/InspectionPrototype.Application/Abstractions/IMotionController.cs - (no changes to)
src/InspectionPrototype.Infrastructure/Simulator/SimulatedMotionController.cscore behavior - (no changes to)
src/InspectionPrototype.Presentation/ViewModels/MainViewModel.csmotion subscription
AI Tool Guidance
Three Copilot passes; one-pass-per-session protocol as in TASK-1.2.
- Domain + producer + Win32 timer + validators —
EncoderAxis/EncoderSample/EncoderSnapshot,IEncoderStream,SimulatedEncoderSource,WinMmTimePeriodP/Invoke,Simulator:Encoderconfig + validators,SimulatorProfile.EncoderIntervalMs+ validator extension, seedappsettings.jsonwithEncoderRateprofile andEncoderIntervalMs: 5on existing profiles. Tests for the simulator path. NO pipeline service, NO metrics, NO measurement-extraction work. - Pipeline service + metrics + measurement extraction —
EncoderStreamPipelineServicedrain loop,AppMetrics.EncoderSamplesIngested/EncoderSamplesCoalesced,Get-EncoderRatePerAxisPester-tested helper,ConvertTo-MeasurementRow20-metric output. NO captures, NO new FlaUI tests. - Capture + row block + runbook §4.4 — Run the 10-min
EncoderRatescenario via existingMultiTagSoakFlaUi, 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)withouttimeBeginPeriod(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 inStartAsync. TheAcquireOrFallbackwarning-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.Updatecall". This is the slice's whole architectural point. If a future maintainer "helpfully" routes the encoder samples throughAppState, 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:Tagsincludesencoder.x.countsandencoder.y.countsfrom SLICE-1.1 — those are unrelated to the newIEncoderStream. 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,HighFrameRateprofiles shipEncoderIntervalMs: 5so 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.