Skip to content

TASK-3.1: Implement Rich Defect Model

Objective

Replace the thin InspectionResult shape with a rich Defect record (DefectId, RunId, FrameId, BoundingBox, Classification, Confidence, Severity, WaferX/Y, ImageRef). Add an M002 migration extending the SQLite schema with a defects table. Implement IDefectStore + SqliteDefectStore (Dapper) following SLICE-3.3 patterns. Upgrade FramePipelineService.ProcessDefectsForFrame to produce + persist Defect records via the fire-and-forget pattern. Add a WaferMapView WPF UserControl rendering markers at wafer coordinates, color-coded by severity. Capture under HighDefect profile, produce ≥ 5 000 defects, verify pagination, assess Phase 2 trigger.

Scope

  • New domain shapes (Defect, BoundingBox, DefectClassification enum) in InspectionPrototype.Domain.Contracts
  • InspectionResult.cs deleted
  • M002 migration creating defects table + 2 indexes + schema_version row
  • IDefectStore (Application.Abstractions) + SqliteDefectStore (Infrastructure.Data, Dapper)
  • FramePipelineService.ProcessDefectsForFrame upgraded to produce Defect and persist fire-and-forget
  • AppState.RecentDefects sliding-100 window; reset at WorkflowService.StartRunAsync
  • WaferMapView UserControl in Presentation/Controls/; DefectViewModel projection; MainWindow.xaml integration
  • AutomationIds (WaferMapView, DefectCountText) for FlaUI
  • New MeasurementExtraction.psm1 helpers: Get-DefectsPersistedCount, Get-DefectClassificationDistribution, Get-DefectPersistP95Ms
  • ConvertTo-MeasurementRow adds three new metric rows
  • Runbook §5.3 entry
  • 30-min HighDefect-profile capture; row block in phase-3-measurements.md; Phase 2 trigger assessment
  • Tests: M002 migration, SqliteDefectStore roundtrip + pagination, SaveManyAsync 5 000-row perf, FramePipelineService integration, MainViewModel diff projection, WaferToCanvas coordinate helper

Non-Scope

  • Per-defect drill-down UI (click marker → detail panel) — deferred
  • Defect-trend / time-series charts — deferred
  • Defect export (CSV/JSON/SECS-GEM) — deferred
  • ImageRef cropped-image storage — field exists, simulator leaves it null
  • ML-based real classification — Phase 4+ (excluded by roadmap §8)
  • Buffered defect flusher / coalesced batched persistence — ships per-defect fire-and-forget; revisit if measurement shows backlog
  • "View all defects for this run" UI — IDefectStore.LoadByRunAsync API exists; UI surface deferred
  • Wafer geometry sophistication (notch orientation, flat indication) — simple circle only
  • Cross-run defect correlation — schema doesn't enforce uniqueness beyond defect_id
  • Modifying DefectSeverity enum or RunSummary/ActiveRunState defect-count fields — unchanged

Touched Projects

  • src/InspectionPrototype.Domain — new Defect.cs, BoundingBox.cs, DefectClassification.cs; delete InspectionResult.cs
  • src/InspectionPrototype.Application — new Abstractions/IDefectStore.cs; State/AppState.cs (add RecentDefects); Services/FramePipelineService.cs (defect generation + persistence); Services/WorkflowService.cs (clear RecentDefects on StartRunAsync); add a Application.Defects.RecentDefectsWindow constant or similar
  • src/InspectionPrototype.Infrastructure — new Data/Migrations/M002_defects.sql (embedded resource); new Data/SqliteDefectStore.cs (Dapper); DI wiring in InfrastructureServiceCollectionExtensions
  • src/InspectionPrototype.Presentation — new Controls/WaferMapView.xaml + .cs UserControl; new ViewModels/DefectViewModel.cs; ViewModels/MainViewModel.cs (RecentDefects ObservableCollection + diff projection); helper WaferToCanvas
  • src/InspectionPrototype.AppMainWindow.xaml (host WaferMapView; AutomationIds)
  • tests/InspectionPrototype.TestsMigrationRunnerM002Tests, SqliteDefectStoreTests, SqliteDefectStorePerformanceTests, FramePipelineServiceDefectIntegrationTests, MainViewModelDefectProjectionTests, WaferToCanvasTests; Stubs/FakeDefectStore.cs
  • tests/InspectionPrototype.AcceptanceTests — possibly add a smoke FlaUI test that asserts WaferMapView is reachable (existing AutomationId regression test pattern)
  • tools/MeasurementExtraction.psm1 — three new helpers
  • tests/Tools/MeasurementExtraction.Tests.ps1 — Pester tests for the new helpers
  • docs/runbook/capturing-measurements.md — new §5.3
  • docs/reviews/phase-3-measurements.md — new row block
  • docs/captures/ — new CSV evidence
  • (no changes to) Directory.Packages.props (Dapper + SQLite already present), IRunHistoryStore, IAlarmHistoryStore, simulator profiles, FlaUI driver

AI Tool Guidance

Three Copilot passes; one-pass-per-session protocol consistent with TASK-1.4 / TASK-2.0 / TASK-3.3.

  1. Domain shapes + persistence + pipeline + AppState. New types, M002, IDefectStore, SqliteDefectStore, FramePipelineService upgrade, AppState.RecentDefects, WorkflowService.StartRunAsync reset, tests including the 5 000-row SaveManyAsync perf test. NO UI work, NO captures.
  2. Wafer-map UI. DefectViewModel, MainViewModel.RecentDefects diff projection, WaferMapView UserControl + XAML, MainWindow.xaml integration, AutomationIds, WaferToCanvas helper, tests for projection + coordinate math + a FlaUI smoke check. NO captures.
  3. 30-min HighDefect capture + row block + Phase 2 trigger assessment + runbook §5.3. Pre-populate scenario unchanged from §5.2 if needed; capture; verify ≥ 5 000 defects + pagination + classification distribution; append row block; assess Phase 2 rubric; write §5.3.

Acceptance Criteria Mapping

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

  • Pass 1 covers criteria 1, 2, 3, 4, 5, 6, 9 (the persistence portions), 11, 16, and the C#-test portions of 15
  • Pass 2 covers criteria 7, 8, 10, and the UI / projection portions of 15
  • Pass 3 covers criteria 9 (capture-driven verification), 12, 13, 14

Copilot Agent Prompts

Pass 1 — Domain shapes + persistence + pipeline + AppState

You are implementing Pass 1 of TASK-3.1 in this repository: introduce the
rich Defect domain shape, persist defects to a new SQLite table via M002,
upgrade FramePipelineService to produce + persist Defect records, and add
AppState.RecentDefects sliding window. The legacy InspectionResult record
is deleted as part of this pass.

NO UI work (Pass 2). NO captures (Pass 3).

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.1-rich-defect-model.md            (the requirements)
- docs/tasks/TASK-3.1-implement-rich-defect-model.md   (this task)
- docs/specs/SLICE-3.3-sqlite-persistence.md           (parallel patterns)
- src/InspectionPrototype.Domain/Contracts/InspectionResult.cs  (to be removed)
- src/InspectionPrototype.Domain/Contracts/DefectSeverity.cs    (kept; reused)
- src/InspectionPrototype.Domain/Contracts/Frame.cs             (frame.CaptureX / CaptureY drive WaferX/Y)
- src/InspectionPrototype.Application/Services/FramePipelineService.cs
- src/InspectionPrototype.Application/Services/WorkflowService.cs    (StartRunAsync state transition)
- src/InspectionPrototype.Application/State/AppState.cs
- src/InspectionPrototype.Infrastructure/Data/Migrations/MigrationRunner.cs
- src/InspectionPrototype.Infrastructure/Data/Migrations/M001_initial_schema.sql  (M002 follows the same pattern)
- src/InspectionPrototype.Infrastructure/Data/SqliteRunHistoryStore.cs (the Dapper pattern for the new SqliteDefectStore)
- src/InspectionPrototype.Infrastructure/Data/SqliteAlarmHistoryStore.cs (parallel — fire-and-forget pattern reference)

## Scope of this pass

Domain shapes + persistence (migration + store) + pipeline upgrade + AppState
shape change + tests. NO UI changes; NO captures.

## Deliverables

1. Domain shapes (src/InspectionPrototype.Domain/Contracts/):
   - DefectClassification.cs:
       public enum DefectClassification {
           Particle, Scratch, Discoloration, Crack, Unknown
       }
   - BoundingBox.cs:
       public record BoundingBox(int X, int Y, int Width, int Height);
   - Defect.cs:
       public record Defect(
           Guid                 DefectId,
           Guid                 RunId,
           Guid                 FrameId,
           DateTimeOffset       DetectedAtUtc,
           BoundingBox          BoundingBox,
           DefectClassification Classification,
           double               Confidence,
           DefectSeverity       Severity,
           double               WaferX,
           double               WaferY,
           string?              ImageRef);

2. **DELETE** src/InspectionPrototype.Domain/Contracts/InspectionResult.cs
   Solution-wide search must show zero references to InspectionResult after
   this delete (only callsite was FramePipelineService.ProcessDefectsForFrame,
   which Pass 1 also updates). If any stale reference remains in a stub or
   test fake, delete it too.

3. M002 SQL embedded resource
   (src/InspectionPrototype.Infrastructure/Data/Migrations/M002_defects.sql):

       CREATE TABLE defects (
           defect_id        TEXT    PRIMARY KEY,
           run_id           TEXT    NOT NULL REFERENCES run_summaries(run_id),
           frame_id         TEXT    NOT NULL,
           detected_at_utc  TEXT    NOT NULL,
           bbox_x           INTEGER NOT NULL,
           bbox_y           INTEGER NOT NULL,
           bbox_width       INTEGER NOT NULL,
           bbox_height      INTEGER NOT NULL,
           classification   TEXT    NOT NULL,
           confidence       REAL    NOT NULL,
           severity         TEXT    NOT NULL,
           wafer_x          REAL    NOT NULL,
           wafer_y          REAL    NOT NULL,
           image_ref        TEXT
       );
       CREATE INDEX idx_defects_run_id ON defects(run_id);
       CREATE INDEX idx_defects_detected_at_utc ON defects(detected_at_utc DESC);
       INSERT INTO schema_version (version, applied_at) VALUES (2, datetime('now'));

   Verify .csproj already includes EmbeddedResource for Migrations\*.sql
   (added in TASK-3.3 Pass 1). M002 is picked up automatically.

4. IDefectStore (src/InspectionPrototype.Application/Abstractions/IDefectStore.cs):

       public interface IDefectStore {
           Task SaveAsync(Defect defect, CancellationToken ct = default);
           Task SaveManyAsync(IEnumerable<Defect> defects, CancellationToken ct = default);
           Task<IReadOnlyList<Defect>> LoadByRunAsync(
               Guid runId, int skip = 0, int take = 100, CancellationToken ct = default);
           Task<long> CountByRunAsync(Guid runId, CancellationToken ct = default);
           Task<IReadOnlyList<Defect>> LoadRecentAsync(int count = 100, CancellationToken ct = default);
       }

5. SqliteDefectStore
   (src/InspectionPrototype.Infrastructure/Data/SqliteDefectStore.cs):
   - Mirror SqliteRunHistoryStore patterns:
       * IOptions<SqlitePersistenceOptions> + ILogger constructor
       * Per-call connection via `OpenAsync(ct)` private helper
       * Private DTO `DefectRow` (string Guids, ISO 8601 timestamps, enum strings)
       * Private static `MapRow` for DTO → Defect projection
   - SaveAsync: parameterized INSERT INTO defects (...) VALUES (...).
   - SaveManyAsync: open connection; BeginTransaction; loop over the input,
     ExecuteAsync the same INSERT for each; Commit. This is the load-bearing
     performance choice — single transaction means SQLite batches the WAL
     writes.
   - LoadByRunAsync: SELECT ... WHERE run_id = @RunId ORDER BY
     detected_at_utc DESC LIMIT @Take OFFSET @Skip.
   - CountByRunAsync: SELECT COUNT(*) FROM defects WHERE run_id = @RunId.
   - LoadRecentAsync: SELECT ... ORDER BY detected_at_utc DESC LIMIT @Count.

6. DI wiring (InfrastructureServiceCollectionExtensions.AddInfrastructure):
   Add right after the SqliteAlarmHistoryStore registration:
       services.AddSingleton<IDefectStore, SqliteDefectStore>();

7. AppState evolution
   (src/InspectionPrototype.Application/State/AppState.cs):
   - Add a new property:
       IReadOnlyList<Defect> RecentDefects = []
   - Update AppState.Initial:
       RecentDefects: []
   - Add a constant somewhere appropriate (e.g., in AppState.cs or a sibling
     Defects.cs):
       public const int RecentDefectsWindow = 100;

8. WorkflowService.StartRunAsync:
   In the state transition that builds activeRun and updates state to Preparing,
   ALSO clear RecentDefects in the same `_store.Update`:
       _store.Update(s => (s with {
           WorkflowState = WorkflowState.Preparing,
           ActiveRun = activeRun,
           RecentDefects = []   // ◀── new
       }).WithDiagnosticsEntry(...));

9. FramePipelineService upgrade
   (src/InspectionPrototype.Application/Services/FramePipelineService.cs):
   - Add IDefectStore as a constructor parameter and field (after the existing
     IDefectShowerSchedule).
   - Replace the existing ProcessDefectsForFrame implementation:

   private void ProcessDefectsForFrame(Frame frame)
   {
       var activeRun = _store.Current.ActiveRun;
       if (activeRun is null) return;

       bool showerActive = _showerSchedule.IsShowerActive;
       if (!showerActive &&
           Random.Shared.NextDouble() >= _profileProvider.CurrentProfile.DefectProbabilityPerFrame)
           return;

       var defect = BuildSimulatedDefect(frame, activeRun.RunId);

       // Fire-and-forget persistence (mirrors alarm-history pattern from SLICE-3.3 Pass 2).
       _ = SafeDefectPersistAsync(() => _defectStore.SaveAsync(defect));

       _store.Update(s =>
       {
           if (s.ActiveRun is not { } run) return s;
           var updatedRun = defect.Severity switch
           {
               DefectSeverity.Critical => run with { DefectCount = run.DefectCount + 1, DefectsCritical = run.DefectsCritical + 1 },
               DefectSeverity.Major    => run with { DefectCount = run.DefectCount + 1, DefectsMajor = run.DefectsMajor + 1 },
               _                       => run with { DefectCount = run.DefectCount + 1, DefectsMinor = run.DefectsMinor + 1 },
           };
           var recent = (new[] { defect }).Concat(s.RecentDefects).Take(AppState.RecentDefectsWindow).ToList();
           return s with { ActiveRun = updatedRun, RecentDefects = recent };
       });

       _logger.LogInformation(
           "Defect detected. Run={RunId}, Frame={FrameId}, Class={Class}, Severity={Sev}, Conf={Conf:F2}, BBox=({X},{Y},{W},{H}), Wafer=({WX:F1}, {WY:F1}).",
           activeRun.RunId, frame.FrameId, defect.Classification, defect.Severity,
           defect.Confidence, defect.BoundingBox.X, defect.BoundingBox.Y,
           defect.BoundingBox.Width, defect.BoundingBox.Height,
           defect.WaferX, defect.WaferY);
   }

   private Defect BuildSimulatedDefect(Frame frame, Guid runId)
   {
       var rng = Random.Shared;
       var classification = (DefectClassification)rng.Next(0, 5);
       double confidence = 0.5 + rng.NextDouble() * 0.5;  // [0.5, 1.0]

       int maxBoxX = Math.Max(1, frame.Width - 50);
       int maxBoxY = Math.Max(1, frame.Height - 50);
       var bbox = new BoundingBox(
           X: rng.Next(0, maxBoxX),
           Y: rng.Next(0, maxBoxY),
           Width: rng.Next(5, 51),    // [5, 50]
           Height: rng.Next(5, 51));

       double sevRoll = rng.NextDouble();
       var severity = sevRoll < 0.10 ? DefectSeverity.Critical
                    : sevRoll < 0.40 ? DefectSeverity.Major
                    :                  DefectSeverity.Minor;

       return new Defect(
           DefectId: Guid.NewGuid(),
           RunId: runId,
           FrameId: frame.FrameId,
           DetectedAtUtc: DateTimeOffset.UtcNow,
           BoundingBox: bbox,
           Classification: classification,
           Confidence: confidence,
           Severity: severity,
           WaferX: frame.CaptureX,
           WaferY: frame.CaptureY,
           ImageRef: null);
   }

   private async Task SafeDefectPersistAsync(Func<Task> op)
   {
       try { await op(); }
       catch (Exception ex)
       {
           _logger.LogWarning(ex,
               "Defect persistence failed; in-memory state is unaffected.");
       }
   }

10. Tests under tests/InspectionPrototype.Tests/:

    - MigrationRunnerM002Tests:
        * AppliesM002OnFreshDatabase: run M001 + M002; assert
          schema_version contains versions {1, 2}; sqlite_master query
          confirms defects table + 2 indexes.
        * IsIdempotentOnAtVersion2Database: run; rerun; version count
          stays at {1, 2}.
        * RollsBackOnFailure: stub a malformed M002 (or use a temp
          sibling that conflicts); assert version stays at 1.

    - SqliteDefectStoreTests:
        * SaveAsync_Then_LoadByRun_RoundTrips
        * SaveAsync_Upserts_OnDuplicateDefectId   (use INSERT OR REPLACE
          if you want upsert; or test that duplicate primary-key insert
          throws — your pick, document the behaviour)
        * SaveManyAsync_PersistsAll_InSingleTransaction:
          insert 50 defects, count = 50, all queryable by RunId.
        * LoadByRunAsync_PaginatesCorrectly:
          insert 10 defects across 2 runs; LoadByRunAsync(runA, 0, 5)
          returns 5; LoadByRunAsync(runA, 5, 5) returns the next 5 (run-A
          only).
        * LoadRecentAsync_OrdersNewestFirst.
        * CountByRunAsync_ReturnsCorrectCount.

    - SqliteDefectStorePerformanceTests
      [Trait("Category", "Performance")]:
        * SaveMany5000_CompletesUnderOneSecond:
          generate 5 000 synthetic Defects; SaveManyAsync; assert
          stopwatch < 1 000 ms. Document expected typical time
          (~150-300 ms on local).

    - FramePipelineServiceDefectIntegrationTests:
        * ProcessDefects_ProducesDefectAndPersistsViaStore:
          fake IDefectStore captures SaveAsync calls. Push a frame
          while ActiveRun is set and DefectProbabilityPerFrame=1.0;
          assert exactly 1 SaveAsync call with a Defect whose RunId
          matches the active run.
        * ProcessDefects_SkipsPersistence_WhenNoActiveRun:
          ActiveRun=null; push frame; assert SaveAsync was not called.
        * ProcessDefects_PopulatesRecentDefectsSlidingWindow:
          push 150 frames with DefectProbabilityPerFrame=1.0; assert
          AppState.RecentDefects.Count == 100 (window cap) and items
          are newest-first.
        * StorePersistenceFailure_DoesNotAffectInMemoryState:
          stub IDefectStore that throws; push frame; assert
          AppState.RecentDefects still has the new defect (in-memory
          unchanged) and no exception escapes the pipeline.

    - WorkflowServiceTests (extend existing or new):
        * StartRunAsync_ClearsRecentDefects:
          arrange AppState with a non-empty RecentDefects; call
          StartRunAsync; assert AppState.RecentDefects = [].

    - Stubs/FakeDefectStore.cs:
        * Records every SaveAsync / SaveManyAsync call.
        * Provides a configurable "throw on save" for the failure test.
        * Implements LoadByRunAsync and CountByRunAsync over an in-memory
          List<Defect> for assertions.

## Constraints

- Do NOT add any UI changes in this pass. WaferMapView is Pass 2.
- Do NOT change DefectSeverity, RunSummary, or ActiveRunState fields.
  Existing severity counts continue to populate the same way.
- Do NOT propagate IDefectStore exceptions out of FramePipelineService.
  The fire-and-forget pattern + SafeDefectPersistAsync's catch is
  load-bearing — a defect-persist failure must not crash the pipeline.
- Do NOT remove M001 or modify it. M002 is additive.
- The SaveManyAsync 5 000-row perf test uses
  [Trait("Category", "Performance")] so CI without the trait runs in
  bounded time; capture-time runs include it.
- Do NOT change the existing AppState.RunHistory field shape — Pass 1
  only ADDS RecentDefects.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release --filter "Category!=Performance"
  dotnet test --configuration Release --filter "Category=Performance"

Both runs green. The performance test must consistently complete under
1 000 ms; if it flakes near 1 000 ms, the prime suspect is the WAL
journal-mode flush — verify WAL is set on the test DB.

Manual smoke (no UI yet, but the persistence works):
  - Delete inspection.db.
  - Launch app; observe migration runner applies M001 + M002 in logs.
  - Run a brief HighDefect cycle (Connect → Home → Run → Stop).
  - Inspect inspection.db: SELECT COUNT(*) FROM defects WHERE run_id = '<the-run-guid>';
    Expect a non-zero count.
  - Confirm AppState.RecentDefects is populated by reading the
    diagnostics timeline or by attaching a debugger.

## Report format when finished

- files created / modified / deleted (especially: deletion of InspectionResult.cs)
- confirmation all tests pass including the perf suite
- the 5 000-row SaveManyAsync measured time (e.g., "187 ms; well under 1 000 ms ceiling")
- a single commit hash
- commit message: "feat(domain): add Defect record + SqliteDefectStore + M002 migration; remove InspectionResult (pass 1/3 of TASK-3.1)"

Pass 2 — Wafer-map UI

You are implementing Pass 2 of TASK-3.1. Pass 1 (Defect domain shape +
persistence + FramePipelineService upgrade + AppState.RecentDefects)
is already merged. This pass adds the WaferMapView WPF UserControl,
the DefectViewModel projection, MainWindow.xaml integration, and the
diff-based ObservableCollection projection in MainViewModel.

NO captures (Pass 3).

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.1-rich-defect-model.md          (criteria 7, 8, 10)
- src/InspectionPrototype.Application/State/AppState.cs   (Pass 1 — RecentDefects)
- src/InspectionPrototype.Domain/Contracts/Defect.cs      (Pass 1)
- src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs
- src/InspectionPrototype.App/MainWindow.xaml             (where the WaferMapView slots in)
- tests/InspectionPrototype.Tests/MainWindowAutomationIdRegressionTests.cs
  (Pattern: every new AutomationId must be registered here)

Pass 1 must be merged. Confirm: Defect record exists in Domain,
SqliteDefectStore exists, AppState.RecentDefects = []. The Pass-1
manual smoke test populates RecentDefects via FramePipelineService
during a HighDefect run.

## Scope of this pass

UI control + ViewModel projection + XAML integration + AutomationIds +
WaferToCanvas helper + tests. NO captures.

## Deliverables

1. DefectViewModel
   (src/InspectionPrototype.Presentation/ViewModels/DefectViewModel.cs):

   public sealed class DefectViewModel
   {
       public Guid             DefectId       { get; init; }
       public DefectClassification Classification { get; init; }
       public DefectSeverity   Severity       { get; init; }
       public double           Confidence     { get; init; }
       public double           CanvasX        { get; init; }   // pre-projected
       public double           CanvasY        { get; init; }   // pre-projected
       public Brush            MarkerBrush    { get; init; }   // pre-resolved color
       public string           ToolTipText    { get; init; }   // optional, e.g. "Particle, conf 0.85"
   }

   The ViewModel exposes already-projected canvas coordinates and a Brush
   so the XAML stays converter-free. The projection runs in MainViewModel
   on the dispatcher.

2. WaferToCanvas helper
   (src/InspectionPrototype.Presentation/WaferToCanvas.cs):

   public static class WaferToCanvas
   {
       public const double WaferRangeMm = 100.0;       // ±100 mm
       public const double CanvasSizePx = 400.0;       // 400×400 canvas

       public static (double cx, double cy) Project(double waferX, double waferY)
       {
           // Wafer-mm range [-100, 100]; canvas-px range [0, 400].
           double cx = (waferX + WaferRangeMm) / (2.0 * WaferRangeMm) * CanvasSizePx;
           double cy = (waferY + WaferRangeMm) / (2.0 * WaferRangeMm) * CanvasSizePx;
           // Clip to canvas; out-of-range coords land on the edge.
           cx = Math.Clamp(cx, 0.0, CanvasSizePx);
           cy = Math.Clamp(cy, 0.0, CanvasSizePx);
           return (cx, cy);
       }

       public static Brush BrushForSeverity(DefectSeverity s) => s switch {
           DefectSeverity.Critical => Brushes.Red,
           DefectSeverity.Major    => Brushes.Orange,
           _                       => Brushes.Gold,    // Minor → Gold (more visible than Yellow)
       };
   }

3. MainViewModel updates
   (src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs):
   - Add: public ObservableCollection<DefectViewModel> RecentDefects { get; } = new();
   - In OnStateChanged:
       _dispatcher.Invoke(() => Project(state));
       _dispatcher.InvokeAsync(() => DiffRecentDefects(state.RecentDefects));
       (existing CurrentFrame call stays)

   - DiffRecentDefects diff projection:
       * Build a HashSet<Guid> of new state's defect IDs.
       * Remove any RecentDefects entries whose DefectId is not in the set
         (these fell out of the window).
       * For each defect in state.RecentDefects (newest first), if not
         already at the corresponding index, insert a fresh
         DefectViewModel at the right position.
       * Trim the ObservableCollection to RecentDefectsWindow (= 100).

   The simpler "clear and rebuild" pattern is REJECTED — it invalidates
   every visual element and trashes the canvas's render cache. The diff
   pattern preserves identity for unchanged entries.

   - Add an int DefectCount property bound to ActiveRun.DefectCount, so
     the live count label updates as defects accumulate.

4. WaferMapView UserControl
   (src/InspectionPrototype.Presentation/Controls/WaferMapView.xaml +
    WaferMapView.xaml.cs):

   <UserControl
       x:Class="InspectionPrototype.Presentation.Controls.WaferMapView"
       xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
       xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
       xmlns:vm="clr-namespace:InspectionPrototype.Presentation.ViewModels"
       Width="420" Height="460">
     <StackPanel>
       <Grid Width="400" Height="400" Margin="10"
             AutomationProperties.AutomationId="WaferMapView">
         <Ellipse Width="400" Height="400" Stroke="Gray" StrokeThickness="2" Fill="#FFFAFAFA"/>
         <ItemsControl ItemsSource="{Binding RecentDefects}">
           <ItemsControl.ItemsPanel>
             <ItemsPanelTemplate>
               <Canvas Width="400" Height="400" />
             </ItemsPanelTemplate>
           </ItemsControl.ItemsPanel>
           <ItemsControl.ItemTemplate>
             <DataTemplate DataType="{x:Type vm:DefectViewModel}">
               <Ellipse Width="6" Height="6"
                        Fill="{Binding MarkerBrush}"
                        ToolTip="{Binding ToolTipText}">
                 <Canvas.Left><Binding Path="CanvasX"/></Canvas.Left>
                 <Canvas.Top><Binding Path="CanvasY"/></Canvas.Top>
               </Ellipse>
             </DataTemplate>
           </ItemsControl.ItemTemplate>
         </ItemsControl>
       </Grid>
       <TextBlock HorizontalAlignment="Center" Margin="10,0">
         Defects detected:
         <Run AutomationProperties.AutomationId="DefectCountText"
              Text="{Binding DefectCount, Mode=OneWay}" />
       </TextBlock>
     </StackPanel>
   </UserControl>

   Note: Canvas.Left and Canvas.Top must be set via attached properties.
   The exact XAML pattern for binding attached properties on items inside
   an ItemsControl with Canvas ItemsPanel requires either an ItemContainerStyle
   or a wrapping element. If the inline Canvas.Left form above doesn't
   bind correctly (WPF gotcha), use ItemContainerStyle:

       <ItemsControl.ItemContainerStyle>
         <Style TargetType="ContentPresenter">
           <Setter Property="Canvas.Left" Value="{Binding CanvasX}"/>
           <Setter Property="Canvas.Top"  Value="{Binding CanvasY}"/>
         </Style>
       </ItemsControl.ItemContainerStyle>

   Either form is acceptable; ItemContainerStyle is the canonical WPF
   pattern for "Canvas.Left bound to data item's property".

5. MainWindow.xaml integration:
   Place the WaferMapView in a sensible location alongside the existing
   frame preview Image. The exact layout decision is yours; one workable
   pattern: a horizontal stack with frame preview on the left and
   WaferMapView on the right, each ~420 px wide. Add the namespace:

   xmlns:controls="clr-namespace:InspectionPrototype.Presentation.Controls;assembly=InspectionPrototype.Presentation"

   And the control:

   <controls:WaferMapView />

   The MainWindow already binds DataContext to MainViewModel; the
   WaferMapView inherits that DataContext.

6. AutomationIds:
   Add to MainWindowAutomationIdRegressionTests.cs:
       "WaferMapView",       // grid containing the wafer map
       "DefectCountText",    // run-of-text inside the count label
   Both must be present in MainWindow.xaml (or in the WaferMapView's XAML
   that MainWindow includes). Verify by running the existing regression
   tests.

7. Tests under tests/InspectionPrototype.Tests/:

   - WaferToCanvasTests:
       * Project_Center_Returns_HalfHalf:
           Project(0, 0) ≈ (200, 200).
       * Project_TopLeftCorner_ReturnsZeroZero:
           Project(-100, -100) = (0, 0).
       * Project_BottomRightCorner_ReturnsCanvasSize:
           Project(100, 100) = (400, 400).
       * Project_OutOfRange_ClampsToEdge:
           Project(500, -500) = (400, 0).
       * BrushForSeverity_ReturnsExpectedColors.

   - MainViewModelDefectProjectionTests:
       * EmptyRecentDefects_ProducesEmptyCollection.
       * SingleDefect_AppearsAsViewModelWithProjectedCoords:
           AppState with one defect at (50, 50); after dispatcher pump,
           RecentDefects.Count == 1; CanvasX, CanvasY ≈ (300, 300).
       * NewDefects_PrependedToCollection:
           push 5 defects sequentially; assert ObservableCollection has
           the newest at index 0.
       * EvictedDefects_RemovedFromCollection:
           push 150 defects; assert ObservableCollection.Count == 100
           and the 50 oldest are gone.
       * DefectCount_UpdatesFromActiveRun:
           change ActiveRun.DefectCount; DefectCount property fires
           PropertyChanged.

   - (Optional) acceptance test in tests/InspectionPrototype.AcceptanceTests/
     adding a smoke that asserts WaferMapView and DefectCountText are
     present after app launch (FlaUI ReadTextByAutomationIdAsync).

## Constraints

- Do NOT add value converters to XAML. The DefectViewModel projection
  runs in MainViewModel; XAML stays declarative-only.
- Do NOT clear-and-rebuild the ObservableCollection on every state
  change. The diff pattern is load-bearing for performance — verified
  by the 100-defect projection test.
- Do NOT change the WaferMapView dimensions (400×400 px) without
  also updating WaferToCanvas.CanvasSizePx — these are coupled.
- Do NOT add cross-thread access to RecentDefects from outside the
  UI dispatcher. The OnStateChanged hop via _dispatcher.InvokeAsync
  is load-bearing.
- The wafer outline color (`#FFFAFAFA`) and marker brushes are picked
  for visibility on the existing app theme. Adjust if needed but
  don't drop them below 4.5:1 contrast.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release --filter "Category!=Performance"

Manual smoke:
  - Launch app; switch to HighDefect profile.
  - Connect → Home → Run.
  - Watch the WaferMapView panel — defect markers should appear as
    runs progress, color-coded by severity.
  - Verify the DefectCountText updates in real time.
  - Stop the run; the markers stay on screen until the next run starts
    (RecentDefects is cleared at StartRunAsync per Pass 1).
  - Switch to Normal profile; defects appear at a much lower rate.

## Report format when finished

- files created / modified
- confirmation all tests pass
- a screenshot or description of the wafer map after a brief HighDefect run
- a single commit hash
- commit message: "feat(ui): add wafer-map view with defect markers + projection diff (pass 2/3 of TASK-3.1)"

Pass 3 — HighDefect capture + row block + Phase 2 trigger assessment + runbook §5.3

You are implementing Pass 3 of TASK-3.1, the final pass. Passes 1 and 2 are
merged. This pass runs a 30-min HighDefect-profile capture under the FlaUI
rig, appends the slice-3-1-rich-defect-model row to phase-3-measurements.md,
writes runbook §5.3, and applies the SLICE-2.0 decision rubric to the row.

NO code changes — Passes 1 and 2 own those.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.1-rich-defect-model.md   (criteria 9, 12, 13, 14)
- docs/runbook/capturing-measurements.md      (§5.1 SLICE-2.0; §5.2 SLICE-3.3 — mirror for §5.3)
- docs/reviews/phase-3-measurements.md        (Phase 3 row format + the slice-3-3 row)
- tools/Capture-Measurements.ps1
- tools/MeasurementExtraction.psm1
- tools/Populate-SyntheticHistory.ps1         (only if pre-population is needed; HighDefect run-history doesn't require it)

## Scope of this pass

Capture, three new measurement-extraction helpers, row block append, runbook
§5.3, Phase 2 trigger assessment in the row's Notes section, session-handoff
updates. No code or test changes.

## Deliverables

1. tools/MeasurementExtraction.psm1:
   Add and export three helpers:

   - Get-DefectsPersistedCount -DatabasePathBefore $b -DatabasePathAfter $a:
       Open both DBs, SELECT COUNT(*) FROM defects, return after - before.

   - Get-DefectClassificationDistribution -DatabasePath $db -RunId $runId:
       SELECT classification, COUNT(*) FROM defects WHERE run_id = @runId
           GROUP BY classification ORDER BY COUNT(*) DESC;
       Return [ordered] hashtable @{Particle=N; Scratch=N; ...}.

   - Get-DefectPersistP95Ms -LogPath $logPath:
       Parse "Defect persisted (latency Xms)" log lines (the SafeDefectPersist-
       Async helper logs latency at Debug level on success — Pass 1 should
       have wired this). Return p95.

   If Pass 1 didn't enrich the SafeDefectPersistAsync log line with latency,
   this pass adds the enrichment minimally — but the spec criterion expects
   Pass 1 to have it. Verify before adding.

   Update ConvertTo-MeasurementRow to optionally accept -DatabaseBefore /
   -DatabaseAfter / -RunId, and to look up the latency p95 from the log.
   Append three new rows to the markdown output:
     | defects.persisted (count)             | <Get-DefectsPersistedCount>            |
     | defect-classification distribution    | <hashtable as "Particle: 1234; ...">   |
     | defect-persist p95 (ms)               | <Get-DefectPersistP95Ms>               |

2. tests/Tools/MeasurementExtraction.Tests.ps1:
   Pester tests for the three new helpers using synthetic SQLite databases
   (sqlite3 CLI in setup) and synthetic log fixtures. Same pattern as the
   SLICE-3.3 helper tests.

3. Disable system sleep before capture (runbook discipline reaffirmed):
       powercfg /change standby-timeout-ac 0
       powercfg /change monitor-timeout-ac 0

4. Capture procedure:
       $date = Get-Date -Format 'yyyy-MM-dd'
       $dbPath = "$env:LOCALAPPDATA\LcnWaferInspection\inspection.db"

       # Step 1: backup any pre-existing database
       if (Test-Path $dbPath) { Move-Item $dbPath "$dbPath.preCapture-$date" }

       # Step 2: launch app briefly to trigger M001+M002, then close
       # (this creates a fresh DB at known schema).

       # Step 3: take before-snapshot AFTER schema creation (no
       # pre-populate needed for SLICE-3.1 — the slice produces its own
       # defects)
       Copy-Item $dbPath "$env:TEMP\inspection.db.before-$date"

       # Step 4: run the 30-min capture under HighDefect profile
       tools/Capture-Measurements.ps1 -Scenario MultiTagSoak `
         -DurationSeconds 1800 -Profile HighDefect `
         -OutputCsv "docs/captures/slice-3-1-rich-defect-model-$date.csv" `
         -CommitHash $(git rev-parse --short HEAD) `
         -SliceTag slice-3-1-rich-defect-model

       # Step 5: take after-snapshot
       Copy-Item $dbPath "$env:TEMP\inspection.db.after-$date"

   Verify:
       * exit code 0; CSV span >= 1700 s
       * SELECT COUNT(*) FROM defects in the after-DB returns >= 5 000
         (criterion 9). If less, the HighDefect profile's defect rate
         was insufficient — investigate before proceeding.
       * SELECT classification, COUNT(*) FROM defects ... GROUP BY
         classification — expect roughly even distribution across the
         5 enum values (uniform random in BuildSimulatedDefect).
       * Pagination check: open a sqlite client, run
           SELECT defect_id FROM defects WHERE run_id = '<some-run>'
           ORDER BY detected_at_utc DESC LIMIT 100 OFFSET 0;
         Returns 100 rows. Then OFFSET 100 returns the next page. If
         the row count for that run is high enough.
       * Reproducibility: first 26 metrics within ±10% of slice-3-3
         on overlapping rate metrics (frames.ingested, telemetry rate,
         encoder rate, store.update rate). If significantly different,
         the HighDefect profile load is the explanation; document.

5. Append row block to docs/reviews/phase-3-measurements.md:

   Mirror the slice-3-3 row format. Standard 26 metrics from Phase 2
   set, plus the three new persistence-specific metrics:

   ### Row — slice-3-1-rich-defect-model

   - Scenario: §5.3 Rich-defect-model capture (30 min, HighDefect profile)
   - Capture: docs/captures/slice-3-1-rich-defect-model-<date>.csv
   - Profile: HighDefect (DefectProbabilityPerFrame = 0.6)
   - Commit: <hash>
   - Date: <today>

   29-metric table (or 30 — the existing 26 + defects-persisted +
   defect-classification + defect-persist-p95). Baseline is
   slice-3-3-sqlite-persistence for the 26 overlapping metrics; "—" for
   the three new defect-specific ones.

   Notes section MUST include:

   (a) Why HighDefect was chosen (need to exercise persistence at scale;
       MultiTag's defect rate is too low to hit the 5 000-defect criterion
       in a 30-min window).

   (b) Criterion-9 satisfaction: defects.persisted = N (≥ 5 000); pagination
       verified by sqlite query at OFFSET 0 / 100 / 200; classification
       distribution table.

   (c) Phase 2 trigger assessment per the SLICE-2.0 decision rubric:

       | Rubric criterion                       | Threshold | Measured | Decision |
       |----------------------------------------|-----------|----------|----------|
       | store.update alloc share ≥ 10%         | 10%       | <X>%     | <decision> |
       | store.update lock-wait p95 ≥ 100 µs    | 100 µs    | <X> µs   | <decision> |
       | FramePipelineService.* > 50%           | 50%       | <X>%     | <decision> |

       The HighDefect run drives FramePipelineService.ExecuteAsync's
       store-write rate up significantly (every detected defect calls
       _store.Update). If FramePipelineService.* crosses 50% of total
       calls, that's the SLICE-2.3 (frame-stream lift-out) trigger.
       If alloc share crosses 10%, that opens 2.1/2.2.

       Apply the rubric mechanically; pick exactly one outcome:
         - "SLICE-2.3 (frames) opens — FramePipelineService share = X%"
         - "SLICE-2.1 / 2.2 open — alloc share = X%"
         - "SLICE-2.4 opens — lock-wait p95 = X µs"
         - "Phase 2 stays deferred — all rubric gates clear"

   (d) What surprised, if anything: defect-persist latency, classification
       distribution skew, working-set drift, anything outside expected
       envelope.

6. Add §5.3 to docs/runbook/capturing-measurements.md:
   - Title: "### 5.3 Rich-defect-model capture — SLICE-3.1, HighDefect profile"
   - Placement: after §5.2 (slice-3-3 SQLite persistence)
   - Procedure mirrors §5.2 but with -Profile HighDefect, no pre-populate.
   - Sanity checks: defects.persisted ≥ 5 000, classification distribution
     roughly uniform, pagination works, Phase 2 trigger assessment as
     row's expected output.
   - Implemented by: MultiTagSoakFlaUi with --profile HighDefect.

7. Update CLAUDE.md "Current position" block:
   - Phase 3: SLICE-3.1 complete; defects.persisted = N; Phase 2 trigger
     decision: <outcome>.
   - Last completed action: TASK-3.1 Pass 3 — HighDefect 30-min capture;
     <N> defects persisted; classification distribution <hashtable>;
     <Phase 2 outcome>; commit <hash>.
   - Next action:
       * if SLICE-2.x opened: that's the next implementation slice.
       * if Phase 2 stays deferred: write SLICE-3.2 spec + task (cassette
         cadence — composes SLICE-3.3 + SLICE-3.1 + workflow state machine).

8. Append session-log entry to docs/reviews/roadmap-progress.md.
   Mark SLICE-3.1 as Completed in the Phase 3 progress table.

9. Restore powercfg settings.

## Constraints

- Do NOT make any code or test changes in this pass.
- Do NOT skip the 30-min HighDefect capture. Producing 5 000+ defects
  requires sustained run cycles under HighDefect; shorter captures may
  not hit the criterion.
- Do NOT proceed to step 5 (table edit) if criterion 9 (5 000 defects)
  fails. The whole slice's exit gate hangs on this number.
- Do NOT pre-decide the Phase 2 trigger outcome. Apply the rubric to
  the captured numbers; write whichever outcome the data supports.
  Phase 2 deferral or one specific Phase 2 slice opening — both are
  valid outcomes per the strategy doc.

## Verification before you report done

  dotnet build --configuration Release
  dotnet test --configuration Release --filter "Category!=Performance"

Plus:
  - docs/captures/slice-3-1-rich-defect-model-<date>.csv exists and is committed
  - docs/reviews/phase-3-measurements.md has the new row block; defects.persisted
    is verifiably ≥ 5 000
  - §5.3 renders correctly in the runbook
  - CLAUDE.md current-position reflects SLICE-3.1 closure and the Phase 2 decision

## Report format when finished

- files created / modified
- the captured 29-metric row block (markdown table) included verbatim
- defects.persisted count + classification distribution
- the Phase 2 trigger assessment outcome (one specific decision)
- a single commit hash
- commit message: "feat(measurements): SLICE-3.1 row + Phase 2 trigger assessment; runbook §5.3 (pass 3/3 of TASK-3.1)"

Operator notes

  • One pass per Copilot session. Same protocol as TASK-1.4 / TASK-2.0 / TASK-3.3.
  • Pass 1's load-bearing detail is the SaveManyAsync transaction. 5 000 single-statement INSERTs in one transaction is the slice's performance hinge. If the perf test ever fails, the prime suspects are: (a) WAL mode not set (verify migration runner sets it); (b) FK enforcement during high-volume insert (consider PRAGMA foreign_keys = OFF for the bulk insert if FK validation overhead dominates — but only if measurement supports it). Do not pre-optimize.
  • Pass 1's other load-bearing detail is RecentDefects reset at run start. A run-end reset would lose the visualization of the most recent run's defects when the operator stops a run; resetting at start of the next run preserves the post-run view. The unit test for this is non-optional.
  • Pass 2's load-bearing detail is the diff projection. "Clear and rebuild ObservableCollection" is the wrong pattern under sustained defect rate — every state change would invalidate the canvas's render cache. The diff pattern (HashSet of new IDs, remove evicted, prepend new) is what makes the wafer-map performant under HighDefect. The 150-defect projection test verifies this.
  • Pass 2's WPF gotcha: Canvas.Left bound to a data item's property requires ItemContainerStyle rather than inline <Canvas.Left> inside a DataTemplate. The prompt mentions both forms; if the inline form doesn't work (varies by WPF version), fall through to ItemContainerStyle without re-litigating.
  • Pass 3's exit gate is the 5 000-defect count. This is the strict measurement criterion from the original roadmap §5. If the HighDefect profile's defect rate doesn't produce 5 000 in 30 min, the slice has a profile-tuning gap, not a code defect — bump DefectProbabilityPerFrame or extend the capture window. Document the actual numbers in the row block regardless.
  • Pass 3's Phase 2 trigger assessment is the row's strategic value. The HighDefect profile drives the highest store-write rate the prototype has measured to date — if anything ever crosses the rubric thresholds, this is the most likely capture to surface it. Apply the rubric mechanically; the slice succeeds whether Phase 2 opens or stays deferred. Don't pre-decide.
  • Update the index files only at the end of the phase, not per-slice. Same rationale as earlier tasks. When SLICE-3.4 closes (last Phase 3 slice), do an index sweep across docs/specs/index.md, docs/tasks/index.md, and the VitePress sidebar.

Docs-first project memory for AI-assisted implementation.