Skip to content

TASK-3.2: Implement Wafer Loop / Cassette Cadence

Objective

Compose SLICE-3.3 + SLICE-3.1 + the existing workflow state machine into a wafer-cassette scheduler that runs 25 wafers back-to-back through Load → Align → Run → Unload phases. Add WaferId and LotId to RunSummary. Introduce a stub-row-at-run-start pattern so defects.run_id FK can be enforced (closing the SLICE-3.1 follow-up #3). Add cassette UI panel showing slot progress. 25-wafer cassette completes under Soak8h profile in ≤ 20 min wall-clock with all per-wafer records persisted and zero FK errors.

Scope

  • New domain shapes (WaferId, LotId, CassetteSlot, CassetteState, WaferPhase) in Domain.Contracts
  • RunTerminalStatus.Pending enum value
  • RunSummary gains nullable WaferId / LotId strings
  • M003 migration (wafer_id + lot_id columns + indexes); M004 migration (FK-retirement audit marker)
  • ICassetteScheduler (Application.Abstractions) + SimulatedCassetteScheduler (Infrastructure.Simulator)
  • WorkflowService.StartRunAsync(WaferId? = null, LotId? = null) overload; stub-row insert at run start; existing terminal-state update at run end
  • SqliteDefectStore.OpenAsync removes PRAGMA foreign_keys = OFF (FK band-aid retired)
  • AppState.Cassette field; MainViewModel.CassetteStatusPanel projection
  • SimulatorProfile gains WaferLoadMs / WaferAlignMs / WaferUnloadMs
  • CassetteStatusPanel UserControl + 4 new MainWindow buttons with AutomationIds
  • New MeasurementExtraction.psm1 helpers: Get-WafersCompletedCount, Get-CassetteWallClockSeconds
  • Runbook §5.4 entry; row block in phase-3-measurements.md
  • Tests: cassette scheduler loop, stub-row pattern, FK orphan-row integrity (zero orphans), reproducibility against slice-1-1-multi-tag-telemetry, 25-wafer cassette acceptance

Non-Scope

  • Multi-cassette / lot-batching beyond 25 wafers
  • Real wafer-handler robotics (Load / Align / Unload are simulated Task.Delay)
  • Pre-align failure modes (notch not found, wafer mis-clamp)
  • Mid-cassette pause / resume across cassette boundaries
  • Wafer-level retry policy (operator re-loads slot manually)
  • OperatorId — Phase 3.4 territory
  • Defect correlation across wafers (cross-wafer pattern detection is Phase 4 ML)
  • Cassette UI virtualization (25 slots is small)
  • Modifying DefectSeverity / RunTerminalStatus other than adding Pending

Touched Projects

  • src/InspectionPrototype.Domain — new WaferId.cs, LotId.cs, CassetteSlot.cs, CassetteState.cs, WaferPhase.cs; RunTerminalStatus.cs adds Pending; RunSummary.cs adds two nullable string fields
  • src/InspectionPrototype.Application — new Abstractions/ICassetteScheduler.cs; State/AppState.cs adds Cassette; Services/WorkflowService.cs (stub-row insert + StartRunAsync overload)
  • src/InspectionPrototype.Infrastructure — new Data/Migrations/M003_cassette_columns.sql + M004_enable_defects_fk.sql; new Simulator/SimulatedCassetteScheduler.cs; Simulator/SimulatorProfilesOptions.cs adds 3 fields; Data/SqliteRunHistoryStore.cs (extended INSERT for new columns); Data/SqliteDefectStore.cs (remove PRAGMA OFF); DI wiring
  • src/InspectionPrototype.Application/State/SimulatorProfile.cs — 3 new fields with defaults
  • src/InspectionPrototype.Presentation — new Controls/CassetteStatusPanel.xaml/.cs; new ViewModels/CassetteSlotViewModel.cs; MainViewModel cassette projection
  • src/InspectionPrototype.App/MainWindow.xaml — new buttons with AutomationIds; CassetteStatusPanel hosting
  • tests/InspectionPrototype.TestsMigrationRunnerM003M004Tests, SimulatedCassetteSchedulerTests, WorkflowServiceStubRowTests, SqliteDefectStoreFkIntegrationTests, MainViewModelCassetteProjectionTests; Stubs/FakeCassetteScheduler.cs
  • tests/Tools/MeasurementExtraction.Tests.ps1 — Pester for two new helpers
  • docs/runbook/capturing-measurements.md — §5.4
  • docs/reviews/phase-3-measurements.md — new row block
  • docs/captures/ — new CSV
  • tools/MeasurementExtraction.psm1 — two new helpers

AI Tool Guidance

Three Copilot passes; one-pass-per-session.

  1. Domain shapes + persistence + scheduler + WorkflowService stub-row + FK retirement. All non-UI code; M003 + M004; SimulatedCassetteScheduler; tests including the FK orphan-row integrity test. NO UI work, NO captures.
  2. Cassette UI panel. CassetteStatusPanel UserControl + CassetteSlotViewModel projection in MainViewModel + 4 new MainWindow buttons + AutomationIds + UI tests. NO captures.
  3. 25-wafer cassette capture under Soak8h + row block + runbook §5.4 + Phase 2 trigger assessment. No code changes.

Acceptance Criteria Mapping

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

  • Pass 1 covers criteria 1–10, 17, 18, and the C#-test portions of 12
  • Pass 2 covers criteria 9 (UI projection portion), 11
  • Pass 3 covers criteria 12 (capture-driven verification), 13, 14, 15, 16

Copilot Agent Prompts

Pass 1 — Domain shapes + persistence + scheduler + WorkflowService stub-row + FK retirement

You are implementing Pass 1 of TASK-3.2: introduce the wafer-cassette
scheduler, add stub-row-at-run-start pattern that closes the SLICE-3.1 FK
band-aid, and persist WaferId/LotId on RunSummary. NO UI work (Pass 2). NO
captures (Pass 3).

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.2-cassette-cadence.md             (the requirements)
- docs/tasks/TASK-3.2-implement-cassette-cadence.md    (this task)
- docs/specs/SLICE-3.3-sqlite-persistence.md           (parallel patterns)
- docs/specs/SLICE-3.1-rich-defect-model.md            (FK band-aid context)
- src/InspectionPrototype.Application/Services/WorkflowService.cs
- src/InspectionPrototype.Application/Services/AppStateStore.cs
- src/InspectionPrototype.Domain/Contracts/RunSummary.cs
- src/InspectionPrototype.Domain/Contracts/RunTerminalStatus.cs
- src/InspectionPrototype.Infrastructure/Data/Migrations/M001_initial_schema.sql
  and M002_defects.sql                                 (migration patterns)
- src/InspectionPrototype.Infrastructure/Data/SqliteRunHistoryStore.cs
- src/InspectionPrototype.Infrastructure/Data/SqliteDefectStore.cs
  (the FK band-aid line — to be retired)
- src/InspectionPrototype.Application/State/AppState.cs
- src/InspectionPrototype.Application/State/SimulatorProfile.cs
- src/InspectionPrototype.Infrastructure/Simulator/SimulatorProfilesOptions.cs

## Scope of this pass

All non-UI code: domain shapes + migrations + scheduler + WorkflowService
extension + FK retirement + AppState shape + tests. NO UI changes.

## Deliverables

1. Domain shapes (src/InspectionPrototype.Domain/Contracts/):
   - WaferPhase.cs:
       public enum WaferPhase { Idle, Loading, Aligning, Running, Unloading, Complete }
   - WaferId.cs: record with Value string + Generate(lotId, slotIndex) static
   - LotId.cs: record with Value string + GenerateForToday() static (yyyyMMdd-HHmmss)
   - CassetteSlot.cs: record (Index, WaferId?)
   - CassetteState.cs: record (LotId, TotalSlots, CurrentSlotIndex, CurrentPhase, IReadOnlyList<CassetteSlot> Slots)
   - RunTerminalStatus.cs: add Pending value as the FIRST enum value (so default(RunTerminalStatus) = Pending)

2. RunSummary extension (src/InspectionPrototype.Domain/Contracts/RunSummary.cs):
   Add at end of record:
       string? WaferId = null,
       string? LotId   = null

3. M003 SQL embedded resource
   (src/InspectionPrototype.Infrastructure/Data/Migrations/M003_cassette_columns.sql):

       ALTER TABLE run_summaries ADD COLUMN wafer_id TEXT;
       ALTER TABLE run_summaries ADD COLUMN lot_id TEXT;
       CREATE INDEX idx_run_summaries_lot_id ON run_summaries(lot_id);
       CREATE INDEX idx_run_summaries_wafer_id ON run_summaries(wafer_id);
       INSERT INTO schema_version (version, applied_at) VALUES (3, datetime('now'));

4. M004 SQL embedded resource (FK-retirement audit marker)
   (src/InspectionPrototype.Infrastructure/Data/Migrations/M004_enable_defects_fk.sql):

       -- M004 — no schema change. Marks the version bump where SqliteDefectStore
       -- removed its `PRAGMA foreign_keys = OFF` band-aid (retired by introducing
       -- the stub-row pattern in WorkflowService.StartRunAsync). Defect inserts
       -- now succeed under FK enforcement because the parent run_summary row
       -- exists before any defect is persisted.
       INSERT INTO schema_version (version, applied_at) VALUES (4, datetime('now'));

5. SqliteRunHistoryStore.SaveAsync — extend INSERT to include new columns:
   - INSERT INTO run_summaries (..., wafer_id, lot_id) VALUES (..., @WaferId, @LotId)
   - Map RunSummary.WaferId / LotId in the parameter object.
   - Update the SELECT statements (LoadRecentAsync / LoadPageAsync / GetAsync) to
     include wafer_id and lot_id columns; map back via DTO + MapRow.
   - DefectRow's RunSummaryRow DTO gains WaferId, LotId nullable string fields.

6. SqliteDefectStore.OpenAsync — REMOVE the explicit PRAGMA:
   Before:
       await conn.ExecuteAsync("PRAGMA foreign_keys = OFF");
   After: (delete the line entirely; default Foreign Keys=True is correct)
   Also keep `Cache=Shared` on the connection string — pooling is fine because
   every store now expects FK enforcement.

7. ICassetteScheduler (src/InspectionPrototype.Application/Abstractions/ICassetteScheduler.cs):
   See spec §"ICassetteScheduler + SimulatedCassetteScheduler" for the interface.
   5 methods + 1 event + 1 property.

8. SimulatedCassetteScheduler (src/InspectionPrototype.Infrastructure/Simulator/SimulatedCassetteScheduler.cs):
   - Constructor: IWorkflowService, IRunHistoryStore, ISimulatorProfileProvider,
     IAppStateStore, ILogger<SimulatedCassetteScheduler>
   - LoadCassetteAsync: builds CassetteState with TotalSlots slots, populates
     each slot's WaferId via WaferId.Generate(lotId, i); updates AppState.Cassette.
     Idempotent: if AppState.Cassette is already non-null, throws InvalidOperationException
     (or logs Warning + returns; pick one and document).
   - StartCassetteRunAsync: Task.Run(() => RunCassetteLoopAsync(ct)). Returns
     Task.CompletedTask immediately (fire-and-forget); the loop runs on the
     background.
   - RunCassetteLoopAsync: see spec §"Cassette flow" pseudo-code. Per slot:
       Loading → Task.Delay(profile.WaferLoadMs)
       Aligning → Task.Delay(profile.WaferAlignMs)
       Running → call _workflow.StartRunAsync(slot.WaferId, cassette.LotId);
                 await wait-for-terminal-state via polling AppState.WorkflowState
                 (or subscribe to StateChanged)
       Unloading → Task.Delay(profile.WaferUnloadMs)
       Advance to next slot
   - Each phase transition fires CassetteStateChanged AND updates
     AppState.Cassette via _store.Update.
   - Single-wafer faults: log Warning + advance to next slot. Cassette keeps going.
   - RequestCassetteStop: sets a flag; loop exits after current phase finishes.
   - AbortCassetteAsync: cancels the loop's CTS immediately.
   - UnloadCassetteAsync: sets AppState.Cassette = null; only allowed when
     loop is not running (CurrentPhase ∈ {Idle, Complete}).

9. WorkflowService.StartRunAsync overload (Application/Services/WorkflowService.cs):
   Add an overload:
       public Task StartRunAsync(WaferId? waferId, LotId? lotId)
   The existing parameterless StartRunAsync delegates to this overload with
   nulls.

   In the overload, AFTER existing guard checks, BEFORE starting the run loop:
       var stubSummary = new RunSummary(
           RunId: runId,
           RecipeName: recipe.Name,
           StartedAtUtc: startTime,
           EndedAtUtc: DateTimeOffset.MinValue,           // placeholder; updated at run end
           TerminalStatus: RunTerminalStatus.Pending,     // <-- new
           DefectCount: 0,
           MajorAlarms: [],
           CompletedScanPoints: 0,
           TotalScanPoints: recipe.ScanPoints.Count,
           SimulatorProfileName: state.SelectedSimulatorProfile.Name,
           DefectsMinor: 0,
           DefectsMajor: 0,
           DefectsCritical: 0,
           WaferId: waferId?.Value,
           LotId: lotId?.Value);

       _ = SafeRunHistoryPersistAsync(() => _historyStore.SaveAsync(stubSummary));

   Implement SafeRunHistoryPersistAsync as a private method mirroring
   SafeAlarmHistoryAsync — try/await/catch Warning.

   At run end (existing finally block), the existing _historyStore.SaveAsync(summary)
   call replaces the stub row via INSERT OR REPLACE. The terminal RunSummary
   carries WaferId and LotId from the constructor arguments.

10. AppState evolution (Application/State/AppState.cs):
    Add at end of record:
        CassetteState? Cassette = null
    Update AppState.Initial accordingly.

11. SimulatorProfile fields (Application/State/SimulatorProfile.cs and
    Infrastructure/Simulator/SimulatorProfilesOptions.cs):
    Add three new fields:
        int WaferLoadMs    = 2000
        int WaferAlignMs   = 1500
        int WaferUnloadMs  = 1500
    Update validators to reject values outside [100, 60000].

12. DI wiring (InfrastructureServiceCollectionExtensions):
    Add right after SqliteDefectStore registration:
        services.AddSingleton<ICassetteScheduler, SimulatedCassetteScheduler>();

13. Tests:

    - MigrationRunnerM003M004Tests:
        * AppliesM003OnVersion2Database: starting from M001+M002 state; assert
          schema_version contains {1, 2, 3}; PRAGMA table_info(run_summaries)
          returns wafer_id and lot_id columns.
        * AppliesM004OnVersion3Database: assert {1, 2, 3, 4}.
        * IsIdempotentOnVersion4Database: re-run; version count stays at {1..4}.

    - SqliteDefectStoreFkIntegrationTests:
        * DefectInsert_SucceedsAfterRunSummaryStubPersisted:
          1. Persist stub run_summary (TerminalStatus = Pending).
          2. Persist defect referencing the same run_id.
          3. Assert no exception; SELECT verifies the row.
        * DefectInsert_FailsWithFkError_WhenParentRowMissing:
          1. Skip the stub persistence step.
          2. Persist defect referencing a non-existent run_id.
          3. Assert SqliteException with FK-error code (Code 19, ResultCode 787 NotNull or similar).
        * NoOrphanDefectsAfterCassetteRun:
          1. Run a small simulated cassette (3 wafers).
          2. SELECT COUNT(*) FROM defects d LEFT JOIN run_summaries r
             ON d.run_id = r.run_id WHERE r.run_id IS NULL.
          3. Assert count = 0.

    - WorkflowServiceStubRowTests:
        * StartRunAsync_PersistsStubRow_WithPendingTerminalStatus:
          arrange a recording IRunHistoryStore; call StartRunAsync; verify
          SaveAsync was called once with TerminalStatus = Pending; verify
          stub WaferId/LotId match the input args.
        * RunComplete_UpdatesRowToTerminalStatus:
          run a fast simulated run; verify two SaveAsync calls (stub + terminal);
          terminal call has the actual TerminalStatus.

    - SimulatedCassetteSchedulerTests:
        * LoadCassette_PopulatesAllSlots: 25 slots, all WaferIds set per pattern.
        * LoadCassette_RejectsIfAlreadyLoaded.
        * StartCassetteRun_DrivesAllPhasesPerSlot: use very-small Wafer*Ms
          (50ms each) and a 3-slot cassette; assert phase transitions
          captured in CassetteStateChanged event log: Loading→Aligning→
          Running→Unloading×3.
        * SingleWaferFault_AdvancesToNextSlot: stub IWorkflowService that
          returns a Faulted run on slot 1; assert cassette continues to
          slot 2.
        * StopRequest_CompletesCurrentPhaseAndHalts.
        * AbortRequest_CancelsImmediately.
        * UnloadCassette_ResetsAppState.

    - SimulatorProfilesOptionsValidatorTests (extend existing):
        * Reject WaferLoadMs < 100, > 60000; same for Align/Unload.

## Constraints

- Do NOT add UI changes — Pass 2 is the cassette UI.
- Do NOT skip the FK orphan-row integrity test — that's the verification of
  the band-aid retirement.
- Do NOT change the existing direct-mode WorkflowService path beyond adding
  the stub-row insert + the WaferId/LotId overload. Direct-mode runs
  (legacy operator-driven) must still work without a cassette loaded.
- Do NOT introduce Cassette-related code in the Domain layer beyond the
  pure value types (WaferId, LotId, etc.). Scheduler logic stays in
  Application + Infrastructure.
- The stub-row's EndedAtUtc value can be DateTimeOffset.MinValue or
  StartedAtUtc itself — pick the convention that doesn't violate any
  validator. INSERT OR REPLACE at run end fixes the value.
- RunTerminalStatus.Pending is added as the FIRST enum member so
  default(RunTerminalStatus) = Pending. This is a small but load-bearing
  detail: if a stub row is queried before run-end, callers reading the
  enum get a meaningful default.

## Verification before you report done

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

Manual smoke (no UI yet, but persistence + scheduler work):
  - Delete inspection.db.
  - Launch app; observe migrations M001+M002+M003+M004 applied in logs.
  - Use a debugger or scripted call to ICassetteScheduler.LoadCassetteAsync +
    StartCassetteRunAsync with a 3-slot cassette and small Wafer*Ms.
  - Observe AppState.Cassette state transitions; verify defects table
    contains rows for each wafer; confirm FK enforcement is live by
    querying PRAGMA foreign_keys.

## Report format when finished

- files created / modified / deleted
- confirmation tests pass
- the FK orphan-row count from SqliteDefectStoreFkIntegrationTests' simulated
  cassette run (should be 0)
- a single commit hash
- commit message: "feat(workflow): add cassette scheduler + stub-row pattern; retire FK band-aid (pass 1/3 of TASK-3.2)"

Pass 2 — Cassette UI

You are implementing Pass 2 of TASK-3.2. Pass 1 (cassette scheduler +
stub-row pattern + FK retirement + AppState.Cassette) is merged. This pass
adds the CassetteStatusPanel WPF UserControl, MainViewModel projection,
and 4 new MainWindow buttons.

NO captures (Pass 3).

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.2-cassette-cadence.md            (criterion 11)
- src/InspectionPrototype.Application/State/AppState.cs   (Pass 1 — Cassette field)
- src/InspectionPrototype.Domain/Contracts/CassetteState.cs (Pass 1)
- src/InspectionPrototype.Presentation/ViewModels/MainViewModel.cs
- src/InspectionPrototype.Presentation/Controls/WaferMapView.xaml
  (Pattern: similar UserControl from SLICE-3.1)
- src/InspectionPrototype.App/MainWindow.xaml          (where the new buttons + panel slot in)
- tests/InspectionPrototype.Tests/MainWindowAutomationIdRegressionTests.cs

Pass 1 must be merged. Confirm: AppState.Cassette field exists,
SimulatedCassetteScheduler exists.

## Scope of this pass

Cassette UI panel + ViewModel projection + 4 new MainWindow buttons +
AutomationIds + tests. NO captures.

## Deliverables

1. CassetteSlotViewModel
   (src/InspectionPrototype.Presentation/ViewModels/CassetteSlotViewModel.cs):
   public sealed class CassetteSlotViewModel
   {
       public int    SlotIndex     { get; init; }
       public string WaferIdText   { get; init; }
       public string DisplayState  { get; init; }   // "Empty" / "Pending" / "Done" / "Current"
       public Brush  StateBrush    { get; init; }   // gray / blue / green / yellow
   }

2. MainViewModel updates:
   - Add: public ObservableCollection<CassetteSlotViewModel> CassetteSlots { get; } = new();
   - Add: public string? CassetteLotIdText { get; private set; }
   - Add: public string  CassetteCurrentSlotText { get; private set; } = "—";
   - Add: public string  CassetteCurrentPhaseText { get; private set; } = "Idle";
   - Add: public bool    IsCassetteLoaded { get; private set; }
   - In Project(state):
       if (state.Cassette is { } cassette) {
           IsCassetteLoaded = true;
           CassetteLotIdText = cassette.LotId.Value;
           CassetteCurrentSlotText = $"{cassette.CurrentSlotIndex + 1} / {cassette.TotalSlots}";
           CassetteCurrentPhaseText = cassette.CurrentPhase.ToString();
           DiffCassetteSlots(cassette);
       } else {
           IsCassetteLoaded = false;
           CassetteLotIdText = null;
           CassetteSlots.Clear();
       }
   - DiffCassetteSlots: similar diff pattern as RecentDefects, prepend-by-index;
     keep CassetteSlots size = cassette.TotalSlots; update DisplayState/StateBrush
     for each slot based on whether SlotIndex < CurrentSlotIndex (Done),
     == CurrentSlotIndex (Current), or > CurrentSlotIndex (Pending).

3. CassetteStatusPanel UserControl
   (src/InspectionPrototype.Presentation/Controls/CassetteStatusPanel.xaml + .xaml.cs):

   <UserControl
       x:Class="InspectionPrototype.Presentation.Controls.CassetteStatusPanel"
       xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
       xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
       Width="280">
     <StackPanel
         AutomationProperties.AutomationId="CassetteStatusPanel"
         Visibility="{Binding IsCassetteLoaded, Converter={StaticResource BoolToVisibility}}">
       <TextBlock>Lot: <Run Text="{Binding CassetteLotIdText}" /></TextBlock>
       <TextBlock>
         Wafer:
         <Run AutomationProperties.AutomationId="CassetteCurrentSlotText"
              Text="{Binding CassetteCurrentSlotText}" />
       </TextBlock>
       <TextBlock>
         Phase:
         <Run AutomationProperties.AutomationId="CassetteCurrentPhaseText"
              Text="{Binding CassetteCurrentPhaseText}" />
       </TextBlock>
       <ItemsControl ItemsSource="{Binding CassetteSlots}" Margin="0,10">
         <ItemsControl.ItemsPanel>
           <ItemsPanelTemplate>
             <UniformGrid Columns="5" />   <!-- 25 slots = 5 × 5 grid -->
           </ItemsPanelTemplate>
         </ItemsControl.ItemsPanel>
         <ItemsControl.ItemTemplate>
           <DataTemplate>
             <Border BorderBrush="Black" BorderThickness="1" Margin="2"
                     Width="40" Height="30" Background="{Binding StateBrush}">
               <TextBlock Text="{Binding SlotIndex}"
                          HorizontalAlignment="Center" VerticalAlignment="Center" />
             </Border>
           </DataTemplate>
         </ItemsControl.ItemTemplate>
       </ItemsControl>
     </StackPanel>
   </UserControl>

4. MainWindow.xaml integration:
   - Add 4 new buttons (alongside existing buttons, in their natural place):
       <Button AutomationProperties.AutomationId="LoadCassetteButton"
               Content="Load Cassette"
               Command="{Binding LoadCassetteCommand}" />
       <Button AutomationProperties.AutomationId="StartCassetteRunButton"
               Content="Start Cassette Run"
               Command="{Binding StartCassetteRunCommand}" />
       <Button AutomationProperties.AutomationId="StopCassetteButton"
               Content="Stop Cassette"
               Command="{Binding StopCassetteCommand}" />
       <Button AutomationProperties.AutomationId="UnloadCassetteButton"
               Content="Unload Cassette"
               Command="{Binding UnloadCassetteCommand}" />
   - Add the CassetteStatusPanel control:
       <controls:CassetteStatusPanel />
     placed near the WaferMapView panel.

5. MainViewModel commands:
   - LoadCassetteCommand: calls _scheduler.LoadCassetteAsync(LotId.GenerateForToday())
   - StartCassetteRunCommand: calls _scheduler.StartCassetteRunAsync()
   - StopCassetteCommand: calls _scheduler.RequestCassetteStop()
   - UnloadCassetteCommand: calls _scheduler.UnloadCassetteAsync()
   - Each command's CanExecute reflects cassette state (Load only when no
     cassette loaded; Start only when loaded and idle; Stop only when running;
     Unload only when not running).

6. AutomationId regression test:
   Add to MainWindowAutomationIdRegressionTests.cs:
       "CassetteStatusPanel",
       "CassetteCurrentSlotText",
       "CassetteCurrentPhaseText",
       "LoadCassetteButton",
       "StartCassetteRunButton",
       "StopCassetteButton",
       "UnloadCassetteButton",

7. Tests under tests/InspectionPrototype.Tests/:

   - MainViewModelCassetteProjectionTests:
       * NoCassette_ProducesEmptyCollection_AndIsLoadedFalse.
       * LoadedCassette_PopulatesSlotsAndProgress.
       * SlotIndexAdvances_UpdatesCurrentSlotText.
       * PhaseTransition_UpdatesCurrentPhaseText.
       * UnloadedCassette_ClearsCollection.

   - (Optional) acceptance test in InspectionPrototype.AcceptanceTests/:
       * smoke FlaUI test that asserts CassetteStatusPanel is visible after
         clicking LoadCassetteButton.

## Constraints

- Do NOT add value converters in XAML beyond a generic BoolToVisibility
  (probably already exists in the project; reuse).
- Do NOT clear-and-rebuild CassetteSlots on every state change — diff
  pattern preserves visual identity. With 25 slots, the perf impact is
  small but the pattern matters.
- Do NOT change any non-UI Pass 1 code.

## Verification before you report done

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

Manual smoke:
  - Launch app; observe LoadCassetteButton enabled, others disabled.
  - Click Load Cassette; observe 25-slot grid appears; LotId populated.
  - Click Start Cassette Run; observe slot grid colors transition (Pending →
    Current → Done) as wafers progress.
  - Click Stop Cassette mid-run; observe scheduler completes current phase
    and halts.
  - Click Unload Cassette; observe panel hides.

## Report format when finished

- files created / modified
- confirmation all tests pass
- a screenshot or description of the cassette panel mid-run
- a single commit hash
- commit message: "feat(ui): add cassette status panel + 4 cassette buttons (pass 2/3 of TASK-3.2)"

Pass 3 — 25-wafer cassette capture under Soak8h + row block + Phase 2 trigger assessment + runbook §5.4

You are implementing Pass 3 of TASK-3.2, the final pass. Passes 1 and 2
are merged. This pass runs a 25-wafer cassette capture under Soak8h
profile, appends the slice-3-2-cassette-cadence row to phase-3-measurements.md,
writes runbook §5.4, and applies the SLICE-2.0 decision rubric.

NO code changes — Passes 1 and 2 own those.

## Authoritative references

Read these before making changes:
- docs/specs/SLICE-3.2-cassette-cadence.md       (criteria 12, 13, 14, 15, 16)
- docs/runbook/capturing-measurements.md          (§5.1–§5.3 — mirror for §5.4)
- docs/reviews/phase-3-measurements.md            (slice-3-3 + slice-3-1 row formats)
- tools/Capture-Measurements.ps1
- tools/MeasurementExtraction.psm1

## Scope of this pass

Capture, two new MeasurementExtraction helpers, row block append, runbook
§5.4, Phase 2 trigger assessment, session-handoff updates. No code.

## Deliverables

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

   - Get-WafersCompletedCount -DatabasePath $db -LotId $lotId:
       SELECT COUNT(*) FROM run_summaries
       WHERE lot_id = @lotId AND terminal_status != 'Pending'
       Return integer count.

   - Get-CassetteWallClockSeconds -DatabasePath $db -LotId $lotId:
       SELECT
         (julianday(MAX(ended_at_utc)) - julianday(MIN(started_at_utc))) * 86400.0
       FROM run_summaries
       WHERE lot_id = @lotId AND terminal_status != 'Pending'
       Return seconds (real number).

   Update ConvertTo-MeasurementRow to optionally accept -LotId; when set,
   append two rows to the markdown:
       | wafers.completed (count)        | <Get-WafersCompletedCount>      |
       | cassette.wall-clock (s)         | <Get-CassetteWallClockSeconds>  |

2. tests/Tools/MeasurementExtraction.Tests.ps1:
   Two new Pester Describe blocks with synthetic SQLite databases. Verify
   correct count + wall-clock math; verify $null for missing lot_id.

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

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

   # Step 2: launch app once to apply migrations M001+M002+M003+M004
   # then close.

   # Step 3: capture before-snapshot
   Copy-Item $dbPath "$env:TEMP\inspection.db.before-$date"

   # Step 4: launch app, switch to Soak8h profile, click:
   #         Connect → Home → Load Cassette → Start Cassette Run
   # The 25-wafer cassette runs ~14-15 min under Soak8h.
   # IMPORTANT: this is NOT a Capture-Measurements.ps1 invocation; the
   # cassette scheduler doesn't go through MultiTagSoakFlaUi. Run dotnet-
   # counters collect manually for the duration:
   #   Start-Process dotnet-counters -ArgumentList "collect --process-id $(Get-Process InspectionPrototype.App).Id --output docs/captures/slice-3-2-cassette-cadence-$date.csv --refresh-interval 1 --counters InspectionPrototype,System.Runtime --duration 00:20:00" -NoNewWindow

   # Wait for cassette to complete (observe CassetteStatusPanel — phase = Complete);
   # click Unload Cassette → Disconnect → close app.

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

   # Step 6: extract the LotId from the database
   $lotId = (sqlite3 $dbPath "SELECT lot_id FROM run_summaries WHERE lot_id IS NOT NULL ORDER BY started_at_utc DESC LIMIT 1;")

   # Step 7: extract row block via PowerShell
   Import-Module ./tools/MeasurementExtraction.psm1
   ConvertTo-MeasurementRow `
     -CsvPath "docs/captures/slice-3-2-cassette-cadence-$date.csv" `
     -SliceTag slice-3-2-cassette-cadence `
     -Scenario CassetteCadence `
     -CommitHash $(git rev-parse --short HEAD) `
     -Date $date `
     -DatabaseBefore "$env:TEMP\inspection.db.before-$date" `
     -DatabaseAfter $dbPath `
     -LotId $lotId

   Verify:
       * exit code 0; CSV span ≥ 800 s (14 min minimum)
       * Get-WafersCompletedCount returns 25
       * Get-CassetteWallClockSeconds returns ≤ 1200 s (20 min ceiling)
       * No SqliteException Error 19 entries in app log
       * Reproducibility: first 26 metrics within ±15% of slice-3-1 baseline
         (slightly looser bound to absorb cassette-mode overhead)

4. Append row block to docs/reviews/phase-3-measurements.md:
   Standard 32-metric set (from slice-3-1 baseline) + 2 new cassette-specific
   metrics. Notes section MUST include:
   (a) Why slice-3-1 is the baseline (most recent Phase 3 row).
   (b) Cassette criterion satisfaction: 25 wafers completed in <20 min wall-clock.
   (c) FK band-aid retirement evidence: 0 SqliteException Error 19 entries
       in the app log; PRAGMA foreign_keys reports 1 on a fresh connection;
       0 orphan rows from SELECT COUNT(*) FROM defects d LEFT JOIN
       run_summaries r ON d.run_id = r.run_id WHERE r.run_id IS NULL.
   (d) Phase 2 trigger assessment per the SLICE-2.0 rubric. Cassette mode
       drives ~25× more workflow state-transition rate than single-run
       mode; expect store.update rate to climb. If alloc share crosses
       10% or lock-wait p95 crosses 100 µs, open the relevant 2.x slice.
       Otherwise document Phase 2 stays deferred against five measurement
       points now.
   (e) What surprised, if anything.

5. Add §5.4 to docs/runbook/capturing-measurements.md:
   - Title: "### 5.4 Cassette cadence capture — SLICE-3.2, 25-wafer Soak8h"
   - Procedure: manual cassette-driven (not the MultiTagSoakFlaUi orchestrator)
   - Sanity checks: 25 wafers completed, wall-clock under 20 min, 0 FK errors.
   - Phase 2 trigger assessment as the row's expected output.

6. Update CLAUDE.md "Current position":
   - Phase 3: SLICE-3.2 Completed; FK band-aid retired; cassette mode
     functional; Phase 2 trigger decision: <outcome>.
   - Last completed action: TASK-3.2 Pass 3 — 25-wafer cassette capture;
     wall-clock <X> s; <Phase 2 outcome>; commit <hash>.
   - Next action: SLICE-3.4 (identity + audit) or SLICE-4.1 (real SDK swap)
     depending on prioritization.

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

## Constraints

- Do NOT make any code changes.
- Do NOT skip the 25-wafer cassette criterion. If the cassette doesn't
  complete in 20 min wall-clock or fewer than 25 wafers persist, the
  slice's exit gate is unmet — investigate before proceeding to row edit.
- Do NOT pre-decide the Phase 2 trigger outcome.

## Verification before you report done

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

Plus:
  - docs/captures/slice-3-2-cassette-cadence-<date>.csv committed
  - docs/reviews/phase-3-measurements.md has the row with all 34 metrics
  - SQL verification: 25 rows in run_summaries with the captured lot_id;
    zero rows in any "orphan" check
  - CLAUDE.md current-position reflects SLICE-3.2 closure

## Report format when finished

- files created / modified
- the captured row block (markdown table) included verbatim
- 25-wafer cassette wall-clock + Phase 2 trigger assessment outcome
- 0 / non-zero counts of FK errors and orphan rows
- a single commit hash
- commit message: "feat(measurements): SLICE-3.2 row + cassette cadence verified; FK band-aid retired (pass 3/3 of TASK-3.2)"

Operator notes

  • One pass per Copilot session. Same protocol as TASK-1.4 / 2.0 / 3.3 / 3.1.
  • Pass 1's stub-row + FK retirement is the load-bearing architectural fix. The RunTerminalStatus.Pending enum value, the stub _historyStore.SaveAsync at run start, and the SqliteDefectStore.OpenAsync PRAGMA removal are coupled — all three must land in the same commit, otherwise FK violations re-appear. The SqliteDefectStoreFkIntegrationTests.NoOrphanDefectsAfterCassetteRun test is the regression gate; if it ever fails, the band-aid is back.
  • Pass 1's RunTerminalStatus.Pending placement. Adding it as the FIRST enum value so default(RunTerminalStatus) = Pending matters because reducer code occasionally constructs partial RunSummary records during transitions; defaulting to Pending avoids "default(enum) = Completed" bugs that would silently report stub rows as terminal.
  • Pass 2's MainViewModel cassette projection mirrors the SLICE-3.1 RecentDefects diff pattern. With 25 slots, the perf impact is small, but the pattern matters because it preserves visual identity for unchanged slots — a Critical-severity slot doesn't visually flicker as the cassette progresses.
  • Pass 3's capture is manual, not orchestrated. The cassette scheduler isn't reachable via MultiTagSoakFlaUi's scenario; capture runs by clicking the new buttons in the UI while dotnet-counters collect runs alongside. Document this in §5.4 explicitly so future captures don't try to use the FlaUI orchestrator.
  • Phase 2 trigger watch is critical for this row. Cassette mode is the highest workflow-state-transition rate the prototype has yet measured (each wafer drives ~5 phase transitions plus ~10 store updates from the run loop, × 25 wafers = ~375 state transitions in ~14 min). If alloc share or lock-wait p95 ever crosses thresholds, this is the most likely capture to surface it. Apply the rubric mechanically.
  • Update the index files only at the end of the phase, not per-slice. SLICE-3.4 (identity + audit) is the next slice; the index sweep happens after 3.4 lands.

Docs-first project memory for AI-assisted implementation.