Skip to content

TASK-1.1: Implement Multi-Tag Telemetry

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 four NoiseModel variants, and an ITagStream abstraction over a bounded snapshot channel
  • introduce SimulatedTagSource (per-tag emitter loops + a snapshot publisher) under Infrastructure.Simulator
  • rename TelemetryPipelineService to TagStreamPipelineService and update it to consume TagSnapshot and write AppState.LatestTagValues
  • swap AppState.LatestTelemetry for AppState.LatestTagValues; update MainViewModel to read named tags
  • add Simulator:Tags configuration binding and seed 50 tags in appsettings.json
  • add a new MultiTag simulator profile to the seed configuration
  • extend AppMetrics with samples.ingested and samples.coalesced counters (each with a tag.name dimension) and a tags.active observable gauge
  • add measurement scenario §4.2 to docs/runbook/capturing-measurements.md and a slice-1-1-multi-tag-telemetry row block to docs/reviews/phase-1-measurements.md
  • add tests for: each NoiseModel, configuration validation (four reject cases), TagStreamPipelineService consumption from FakeTagStream, 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 TagSample history to disk
  • changing the frames.*, runs.*, or DiagnosticsEntries surfaces

Touched Projects

  • src/InspectionPrototype.Application — domain types, abstractions, AppState, pipeline service, AppMetrics
  • src/InspectionPrototype.InfrastructureSimulatedTagSource, configuration options, profile hydration
  • src/InspectionPrototype.Appappsettings.json seed, host wiring
  • src/InspectionPrototype.PresentationMainViewModel UI adapter
  • tests/InspectionPrototype.Tests — new test files, replace FakeTelemetrySource with FakeTagStream
  • docs/runbook/capturing-measurements.md — §4.2
  • docs/reviews/phase-1-measurements.md — new row block
  • docs/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.

  1. Domain shapes and configuration — introduce TagSample, TagQuality, TagDefinition, the NoiseModel variants, SimulatorTagsOptions, the seed appsettings.json (50 tags + MultiTag profile), and configuration validation. Keep the existing telemetry pipeline working; do not delete MachineTelemetry yet. Add tests for noise models and validation.
  2. Producer, pipeline, and AppState swap — introduce ITagStream / SimulatedTagSource / TagStreamPipelineService, swap AppState.LatestTelemetry for LatestTagValues, rewire MainViewModel, delete the old types and their tests, register everything in DI. Existing test suite stays green.
  3. 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 under MultiTag, 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.md and docs/tasks/index.md are stale (they don't list 005/006 either); deferring their refresh until Phase 1 closes keeps churn down.

Docs-first project memory for AI-assisted implementation.