TASK-1.1: Implement Multi-Tag Telemetry
- Status: Proposed
- Date: 2026-04-24
- Spec: SLICE-1.1: Multi-Tag Telemetry
- Depends on: TASK-006: Implement Observability Baseline
Objective
Replace the two-field MachineTelemetry snapshot and its single-rate producer with a keyed tag stream: TagSample + TagDefinition registry, a per-tag-cadence simulator producer, a new pipeline that updates an immutable LatestTagValues map on AppState, and per-tag metrics dimensions that make per-tag emit rate and coalesce count observable.
Scope
- delete
MachineTelemetry,ITelemetrySource,SimulatedTelemetrySource,FakeTelemetrySource - introduce
TagSample,TagQuality,TagDefinition, the fourNoiseModelvariants, and anITagStreamabstraction over a bounded snapshot channel - introduce
SimulatedTagSource(per-tag emitter loops + a snapshot publisher) underInfrastructure.Simulator - rename
TelemetryPipelineServicetoTagStreamPipelineServiceand update it to consumeTagSnapshotand writeAppState.LatestTagValues - swap
AppState.LatestTelemetryforAppState.LatestTagValues; updateMainViewModelto read named tags - add
Simulator:Tagsconfiguration binding and seed 50 tags inappsettings.json - add a new
MultiTagsimulator profile to the seed configuration - extend
AppMetricswithsamples.ingestedandsamples.coalescedcounters (each with atag.namedimension) and atags.activeobservable gauge - add measurement scenario §4.2 to
docs/runbook/capturing-measurements.mdand aslice-1-1-multi-tag-telemetryrow block todocs/reviews/phase-1-measurements.md - add tests for: each
NoiseModel, configuration validation (four reject cases),TagStreamPipelineServiceconsumption fromFakeTagStream, and the UI adapter's tag-name lookup
Non-Scope
- moving telemetry data out of
AppState(Phase 2 / SLICE 2.3) - per-slice observables on
IAppStateStore(Phase 2 / SLICE 2.4) - real frame payloads (SLICE-1.2)
- encoder-rate motion stream (SLICE-1.3)
- chaos / soak profiles beyond the new
MultiTag(SLICE-1.4) - charting or trending UI for tag history
- persisting
TagSamplehistory to disk - changing the
frames.*,runs.*, orDiagnosticsEntriessurfaces
Touched Projects
src/InspectionPrototype.Application— domain types, abstractions,AppState, pipeline service,AppMetricssrc/InspectionPrototype.Infrastructure—SimulatedTagSource, configuration options, profile hydrationsrc/InspectionPrototype.App—appsettings.jsonseed, host wiringsrc/InspectionPrototype.Presentation—MainViewModelUI adaptertests/InspectionPrototype.Tests— new test files, replaceFakeTelemetrySourcewithFakeTagStreamdocs/runbook/capturing-measurements.md— §4.2docs/reviews/phase-1-measurements.md— new row blockdocs/captures/— new CSV evidence file
AI Tool Guidance
This task is significantly larger than TASK-006 because it spans domain, infrastructure, configuration, and metrics in one shape change. Split it into three Copilot passes; do not paste all three prompts into a single session.
- Domain shapes and configuration — introduce
TagSample,TagQuality,TagDefinition, theNoiseModelvariants,SimulatorTagsOptions, the seedappsettings.json(50 tags +MultiTagprofile), and configuration validation. Keep the existing telemetry pipeline working; do not deleteMachineTelemetryyet. Add tests for noise models and validation. - Producer, pipeline, and
AppStateswap — introduceITagStream/SimulatedTagSource/TagStreamPipelineService, swapAppState.LatestTelemetryforLatestTagValues, rewireMainViewModel, delete the old types and their tests, register everything in DI. Existing test suite stays green. - Metrics, runbook, and measurement capture — add the per-tag counter dimensions and the gauge to
AppMetrics, wire them into the producer/pipeline, write the §4.2 scenario, run a 30-minute capture underMultiTag, append the row block to the measurements table, commit the CSV.
Each pass ends with its own commit. Run dotnet test and confirm acceptance criteria for that pass before kicking off the next session.
Acceptance Criteria Mapping
The implementation must satisfy all acceptance criteria from SLICE-1.1:
- Pass 1 covers criteria 4, 5, and parts of 10 (noise + validation tests)
- Pass 2 covers criteria 1, 2, 3, and the remaining test cases of 10
- Pass 3 covers criteria 6, 7, 8, 9
Copilot Agent Prompts
Pass 1 — Domain shapes and configuration
You are implementing Pass 1 of TASK-1.1 in this repository: introduce the new tag
domain shapes, the noise models, the configuration binding, and a seed of 50
tags — without yet deleting the existing MachineTelemetry / ITelemetrySource path.
Two pipelines coexist briefly so the test suite stays green between passes.
## Authoritative references
Read these before making changes:
- docs/specs/SLICE-1.1-multi-tag-telemetry.md (the requirements)
- docs/tasks/TASK-1.1-implement-multi-tag-telemetry.md (this task)
- src/InspectionPrototype.Application/State/MachineTelemetry.cs
- src/InspectionPrototype.Application/State/SimulatorProfile.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesOptions.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfileHydrationService.cs
- src/InspectionPrototype.App/appsettings.json (the configuration source of truth)
Spec acceptance criteria 4, 5, and the noise/validation parts of 10 are the
definition of done for this pass.
## Scope of this pass
Domain types, configuration binding, seed data, validation. NO changes to
ITelemetrySource, SimulatedTelemetrySource, TelemetryPipelineService, AppState,
or MainViewModel — those move in Pass 2.
## Deliverables
1. New domain types under src/InspectionPrototype.Application/State/:
- TagQuality.cs (enum: Good, Uncertain, Bad)
- TagSample.cs (record: string Name, DateTimeOffset Timestamp, double Value, TagQuality Quality)
- TagDefinition.cs (record: string Name, string Unit, double IntervalMs, NoiseModel Noise)
- NoiseModel.cs (abstract record with sealed variants):
SineNoise(double Baseline, double Amplitude, double PeriodMs)
DriftNoise(double Baseline, double SlopePerSecond, double JitterStdDev)
RandomWalkNoise(double Baseline, double StepStdDev, double ClampMin, double ClampMax)
StepNoise(double Low, double High, double PeriodMs, double DutyCycle)
2. A pure-function evaluator alongside NoiseModel:
- file: src/InspectionPrototype.Application/State/NoiseModelEvaluator.cs
- public static double Evaluate(NoiseModel model, DateTimeOffset at, ref double state, Random rng)
- state is per-tag (caller owns it). Sine/Step are stateless; Drift/RandomWalk
accumulate state across calls.
3. Configuration types under src/InspectionPrototype.Infrastructure/Simulator/:
- SimulatorTagOptions.cs:
string Name, string Unit, double IntervalMs, NoiseOptions Noise
- NoiseOptions.cs:
string Kind ("Sine" | "Drift" | "RandomWalk" | "Step")
double? Baseline, Amplitude, PeriodMs, SlopePerSecond, JitterStdDev,
StepStdDev, ClampMin, ClampMax, Low, High, DutyCycle
(a flat bag — the validator below maps to the right NoiseModel variant)
- SimulatorTagsOptions.cs:
const string SectionName = "Simulator"; // bound at the existing Simulator section
List<SimulatorTagOptions> Tags { get; set; } = [];
If Simulator:Profiles is currently bound from the same section, extend the
existing SimulatorProfilesOptions (or its host section binding) so both
Profiles and Tags load from one block. Do NOT split into two top-level
sections.
4. A startup validator:
- file: src/InspectionPrototype.Infrastructure/Simulator/SimulatorTagsValidator.cs
- implement IValidateOptions<SimulatorTagsOptions>
- reject:
* any tag with IntervalMs < 2 or > 1000
* any duplicate Name
* any empty/whitespace Name
* any unrecognized Noise.Kind
* any noise variant missing required parameters (e.g. Sine without Amplitude)
Validation messages name the offending tag's Name (or its index when Name is empty).
Register via services.AddOptions<SimulatorTagsOptions>().Bind(...).Validate(...)
in InfrastructureServiceCollectionExtensions, with ValidateOnStart() so the
app fails to launch on bad config.
5. Update src/InspectionPrototype.App/appsettings.json:
- add a Tags array under the Simulator section with EXACTLY 50 entries.
Use realistic dotted names. At least one tag per noise variant. Mix of
intervals: include several at each of 1000, 200, 100, 50, 20, 10, 4, 2 ms.
Reserved names that MUST be present: "temperature.celsius" and "pressure.bar"
(these keep the existing UI binding working in Pass 2).
- add a new SimulatorProfile entry "MultiTag" with:
MotionSpeedUnitsPerSecond: 20.0
TelemetryIntervalMs: 50 (snapshot publish at 20 Hz)
FrameIntervalMs: 500
DefectProbabilityPerFrame: 0.05
ConnectionFailureProbability: 0.20
6. Tests under tests/InspectionPrototype.Tests/:
- NoiseModelEvaluatorTests.cs:
* Sine: Evaluate over 1 000 ticks stays within [Baseline - Amplitude, Baseline + Amplitude]
* Drift: monotonic when JitterStdDev = 0; bounded by SlopePerSecond * elapsed
* RandomWalk: stays within [ClampMin, ClampMax] across 1 000 ticks
* Step: produces exactly two distinct values; duty cycle ratio within ±5%
- SimulatorTagsValidatorTests.cs: each of the four reject cases produces a
ValidateOptionsResult.Fail with a message that includes the offending name
or index.
## Constraints
- Do NOT delete MachineTelemetry, ITelemetrySource, SimulatedTelemetrySource,
FakeTelemetrySource, TelemetryPipelineService, or AppState.LatestTelemetry
in this pass. Pass 2 deletes them as one atomic change with the rewire.
- Do NOT add any new metrics here. Pass 3 owns metrics.
- Do NOT touch MainViewModel or any XAML.
- Do NOT use System.Text.Json polymorphic source-gen attributes for NoiseOptions;
the flat-bag + validator approach above keeps configuration binding simple.
## Verification before you report done
dotnet build --configuration Release
dotnet test --configuration Release
Confirm appsettings.json validates by running the app once: it should start
normally with both the legacy telemetry source AND the new tag configuration
bound but unused.
## Report format when finished
- files created and files modified
- confirmation that all existing tests still pass plus new noise/validator tests
- a single commit hash
- commit message: "feat(sim): add tag domain shapes, noise models, and 50-tag seed config (pass 1/3 of TASK-1.1)"Pass 2 — Producer, pipeline, and AppState swap
You are implementing Pass 2 of TASK-1.1. Pass 1 (tag shapes + 50-tag seed +
validator) is already merged; this pass introduces the producer and pipeline,
swaps AppState, rewires the UI adapter, and DELETES the old MachineTelemetry
path in one atomic change.
## Authoritative references
Read these before making changes:
- docs/specs/SLICE-1.1-multi-tag-telemetry.md (criteria 1, 2, 3, 10)
- src/InspectionPrototype.Application/State/AppState.cs
- src/InspectionPrototype.Application/Abstractions/ITelemetrySource.cs
- src/InspectionPrototype.Application/Services/TelemetryPipelineService.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedTelemetrySource.cs
- src/InspectionPrototype.Application/ApplicationServiceCollectionExtensions.cs
- src/InspectionPrototype.Infrastructure/InfrastructureServiceCollectionExtensions.cs
- src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs (the readout)
- tests/InspectionPrototype.Tests/Stubs/FakeTelemetrySource.cs
Confirm Pass 1's tag shapes and configuration validator are in place before
starting. If not, stop and alert the operator.
## Scope of this pass
Producer, pipeline, AppState swap, UI adapter, DI rewire, and deletion of the
old path. NO metrics work — Pass 3 owns that.
## Deliverables
1. New abstraction:
- src/InspectionPrototype.Application/Abstractions/ITagStream.cs:
ChannelReader<TagSnapshot> Reader { get; }
long SnapshotCoalescedCount { get; } // aggregate snapshot drops at the channel
long PerTagCoalescedCount(string tagName); // sum of overwrites for one tag's latest cell
IReadOnlyCollection<TagDefinition> Definitions { get; }
- TagSnapshot record under State/:
TagSnapshot(DateTimeOffset CapturedAt, ImmutableDictionary<string, TagSample> Values)
2. New producer:
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedTagSource.cs
- constructor: ISimulatorProfileProvider, IOptionsMonitor<SimulatorTagsOptions>,
ILogger<SimulatedTagSource>
- one Task per TagDefinition runs an emitter loop on its IntervalMs, writing
into a per-tag latest-value cell (a ConcurrentDictionary<string, TagSample>
plus per-tag coalesce counters)
- one snapshot publisher Task ticks at the active profile's TelemetryIntervalMs
and writes a frozen TagSnapshot to a Channel.CreateBounded<TagSnapshot>
with capacity 1, FullMode = DropOldest, single writer / single reader
- Definitions is built once at construction from IOptionsMonitor<SimulatorTagsOptions>.CurrentValue
- Disposable; cancels the per-tag tasks and the publisher cleanly
3. New pipeline service:
- rename src/InspectionPrototype.Application/Services/TelemetryPipelineService.cs
to TagStreamPipelineService.cs (delete the old file)
- constructor: ITagStream, IAppStateStore, AppMetrics, ILogger<TagStreamPipelineService>
- ExecuteAsync drains snapshots and updates AppState.LatestTagValues with
snapshot.Values; on snapshot coalesce (delta of SnapshotCoalescedCount > 0)
write to PipelineCounters.TelemetryCoalesced and a Warning DiagnosticsEntry
(preserve existing semantics from TelemetryPipelineService)
- AppMetrics.TelemetryIngested.Add(1) on each snapshot consumed (Pass 3 will
extend with per-tag counters; in this pass keep the existing counter wiring
unchanged)
4. AppState swap:
- delete LatestTelemetry from AppState; add
ImmutableDictionary<string, TagSample> LatestTagValues
with Initial = ImmutableDictionary<string, TagSample>.Empty
- delete src/InspectionPrototype.Application/State/MachineTelemetry.cs
5. UI adapter:
- in MainViewModel, replace the existing
if (state.LatestTelemetry is { } t) ...
block with a lookup against state.LatestTagValues:
var temp = state.LatestTagValues.GetValueOrDefault("temperature.celsius");
var pressure = state.LatestTagValues.GetValueOrDefault("pressure.bar");
TelemetryText = (temp is null && pressure is null)
? "–"
: $"Temp: {(temp?.Value):F1}°C Pressure: {(pressure?.Value):F3} bar";
Match the existing fallback string exactly so the UI surface is unchanged
for the empty case.
6. DI rewire:
- InfrastructureServiceCollectionExtensions: register SimulatedTagSource as
a singleton implementing ITagStream; remove SimulatedTelemetrySource and
ITelemetrySource registrations.
- ApplicationServiceCollectionExtensions: replace
AddHostedService<TelemetryPipelineService>()
with
AddHostedService<TagStreamPipelineService>()
7. Delete the old types in the same commit:
- src/InspectionPrototype.Application/Abstractions/ITelemetrySource.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedTelemetrySource.cs
- tests/InspectionPrototype.Tests/Stubs/FakeTelemetrySource.cs
Replace FakeTelemetrySource with a new
tests/InspectionPrototype.Tests/Stubs/FakeTagStream.cs that exposes a writable
channel + helpers for tests to publish a TagSnapshot.
8. Update existing tests that constructed MachineTelemetry, ITelemetrySource, or
FakeTelemetrySource to use TagSample / FakeTagStream / TagSnapshot instead.
Where a test was asserting on AppState.LatestTelemetry, switch to
AppState.LatestTagValues lookup.
9. New tests under tests/InspectionPrototype.Tests/:
- TagStreamPipelineServiceTests.cs: pushing a snapshot via FakeTagStream causes
LatestTagValues to update with the same content; an aggregate snapshot
coalesce increments PipelineCounters.TelemetryCoalesced and appends a
Warning DiagnosticsEntry.
- MainViewModelTelemetryReadoutTests.cs (or extend the existing MainViewModel
test file if one exists): the readout renders "–" when the map is empty,
and the formatted string when both reserved names are present.
## Constraints
- Do NOT keep a back-compat shim that re-exposes a MachineTelemetry-shaped
property on AppState. The whole point is to replace the shape.
- Do NOT change PipelineCounters or the existing telemetry.coalesced /
telemetry.ingested counter wiring.
- Do NOT change the bounded snapshot channel's capacity or DropOldest policy.
- Do NOT introduce per-tag metrics here — that lands in Pass 3.
## Verification before you report done
dotnet build --configuration Release
dotnet test --configuration Release
Manual smoke test:
- launch the app
- confirm the existing temperature/pressure readout still ticks every ~50 ms
once snapshots start flowing (TelemetryIntervalMs comes from the active
profile)
- confirm switching from "Normal" to "MultiTag" via the existing profile
selector does not throw (the producer rebuilds emitters on profile change
via IOptionsMonitor; if the existing profile-switching code path does not
rebuild the producer, document this as a known gap — it is OK for this
slice to require a restart on profile change as long as no exception fires)
## Report format when finished
- files created, modified, and DELETED
- confirmation that all tests pass (count before vs after)
- a single commit hash
- commit message: "refactor(sim): replace MachineTelemetry with multi-tag stream and per-tag emitters (pass 2/3 of TASK-1.1)"Pass 3 — Metrics, runbook, measurement capture
You are implementing Pass 3 of TASK-1.1, the final pass. Passes 1 and 2 are
already merged. This pass adds per-tag metrics dimensions, the §4.2 scenario in
the runbook, captures the slice-1.1 row in the measurements table, and commits
the CSV evidence.
## Authoritative references
Read these before making changes:
- docs/specs/SLICE-1.1-multi-tag-telemetry.md (criteria 6, 7, 8, 9)
- docs/runbook/capturing-measurements.md (existing §4.1 to mirror for §4.2)
- docs/reviews/phase-1-measurements.md (row 0 to mirror)
- src/InspectionPrototype.Application/Diagnostics/AppMetrics.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatedTagSource.cs
- src/InspectionPrototype.Application/Services/TagStreamPipelineService.cs
## Scope of this pass
Metrics extension, runbook scenario, capture run, table row. NO domain or
producer reshape — Passes 1 and 2 own those.
## Deliverables
1. Extend AppMetrics:
- public Counter<long> SamplesIngested { get; } = _meter.CreateCounter<long>("samples.ingested");
- public Counter<long> SamplesCoalesced { get; } = _meter.CreateCounter<long>("samples.coalesced");
- public ObservableGauge<long> TagsActive — created via _meter.CreateObservableGauge<long>(
"tags.active", () => _tagsActiveProvider?.Invoke() ?? 0);
Expose RegisterTagsActiveProvider(Func<long> provider) so SimulatedTagSource
can publish the current count without AppMetrics taking a hard dependency
on the producer.
2. Wire the new counters:
- In SimulatedTagSource: each per-tag emitter increments
_metrics.SamplesIngested.Add(1, new KeyValuePair<string, object?>("tag.name", definition.Name));
and on a per-tag latest-cell overwrite increments
_metrics.SamplesCoalesced.Add(1, new KeyValuePair<string, object?>("tag.name", definition.Name));
- In SimulatedTagSource constructor: call _metrics.RegisterTagsActiveProvider(() => Definitions.Count);
3. Tests under tests/InspectionPrototype.Tests/:
- AppMetricsTagDimensionTests.cs:
* SamplesIngested and SamplesCoalesced exist on the Meter and are reachable
by name via the System.Diagnostics.Metrics API
* Calling Add with a tag.name dimension produces a measurement carrying
that dimension (use a MeterListener to verify)
* tags.active gauge polls the registered provider
4. Runbook §4.2 in docs/runbook/capturing-measurements.md:
- title: "§4.2 Multi-tag soak (30 min, MultiTag profile)"
- prerequisites: build at the slice-1.1 commit, dotnet-counters CLI installed,
50-tag seed present in appsettings.json
- steps:
1. launch the app, switch active profile to "MultiTag", connect, start a
long-running scripted scenario (reuse the §4.1 connect/start loop, just
longer — 30 minutes)
2. start dotnet-counters collect:
dotnet-counters collect --name InspectionPrototype.App
--counters InspectionPrototype,System.Runtime
--refresh-interval 1
--format csv
--output docs/captures/slice-1-1-multi-tag-<YYYY-MM-DD>.csv
3. let it run for 30 minutes plus ~30 s warm-up / 60 s cool-down
- post-processing: include a small PowerShell snippet (mirror the §4.1 style)
that:
* reads the CSV
* groups samples.ingested rows by Tags column (parses tag.name=)
* computes per-tag rate = total / scenario_seconds
* computes per-tag rate error vs configured IntervalMs
* fails loudly if any per-tag error exceeds ±2%
- the post-processing output is what feeds criterion 7
5. Capture and commit evidence:
- run the §4.2 scenario yourself once Pass 2 is merged
- save the CSV to docs/captures/slice-1-1-multi-tag-<date>.csv
- append a row block (16 metrics) to docs/reviews/phase-1-measurements.md
under a new "## Phase 1 rows" subsection, tagged
`slice-1-1-multi-tag-telemetry`, with Baseline column copied from row 0,
After column from this capture, and Delta filled per the table's
conventions
- record the commit hash under measurement (the Pass 2 / pre-Pass 3 commit)
in the new "## Row 1.1" header block, mirroring row 0's header
6. Update the CLAUDE.md "Current position" block AND append a session-log entry
to docs/reviews/roadmap-progress.md so the next session knows Pass 3 closed
the slice. The progress-table row for SLICE-1.1 moves from Proposed to
Completed.
## Constraints
- Do NOT add counters beyond the three named above. Future slices extend.
- Do NOT change the existing telemetry.ingested / telemetry.coalesced
semantics — they remain snapshot-level totals.
- Do NOT skip the 30-minute capture. Without committed CSV evidence, the
slice's exit gate is not met regardless of code correctness.
## Verification before you report done
dotnet build --configuration Release
dotnet test --configuration Release
Plus the §4.2 capture run, with:
- the CSV committed under docs/captures/
- the row block committed under docs/reviews/phase-1-measurements.md
- dotnet-counters output (or a CSV excerpt) confirming ≥ 50 distinct
tag.name values appear in samples.ingested rows
## Report format when finished
- files created and modified
- confirmation that all tests pass plus new metrics tests
- the docs/captures/ CSV path
- the per-tag rate-error post-processing output (max error vs configured rate)
- a single commit hash
- commit message: "feat(obs): add per-tag metrics dimensions, capture slice-1.1 measurements (pass 3/3 of TASK-1.1)"Operator notes
- One pass per Copilot session. Same protocol as TASK-006. Do not feed all three prompts into a single agent.
- Pass 2 is the riskiest. It deletes types and rewires DI in one commit. If the agent leaves dangling references or breaks the test suite, bail out and finish manually rather than letting it iterate.
- Pass 3's capture is the slice's exit gate. The CSV must be committed for the slice to count as Completed. A clean Pass 2 with no Pass 3 capture leaves SLICE-1.1 stuck at "code merged, exit gate not met" — which is exactly the failure mode this whole roadmap exists to prevent.
- Update the index files only at the end of the phase, not per-slice.
docs/specs/index.mdanddocs/tasks/index.mdare stale (they don't list 005/006 either); deferring their refresh until Phase 1 closes keeps churn down.