TASK-3.1: Implement Rich Defect Model
- Status: Proposed (no passes started)
- Date: 2026-05-07
- Spec: SLICE-3.1: Rich Defect Model
- Depends on: TASK-3.3: SQLite Persistence (uses the migration runner + Dapper +
SqlitePersistenceOptionsinfra it established), TASK-1.6: FlaUI Capture (the rig that captures the row block)
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,DefectClassificationenum) inInspectionPrototype.Domain.Contracts InspectionResult.csdeleted- M002 migration creating
defectstable + 2 indexes + schema_version row IDefectStore(Application.Abstractions) +SqliteDefectStore(Infrastructure.Data, Dapper)FramePipelineService.ProcessDefectsForFrameupgraded to produceDefectand persist fire-and-forgetAppState.RecentDefectssliding-100 window; reset atWorkflowService.StartRunAsyncWaferMapViewUserControl inPresentation/Controls/;DefectViewModelprojection;MainWindow.xamlintegration- AutomationIds (
WaferMapView,DefectCountText) for FlaUI - New
MeasurementExtraction.psm1helpers:Get-DefectsPersistedCount,Get-DefectClassificationDistribution,Get-DefectPersistP95Ms ConvertTo-MeasurementRowadds 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,
SqliteDefectStoreroundtrip + pagination,SaveManyAsync5 000-row perf,FramePipelineServiceintegration,MainViewModeldiff projection,WaferToCanvascoordinate 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
ImageRefcropped-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.LoadByRunAsyncAPI 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
DefectSeverityenum orRunSummary/ActiveRunStatedefect-count fields — unchanged
Touched Projects
src/InspectionPrototype.Domain— newDefect.cs,BoundingBox.cs,DefectClassification.cs; deleteInspectionResult.cssrc/InspectionPrototype.Application— newAbstractions/IDefectStore.cs;State/AppState.cs(addRecentDefects);Services/FramePipelineService.cs(defect generation + persistence);Services/WorkflowService.cs(clear RecentDefects on StartRunAsync); add aApplication.Defects.RecentDefectsWindowconstant or similarsrc/InspectionPrototype.Infrastructure— newData/Migrations/M002_defects.sql(embedded resource); newData/SqliteDefectStore.cs(Dapper); DI wiring inInfrastructureServiceCollectionExtensionssrc/InspectionPrototype.Presentation— newControls/WaferMapView.xaml+.csUserControl; newViewModels/DefectViewModel.cs;ViewModels/MainViewModel.cs(RecentDefects ObservableCollection + diff projection); helperWaferToCanvassrc/InspectionPrototype.App—MainWindow.xaml(host WaferMapView; AutomationIds)tests/InspectionPrototype.Tests—MigrationRunnerM002Tests,SqliteDefectStoreTests,SqliteDefectStorePerformanceTests,FramePipelineServiceDefectIntegrationTests,MainViewModelDefectProjectionTests,WaferToCanvasTests;Stubs/FakeDefectStore.cstests/InspectionPrototype.AcceptanceTests— possibly add a smoke FlaUI test that asserts WaferMapView is reachable (existing AutomationId regression test pattern)tools/MeasurementExtraction.psm1— three new helperstests/Tools/MeasurementExtraction.Tests.ps1— Pester tests for the new helpersdocs/runbook/capturing-measurements.md— new §5.3docs/reviews/phase-3-measurements.md— new row blockdocs/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.
- Domain shapes + persistence + pipeline + AppState. New types, M002,
IDefectStore,SqliteDefectStore,FramePipelineServiceupgrade,AppState.RecentDefects,WorkflowService.StartRunAsyncreset, tests including the 5 000-rowSaveManyAsyncperf test. NO UI work, NO captures. - Wafer-map UI.
DefectViewModel,MainViewModel.RecentDefectsdiff projection,WaferMapViewUserControl + XAML,MainWindow.xamlintegration, AutomationIds,WaferToCanvashelper, tests for projection + coordinate math + a FlaUI smoke check. NO captures. - 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 = OFFfor 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
RecentDefectsreset 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
ItemContainerStylerather than inline<Canvas.Left>inside aDataTemplate. 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
DefectProbabilityPerFrameor 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.