# Solution Design Document: Deterministic Scheduling Solver

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** (7/7 ADRs confirmed 2026-02-18)
- [x] **Codex SDD review completed** (2026-02-17: 5 blockers resolved, 6 important items fixed)
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

**CON-1: Technology Stack**
- PHP 8.x with Slim 2.6.2 framework (no Slim 4 migration)
- Twig 1.44.8 templates with Handlebars for client-side rendering (`{% raw %}` blocks)
- MySQL multi-database architecture (central `kiosk_buykiosk`/`kiosk_users` + per-store `kiosk_{typeNum}`)
- Redis for caching, queue backend, and abort signaling
- Ably for real-time browser notifications
- Bootstrap 5.3.3 CSS framework with custom design tokens
- Python 3.9+ required on server for OR-Tools CP-SAT solver (new dependency)
- OpenAI API for LLM explanation/suggestion features (reuses existing `OpenAIClient`)

**CON-2: Operational Constraints**
- TaskEngine for async solver processing (high priority queue, same as AI Scheduler)
- Math Optimizer has NO rate limit (zero API cost). AI Scheduler keeps its 5/pay-week limit.
- Must use global `kiosk_users.users` + `kiosk_users.userStoreAssignments` for employee data (deprecated store-level `employees` table NOT used)
- Must integrate with existing scheduling infrastructure: `scheduleShifts`, `scheduleAvailability`, `scheduleTimeOffRequests`, `schedulePositions`
- Must reuse existing `aiScheduleSuggestions` table (with new `solverType` column) and `aiScheduleJobs` table
- Dual notification: Ably real-time for waiting users, optional email for offline users
- Python subprocess timeout: 90 seconds max (60s solve + 30s buffer)
- No new Docker services; Python runs as subprocess from PHP via `proc_open()`

**CON-3: Security & Auth**
- Session-based authentication via UserFrosting
- Store access via `checkStoreGroup($typeNum)` pattern
- Feature permissions via `checkAccess('uri_schedule_ai')` + premium scheduling gate (Spec 035)
- CSRF protection required for all POST/PUT/DELETE operations
- OpenAI API key stored in `.env`, never exposed to client
- Python subprocess runs with same user as PHP worker process, no elevated privileges

**CON-4: Compatibility with Spec 026**
- Same 6 optimization priorities from `AiDefaultPrefsService::AVAILABLE_PRIORITIES`
- Same 11 hard constraints from `AiPromptBuilder` and `AiScheduleOptimizer.parseAndValidateAssignments()`
- Same preview/accept/reject UI flow
- Same Ably channel pattern for completion notifications
- Fix: Controller priority validation (line 208) must accept all 6 priorities, not just 4

---

## Implementation Context

### Required Context Sources

```yaml
# PRD for this feature
- doc: docs/specs/038-deterministic-scheduling-solver/product-requirements.md
  relevance: CRITICAL
  why: "13 features with acceptance criteria defining what to build"

# Spec 026 SDD - existing AI scheduling architecture
- doc: docs/specs/026-ai-smart-scheduling/solution-design.md
  relevance: CRITICAL
  why: "Architecture we must integrate with: same data model, suggestion storage, preview UI"

# Core AI scheduling services (reuse patterns)
- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/AiScheduleOptimizer.php
  relevance: CRITICAL
  why: "8-step algorithm and data gathering methods we share"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/AiPromptBuilder.php
  relevance: CRITICAL
  sections: [RESPONSE_SCHEMA, constraints 1-11, PRIORITY_LABELS]
  why: "Constraint definitions and priority system we must match"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/AiDefaultPrefsService.php
  relevance: CRITICAL
  sections: [AVAILABLE_PRIORITIES constant]
  why: "6 priorities: position_coverage, labor_cost, hours_fairness, seniority, minimize_overtime, employee_preferences"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Controllers/AiSchedulingApiController.php
  relevance: HIGH
  sections: [dispatchGeneration() lines 181-337, priority validation line 208]
  why: "API patterns to extend; line 208 bug to fix (only accepts 4 of 6 priorities)"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Jobs/AiScheduleGenerationJob.php
  relevance: HIGH
  why: "TaskEngine job pattern to replicate for solver job"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Models/AiSuggestion.php
  relevance: HIGH
  why: "Suggestion model we extend with solverType discrimination"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Repositories/AiSuggestionRepository.php
  relevance: HIGH
  why: "Repository we extend for solver-type filtering"

# TaskEngine infrastructure
- file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/BaseJob.php
  relevance: HIGH
  why: "Base class for the new SolverScheduleGenerationJob"

- file: userfrosting/src/BuyerKiosk/TaskEngine/Application/JobDispatcher.php
  relevance: HIGH
  why: "Dispatch pattern: dispatchManual() for user-triggered jobs"

- file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/JobResult.php
  relevance: MEDIUM
  why: "JobResult::success/failure pattern for solver job"

# OpenAI client (reuse for LLM explanations)
- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/OpenAIClient.php
  relevance: HIGH
  sections: [chat() method, MODEL_FALLBACK_CHAIN]
  why: "Reuse for explanation generation after solver completes"

# Conflict detection (overnight shift handling)
- file: userfrosting/src/BuyerKiosk/Scheduling/Services/ConflictDetectionService.php
  relevance: MEDIUM
  sections: [checkAvailability overnight logic lines 267-295]
  why: "Overnight shift detection pattern to replicate in solver"

# Scheduling data model
- file: userfrosting/src/BuyerKiosk/Scheduling/Models/Shift.php
  relevance: HIGH
  why: "Shift entity model"

# Migration system
- file: userfrosting/migrations/methods/storeMigration.php
  relevance: MEDIUM
  why: "Migration runner pattern for schema changes"

# Premium scheduling gate
- file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/SchedulingPageController.php
  relevance: MEDIUM
  sections: [isPremiumActive check lines 94-110]
  why: "Premium gating pattern for Math Optimizer"

# Frontend AI scheduling UI
- file: public_html/js/admin/scheduling/ai-scheduling.js
  relevance: HIGH
  why: "UI orchestration code to extend with solver type selector"

# OR-Tools documentation
- url: https://developers.google.com/optimization/cp/cp_solver
  relevance: HIGH
  sections: [CP-SAT solver, scheduling examples]
  why: "Primary solver engine documentation"

- url: https://developers.google.com/optimization/scheduling/employee_scheduling
  relevance: HIGH
  why: "Google's employee scheduling example with CP-SAT"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing AI Scheduler flow (Spec 026) - all endpoints, UI, behavior unchanged
  - Existing shift model and lifecycle (draft → published → claimed)
  - Template system from Spec 025
  - `aiScheduleSuggestions` table structure (additive changes only)
  - `aiScheduleJobs` table structure (additive changes only)
  - Real-time Ably notification patterns
  - Premium scheduling gate (Spec 035)

- **Can Modify**:
  - `AiSchedulingApiController` - extend for solver type, fix priority validation
  - `aiScheduleSuggestions` table - add `solverType` column
  - `aiScheduleJobs` table - add `solverType` column
  - `ai-scheduling.js` - add solver type selector UI
  - `calendar.html` - minor template changes for solver selection

- **Must Not Touch**:
  - WhenIWork/Homebase provider integrations
  - Timesheet export functionality
  - Mobile API endpoints (`/api/mobile/*`)
  - Existing `AiScheduleOptimizer` (no modification, solver is a separate service)
  - Existing `AiPromptBuilder` (no modification)
  - Clock-in/punch functionality

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Manager[Store Manager] --> UI[Scheduling Calendar UI]
    UI --> API[Scheduling API Controller]

    API -->|dispatch solver job| Queue[(Redis Queue - high)]
    Queue --> TaskEngine[TaskEngine Workers]

    TaskEngine -->|Math Optimizer| PythonSolver[Python OR-Tools Subprocess]
    TaskEngine -->|AI Scheduler| OpenAI[OpenAI ChatGPT API]
    TaskEngine -->|LLM Explanations| OpenAI

    PythonSolver -->|JSON result| TaskEngine
    TaskEngine --> StoreDB[(Store Database)]
    TaskEngine --> CentralDB[(Central Database)]

    TaskEngine -->|job complete| Ably[Ably Real-time]
    TaskEngine -->|optional| Email[Email Service]
    Ably -->|real-time update| UI

    API --> Redis[(Redis Cache)]

    subgraph Store Database
        StoreDB --> Shifts[scheduleShifts]
        StoreDB --> Availability[scheduleAvailability]
        StoreDB --> TimeOff[scheduleTimeOffRequests]
        StoreDB --> Positions[schedulePositions]
        StoreDB --> AISuggestions[aiScheduleSuggestions]
        StoreDB --> AIJobs[aiScheduleJobs]
    end

    subgraph Central Database
        CentralDB --> Users[kiosk_users.users]
        CentralDB --> Assignments[kiosk_users.userStoreAssignments]
        CentralDB --> PayRates[kiosk_users.userPayRates]
        CentralDB --> Stores[kiosk_buykiosk.stores]
    end
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Web Calendar UI"
    type: HTTPS
    format: REST JSON
    authentication: Session (UserFrosting)
    endpoints:
      - POST /:typeNum/api/schedule/ai/generate       # Extended: accepts solverType
      - GET  /:typeNum/api/schedule/ai/job/:jobId      # Unchanged
      - POST /:typeNum/api/schedule/ai/job/:jobId/cancel # Unchanged
      - GET  /:typeNum/api/schedule/ai/pending-job     # Unchanged
      - GET  /:typeNum/api/schedule/ai/suggestions/:weekStart  # Extended: filter by solverType
      - GET  /:typeNum/api/schedule/ai/suggestions/detail/:id  # Unchanged
      - GET  /:typeNum/api/schedule/ai/usage           # Extended: shows Math runs info
      - GET  /:typeNum/api/schedule/ai/default-prefs   # Extended: includes lastSolverType
      - POST /:typeNum/api/schedule/ai/apply           # Unchanged
      - POST /:typeNum/api/schedule/ai/dismiss         # Unchanged
      - GET  /:typeNum/api/schedule/solver/health       # NEW: Check Python availability
      - GET  /:typeNum/api/schedule/solver/compare/:weekStart  # NEW: Side-by-side comparison (Feature 8)
      - GET  /:typeNum/api/schedule/solver/resolutions/:suggestionId  # NEW: Infeasibility resolutions (Feature 6)
      - POST /:typeNum/api/schedule/solver/rerun           # NEW: Re-run with adjusted constraints (Feature 6)
      - GET  /:typeNum/api/schedule/solver/why-not          # NEW: Why not employee query (Feature 9)
    data_flow: "Manager selects solver type, configures priorities, triggers generation, reviews/applies, compares results"

# Outbound Interfaces
outbound:
  - name: "Python OR-Tools Subprocess"
    type: stdin/stdout pipe via proc_open()
    format: JSON
    authentication: None (same-process, same-user)
    data_flow: "PHP serializes problem as JSON → stdin → Python solver → JSON result → stdout"
    criticality: HIGH
    timeout: 90 seconds

  - name: "OpenAI ChatGPT API (reused)"
    type: HTTPS
    format: REST JSON
    authentication: Bearer token (API key from .env)
    data_flow: "Solver results → LLM explanation prompt → natural language explanations"
    criticality: MEDIUM (graceful degradation when unavailable)

  - name: "Ably Real-time (reused)"
    type: HTTPS/WebSocket
    format: JSON event
    authentication: API key from .env
    data_flow: "Job completion notification → browser update"
    criticality: MEDIUM

# Data Interfaces
data:
  - name: "Store Database (per-store)"
    type: MySQL (MariaDB)
    connection: PDO via dbConnectByName()
    data_flow: "Read shifts/availability/time-off; Write suggestions/jobs"

  - name: "Central Database (kiosk_users)"
    type: MySQL (MariaDB)
    connection: PDO via dbConnectByName('kiosk_users')
    data_flow: "Read employees, roles, pay rates, store assignments"

  - name: "Redis Cache"
    type: Redis
    connection: Predis client
    data_flow: "Cache suggestions, queue jobs, abort signals"
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit                    # Run all unit tests
./test.sh --testsuite integration             # Run integration tests
cd userfrosting && ./vendor/bin/phpunit --filter "Solver"  # Run solver-specific tests

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Scheduling/

# CSS Build (if UI changes)
php userfrosting/conductor build-css --minify

# Database Migrations
php userfrosting/conductor run

# TaskEngine
php userfrosting/bin/task worker:start --queues=high,default,low
php userfrosting/bin/task job:list
php userfrosting/bin/task job:dispatch solver-schedule-generation --store=ou00

# Python Solver Setup
pip3 install -r userfrosting/solver/requirements.txt
python3 userfrosting/solver/schedule_solver.py --test  # Self-test mode

# Deploy
./deploy.sh
```

---

## Solution Strategy

- **Architecture Pattern**: Extension of existing Spec 026 layered architecture. The deterministic solver is a **parallel processing strategy** that shares the same data gathering, suggestion storage, preview UI, and notification infrastructure. The only new component is the Python subprocess and a PHP orchestration service.

- **Integration Approach**: Minimal-touch integration. We add a `solverType` discriminator to existing tables and extend the controller to route generation requests to either `AiScheduleOptimizer` (existing) or `SolverScheduleOptimizer` (new). The frontend gets a solver type selector added to the existing config modal. No existing code is modified except the controller's priority validation fix.

- **Justification**: This approach maximizes code reuse (data gathering, suggestion storage, preview UI, Ably notifications all shared), minimizes deployment risk (existing AI Scheduler is untouched), and allows both solvers to evolve independently. The Python subprocess approach avoids adding a microservice while leveraging OR-Tools' mature CP-SAT solver.

- **Key Decisions**:
  1. OR-Tools CP-SAT via Python subprocess (not PHP-native solver)
  2. Shared suggestion storage with `solverType` discriminator (not separate tables)
  3. LLM explanation as post-processing step (solver output → OpenAI → explanations)
  4. Same TaskEngine job pattern as AI Scheduler (not synchronous)
  5. Exponential priority weighting: weight = 10^(6 - rank)

---

## Building Block View

### Components

```mermaid
graph TB
    subgraph Frontend
        CalendarUI[Calendar UI<br/>calendar.html]
        AiSchedulingJS[ai-scheduling.js<br/>Extended with solver selector]
    end

    subgraph API Layer
        Controller[AiSchedulingApiController<br/>Extended: solverType routing]
    end

    subgraph Service Layer
        SolverOptimizer[SolverScheduleOptimizer<br/>NEW: PHP orchestrator]
        AiOptimizer[AiScheduleOptimizer<br/>EXISTING: unchanged]
        DataGatherer[ScheduleDataGatherer<br/>NEW: shared data collection]
        ExplanationService[SolverExplanationService<br/>NEW: LLM explanation generator]
        ScorecardService[ScorecardService<br/>NEW: quality metrics calculator]
        ImprovementService[SolverImprovementSuggestionService<br/>NEW: Feature 5]
        ComparisonService[SolverComparisonService<br/>NEW: Feature 8]
        ResolutionService[InfeasibilityResolutionService<br/>NEW: Feature 6]
        AnalyticsService[ScheduleAnalyticsService<br/>NEW: event tracking]
    end

    subgraph Solver Engine
        PythonBridge[PythonSolverBridge<br/>NEW: proc_open wrapper]
        PythonSolver[schedule_solver.py<br/>NEW: OR-Tools CP-SAT]
    end

    subgraph Job Layer
        SolverJob[SolverScheduleGenerationJob<br/>NEW: TaskEngine job]
        AiJob[AiScheduleGenerationJob<br/>EXISTING: unchanged]
    end

    subgraph Data Layer
        SuggestionRepo[AiSuggestionRepository<br/>Extended: solverType filter]
        JobRepo[AiJobRepository<br/>Extended: solverType field]
        SuggestionCache[AiSuggestionCacheService<br/>EXISTING: unchanged]
    end

    CalendarUI --> AiSchedulingJS
    AiSchedulingJS --> Controller
    Controller -->|solverType=math| SolverJob
    Controller -->|solverType=ai| AiJob
    SolverJob --> SolverOptimizer
    AiJob --> AiOptimizer
    SolverOptimizer --> DataGatherer
    AiOptimizer -.->|shares data patterns| DataGatherer
    SolverOptimizer --> PythonBridge
    PythonBridge --> PythonSolver
    SolverOptimizer --> ExplanationService
    SolverOptimizer --> ScorecardService
    ExplanationService -->|OpenAI| OpenAIClient[OpenAIClient<br/>EXISTING]
    SolverOptimizer --> SuggestionRepo
    SolverOptimizer --> SuggestionCache
```

### Directory Map

**Component**: PHP Backend (new files)
```
userfrosting/src/BuyerKiosk/Scheduling/
├── AiScheduling/
│   ├── Controllers/
│   │   └── AiSchedulingApiController.php       # MODIFY: add solverType routing, fix priority validation
│   ├── Jobs/
│   │   ├── AiScheduleGenerationJob.php         # EXISTING: unchanged
│   │   └── SolverScheduleGenerationJob.php     # NEW: TaskEngine job for solver
│   ├── Models/
│   │   ├── AiSuggestion.php                    # MODIFY: add solverType property
│   │   ├── AiJob.php                           # MODIFY: add solverType property
│   │   ├── SolverResult.php                    # NEW: solver output value object
│   │   ├── SolverScorecard.php                 # NEW: quality metrics value object
│   │   └── SolverConstraintReport.php          # NEW: infeasibility report value object
│   ├── Repositories/
│   │   ├── AiSuggestionRepository.php          # MODIFY: add solverType filtering
│   │   └── AiJobRepository.php                 # MODIFY: add solverType field
│   └── Services/
│       ├── AiScheduleOptimizer.php             # EXISTING: unchanged
│       ├── SolverScheduleOptimizer.php          # NEW: main solver orchestrator
│       ├── ScheduleDataGatherer.php             # NEW: extracted shared data collection
│       ├── SolverExplanationService.php         # NEW: LLM explanation from solver data
│       ├── ScorecardService.php                 # NEW: quality metrics calculator
│       ├── PythonSolverBridge.php               # NEW: proc_open wrapper for Python
│       ├── AiDefaultPrefsService.php            # EXISTING: unchanged
│       ├── OpenAIClient.php                     # EXISTING: unchanged
│       ├── SolverImprovementSuggestionService.php # NEW: LLM improvement suggestions (Feature 5)
│       ├── SolverComparisonService.php           # NEW: side-by-side comparison (Feature 8)
│       ├── InfeasibilityResolutionService.php    # NEW: resolution options + re-run (Feature 6)
│       └── ScheduleAnalyticsService.php          # NEW: analytics event logging
```

**Component**: Python Solver (new directory)
```
userfrosting/solver/
├── schedule_solver.py                           # NEW: Main solver entry point
├── model_builder.py                             # NEW: CP-SAT model construction
├── constraint_builder.py                        # NEW: Constraint definitions (11 hard + soft)
├── objective_builder.py                         # NEW: Weighted objective function
├── result_formatter.py                          # NEW: JSON output formatting
├── infeasibility_analyzer.py                    # NEW: IIS detection
├── requirements.txt                             # NEW: ortools>=9.9
└── tests/
    ├── test_solver.py                           # NEW: Solver unit tests
    ├── test_constraints.py                      # NEW: Constraint validation tests
    └── fixtures/                                # NEW: Test input data
        ├── simple_store.json
        └── complex_store.json
```

**Component**: Frontend (modified files)
```
public_html/js/admin/scheduling/
└── ai-scheduling.js                             # MODIFY: solver type selector, scorecard display

userfrosting/templates/themes/default/scheduling/
└── calendar.html                                # MODIFY: solver selector in config modal
```

**Component**: Migrations
```
userfrosting/migrations/input/
├── 20260218_038_001_add_solver_type_to_suggestions.json  # NEW: ALTER aiScheduleSuggestions
├── 20260218_038_002_add_solver_type_to_jobs.json         # NEW: ALTER aiScheduleJobs
└── 20260218_038_003_fix_priority_validation.json         # NEW: (code change, not DB)
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Schema modifications via migration system

Table: aiScheduleSuggestions (store DB)
  ADD COLUMN: solverType ENUM('ai','math') NOT NULL DEFAULT 'ai' AFTER suggestionId
  ADD COLUMN: solverStatus VARCHAR(20) NULL COMMENT 'OPTIMAL|FEASIBLE|INFEASIBLE' AFTER solverType
  ADD COLUMN: optimalityGap DECIMAL(8,4) NULL COMMENT 'Gap percentage (0=optimal)' AFTER solverStatus
  ADD COLUMN: solverDurationMs INT(10) UNSIGNED NULL COMMENT 'Solver execution time in ms' AFTER optimalityGap
  ADD COLUMN: constraintReport TEXT NULL COMMENT 'JSON: binding constraints, slack, IIS data' AFTER solverDurationMs
  ADD COLUMN: scorecard TEXT NULL COMMENT 'JSON: labor cost, fairness CV, coverage, OT hours' AFTER constraintReport
  ADD COLUMN: improvementSuggestions TEXT NULL COMMENT 'JSON array of LLM improvement suggestions' AFTER scorecard
  ADD INDEX: idx_solver_type (solverType)
  ADD INDEX: idx_week_solver (weekStart, solverType)

Table: aiScheduleJobs (store DB)
  ADD COLUMN: solverType ENUM('ai','math') NOT NULL DEFAULT 'ai' AFTER weekStart
  ADD INDEX: idx_week_solver_status (weekStart, solverType, status)
```

#### Internal API Changes

```yaml
# Extended endpoint
Endpoint: Dispatch Schedule Generation
  Method: POST
  Path: /:typeNum/api/schedule/ai/generate
  Request:
    weekStart: string (YYYY-MM-DD, required)
    optimizationPriorities: string[] (required, all 6 now accepted)
    solverType: string ('ai'|'math', required, default 'ai')  # NEW
    customInstructions: string (optional, only for AI)
    includeOwnerIds: int[] (optional)
    notifyByEmail: boolean (optional, default false)
    saveDefaults: boolean (optional, default false)
    forceRegenerate: boolean (optional, default false)
  Response:
    success:
      jobId: string (UUID)
      status: string ('pending')
      solverType: string ('ai'|'math')
      ablyChannel: string
    error:
      errorCode: string
      message: string

# Extended endpoint
Endpoint: Get Suggestions for Week
  Method: GET
  Path: /:typeNum/api/schedule/ai/suggestions/:weekStart
  Query Parameters:
    solverType: string ('ai'|'math', optional - returns all if omitted)  # NEW
  Response:
    success:
      suggestion: object (AiSuggestion + scorecard + constraintReport)
      solverType: string
      scorecard: object (SolverScorecard)  # NEW
    error:
      errorCode: string
      message: string

# Extended endpoint
Endpoint: Get Usage Stats
  Method: GET
  Path: /:typeNum/api/schedule/ai/usage
  Response:
    success:
      ai:
        runsUsed: int
        runsAllowed: int
        runsRemaining: int
        resetDate: string (YYYY-MM-DD)
      math:
        runsUsed: int  # Tracked but no limit
        unlimited: true
      lastSolverType: string ('ai'|'math')

# NEW endpoint
Endpoint: Solver Health Check
  Method: GET
  Path: /:typeNum/api/schedule/solver/health
  Response:
    success:
      pythonAvailable: boolean
      pythonVersion: string|null
      ortoolsVersion: string|null
      ortoolsInstalled: boolean
    error:
      errorCode: string
      message: string
```

#### Application Data Models

```pseudocode
# Value Objects (NEW)

ENTITY: SolverResult (NEW)
  FIELDS:
    status: string  # 'OPTIMAL' | 'FEASIBLE' | 'INFEASIBLE'
    assignments: array<SolverAssignment>
    optimalityGap: float  # 0.0 = proven optimal
    objectiveValue: float
    solverDurationMs: int
    constraintReport: SolverConstraintReport
    scorecard: SolverScorecard
  BEHAVIORS:
    isOptimal(): bool
    isFeasible(): bool
    isInfeasible(): bool
    getFilledCount(): int
    getUnfilledCount(): int
    toArray(): array

ENTITY: SolverAssignment (NEW)
  FIELDS:
    shiftId: int
    employeeId: ?int  # null if unfilled
    factors: array  # constraint data for LLM explanation
    constraintsSatisfied: string[]
    alternativesConsidered: int  # how many employees were viable
  BEHAVIORS:
    isFilled(): bool
    toArray(): array

ENTITY: SolverScorecard (NEW)
  FIELDS:
    totalLaborCost: float
    fairnessCV: float  # coefficient of variation
    fairnessRating: string  # 'high' (<0.15) | 'medium' (0.15-0.30) | 'low' (>0.30)
    coverageCount: int  # filled
    coverageTotal: int  # total shifts
    coveragePercent: float
    constraintViolations: int  # should be 0 for math
    totalOvertimeHours: float
    overtimeByEmployee: array<int, float>
    optimalityStatus: string  # 'Optimal' | 'Near-optimal (X.X% gap)'
  BEHAVIORS:
    toArray(): array
    toApiArray(): array
    compareWith(SolverScorecard $other): ComparisonResult

ENTITY: SolverConstraintReport (NEW)
  FIELDS:
    bindingConstraints: array  # constraints at their limit
    slackConstraints: array  # constraints with room
    infeasibleSubset: ?array  # IIS when infeasible
    employeeUtilization: array<int, float>  # % of max hours used per employee
  BEHAVIORS:
    toArray(): array
    getInfeasibilityExplanationData(): array

# Modified Models

ENTITY: AiSuggestion (MODIFIED)
  FIELDS:
    + solverType: string ('ai'|'math') (NEW)
    + solverStatus: ?string (NEW)
    + optimalityGap: ?float (NEW)
    + solverDurationMs: ?int (NEW)
    + constraintReport: ?string (JSON) (NEW)
    + scorecard: ?string (JSON) (NEW)
    existing fields unchanged...
  BEHAVIORS:
    + isMathOptimizer(): bool (NEW)
    + isAiScheduler(): bool (NEW)
    + getScorecard(): ?SolverScorecard (NEW)
    + getConstraintReport(): ?SolverConstraintReport (NEW)
    existing methods unchanged...

ENTITY: AiJob (MODIFIED)
  FIELDS:
    + solverType: string ('ai'|'math') (NEW)
    existing fields unchanged...
  BEHAVIORS:
    + isMathOptimizer(): bool (NEW)
    existing methods unchanged...
```

#### Integration Points

```yaml
# Inter-Component Communication (PHP → Python)
- from: SolverScheduleOptimizer (PHP)
  to: schedule_solver.py (Python)
  protocol: stdin/stdout pipe via proc_open()
  data_flow: |
    PHP serializes ScheduleProblem as JSON → writes to stdin pipe
    Python reads stdin → builds CP-SAT model → solves → writes SolverResult JSON to stdout
    PHP reads stdout → parses JSON → creates SolverResult value object
  timeout: 90 seconds
  error_handling: Non-zero exit code → catch stderr → log → return error to user

# Inter-Component Communication (PHP → OpenAI for explanations)
- from: SolverExplanationService (PHP)
  to: OpenAIClient (PHP, existing)
  protocol: Internal method call
  data_flow: |
    Solver result + constraint report → ExplanationService builds prompt
    → OpenAIClient.chat() → LLM generates natural language explanations
    → Explanations attached to each assignment
  error_handling: If OpenAI unavailable, structured fallback explanations from constraint data

# External System Integration (existing, reused)
OpenAI_API:
  - doc: docs/specs/026-ai-smart-scheduling/solution-design.md
  - sections: [OpenAIClient, model fallback chain]
  - integration: "Same client, different prompt (explanation vs. generation)"
  - critical_data: [solver constraint data, assignment factors]

Ably_Realtime:
  - integration: "Same notification pattern as AI Scheduler"
  - channel: "solver-schedule-{typeNum}-{jobId}"
  - events: ['solver.completed', 'solver.failed']
```

### Implementation Examples

#### Example: Python Solver Input/Output Contract

**Why this example**: The JSON contract between PHP and Python is the most critical interface. Both sides must agree on the exact schema.

```python
# Input JSON (PHP → Python via stdin)
{
  "config": {
    "timeout_seconds": 60,
    "random_seed": 42,
    "priorities": [
      {"name": "position_coverage", "rank": 1, "weight": 100000},
      {"name": "labor_cost", "rank": 2, "weight": 10000},
      {"name": "hours_fairness", "rank": 3, "weight": 1000},
      {"name": "seniority", "rank": 4, "weight": 100},
      {"name": "minimize_overtime", "rank": 5, "weight": 10},
      {"name": "employee_preferences", "rank": 6, "weight": 1}
    ]
  },
  "shifts": [
    {
      "shiftId": 101,
      "date": "2026-02-16",
      "dayOfWeek": 1,
      "startTime": "09:00",
      "endTime": "17:00",
      "durationHours": 8.0,
      "positionId": 3,
      "positionName": "Shift Lead",
      "minRoleId": 3,
      "isLocked": false
    }
  ],
  "employees": [
    {
      "userId": 155,
      "name": "Casey",
      "role": 4,
      "hourlyRate": 15.50,
      "hoursRequested": 32.0,
      "hoursMin": 20.0,
      "hoursMax": 40.0,
      "currentPeriodHours": 8.0,
      "shiftsThisWeek": 1,
      "availability": [
        {"dayOfWeek": 1, "startTime": "08:00", "endTime": "22:00"}
      ],
      "timeOff": []
    }
  ],
  "lockedShifts": [
    {
      "shiftId": 100,
      "employeeId": 28,
      "date": "2026-02-16",
      "startTime": "09:00",
      "endTime": "17:00",
      "durationHours": 8.0
    }
  ]
}
```

```python
# Output JSON (Python → PHP via stdout)
{
  "status": "OPTIMAL",
  "objectiveValue": 4523.75,
  "optimalityGap": 0.0,
  "solverDurationMs": 3421,
  "assignments": [
    {
      "shiftId": 101,
      "employeeId": 155,
      "factors": {
        "roleQualified": true,
        "availabilityMatch": true,
        "hoursAfter": 16.0,
        "hoursMax": 40.0,
        "hoursDeviation": -16.0,
        "overtimeRisk": false,
        "alternativesCount": 3,
        "laborCost": 124.00,
        "priorityScores": {
          "position_coverage": 100000,
          "labor_cost": 8500,
          "hours_fairness": 750
        }
      }
    }
  ],
  "unfilledShifts": [
    {
      "shiftId": 105,
      "reason": "infeasible",
      "conflictingConstraints": [
        "All qualified employees (role <= 3) at hoursMax",
        "minRoleId=3 limits candidates to 2 employees"
      ]
    }
  ],
  "constraintReport": {
    "bindingConstraints": [
      {"type": "hoursMax", "employeeId": 28, "value": 40.0, "limit": 40.0},
      {"type": "shiftsPerWeek", "employeeId": 155, "value": 5, "limit": 5}
    ],
    "slackConstraints": [
      {"type": "hoursMax", "employeeId": 156, "value": 24.0, "limit": 40.0, "slack": 16.0}
    ],
    "infeasibleSubset": null
  },
  "scorecard": {
    "totalLaborCost": 4523.75,
    "fairnessCV": 0.12,
    "coverageFilled": 28,
    "coverageTotal": 30,
    "constraintViolations": 0,
    "totalOvertimeHours": 2.5,
    "overtimeByEmployee": {"28": 2.5}
  }
}
```

#### Example: LLM Explanation Prompt Pattern

**Why this example**: Demonstrates how solver constraint data is translated into an LLM prompt for natural language explanation.

```php
// SolverExplanationService generates this prompt from solver data
$systemPrompt = <<<PROMPT
You are a scheduling assistant explaining shift assignment decisions.
Given structured constraint data from a mathematical optimizer, generate
clear, specific explanations that reference actual numbers and constraints.
Do NOT speculate or add information not in the data.
PROMPT;

$userPrompt = <<<PROMPT
Explain these shift assignments for the week of Feb 16, 2026:

Assignment: Casey → Monday 9am-5pm Shift Lead
Solver factors:
- Role qualified: Yes (role 4 <= minRole 3: FALSE - WAIT this is wrong)
  Actually: role 4 > minRole 3, so Casey is NOT qualified for Shift Lead
  // This shows why the solver enforces constraints mathematically -
  // the LLM only explains, never decides

Corrected example with valid data:
Assignment: Ryan → Monday 9am-5pm Shift Lead
Solver factors:
- Role qualified: Yes (role 2 <= minRole 3)
- Available: Mon 8am-10pm
- Current hours: 8.0 of 40.0 max (20% utilized)
- Hours deviation: -24h below requested (32h)
- Alternatives considered: 3 employees qualified
- Labor cost: $124.00 (8h × $15.50/hr)
- No overtime triggered

Generate a 1-2 sentence explanation focusing on the top 2-3 factors.
PROMPT;

// Expected LLM output:
// "Ryan was assigned because he's one of 3 qualified Shift Leads available
// Monday, and has 24 fewer hours than his 32-hour target this week.
// At $15.50/hr, this shift costs $124.00 with no overtime risk."
```

#### Example: CP-SAT Model Construction Pattern

**Why this example**: Shows the core constraint modeling approach in Python.

```python
from ortools.sat.python import cp_model

def build_model(problem_data):
    model = cp_model.CpModel()

    employees = problem_data['employees']
    shifts = problem_data['shifts']
    priorities = problem_data['config']['priorities']

    # Decision variables: x[e][s] = 1 if employee e assigned to shift s
    x = {}
    for e in employees:
        for s in shifts:
            if not s['isLocked']:
                x[(e['userId'], s['shiftId'])] = model.NewBoolVar(
                    f"x_{e['userId']}_{s['shiftId']}"
                )

    # HARD CONSTRAINT 1: Each open shift assigned to at most 1 employee
    for s in shifts:
        if not s['isLocked']:
            model.Add(
                sum(x[(e['userId'], s['shiftId'])]
                    for e in employees
                    if (e['userId'], s['shiftId']) in x) <= 1
            )

    # HARD CONSTRAINT 3: Role qualification
    for e in employees:
        for s in shifts:
            if (e['userId'], s['shiftId']) in x:
                if s['minRoleId'] is not None and e['role'] > s['minRoleId']:
                    model.Add(x[(e['userId'], s['shiftId'])] == 0)

    # HARD CONSTRAINT 8: One shift per employee per day
    for e in employees:
        for day in unique_days(shifts):
            day_shifts = [s for s in shifts if s['date'] == day and not s['isLocked']]
            if len(day_shifts) > 1:
                model.Add(
                    sum(x[(e['userId'], s['shiftId'])]
                        for s in day_shifts
                        if (e['userId'], s['shiftId']) in x) <= 1
                )

    # ... (remaining 8 hard constraints follow same pattern)

    # OBJECTIVE: Weighted sum of soft constraint penalties
    objective_terms = []
    for priority in priorities:
        weight = priority['weight']
        if priority['name'] == 'labor_cost':
            # Minimize total labor cost
            for e in employees:
                for s in shifts:
                    if (e['userId'], s['shiftId']) in x:
                        cost = int(e['hourlyRate'] * s['durationHours'] * 100)
                        objective_terms.append(
                            x[(e['userId'], s['shiftId'])] * cost * (-weight)
                        )
        # ... other priorities

    model.Maximize(sum(objective_terms))

    return model, x
```

#### Test Examples as Interface Documentation

```php
// SolverScheduleOptimizerTest documents the expected interface
class SolverScheduleOptimizerTest extends TestCase
{
    public function testGenerateWithMathOptimizer(): void
    {
        $optimizer = new SolverScheduleOptimizer(/* ... */);

        $result = $optimizer->generateSuggestions(
            weekStart: new DateTime('2026-02-16'),
            priorities: ['position_coverage', 'labor_cost', 'hours_fairness',
                        'seniority', 'minimize_overtime', 'employee_preferences'],
            userId: 28,
            options: ['solverType' => 'math']
        );

        // Returns same SuggestionResult as AiScheduleOptimizer
        $this->assertInstanceOf(SuggestionResult::class, $result);
        $this->assertNotNull($result->suggestion);
        $this->assertEquals('math', $result->suggestion->solverType);
        $this->assertContains($result->suggestion->solverStatus, ['OPTIMAL', 'FEASIBLE']);
        $this->assertGreaterThanOrEqual(0, $result->suggestion->optimalityGap);

        // Scorecard always present for math optimizer
        $scorecard = $result->suggestion->getScorecard();
        $this->assertNotNull($scorecard);
        $this->assertEquals(0, $scorecard->constraintViolations);
    }
}
```

---

## Runtime View

### Primary Flow: Math Optimizer Schedule Generation

1. Manager opens scheduling calendar and clicks "Generate Schedule"
2. Config modal shows solver type selector: "Math Optimizer" / "AI Scheduler"
3. Manager selects "Math Optimizer", configures priorities, clicks "Generate"
4. Frontend POSTs to `/api/:typeNum/schedule/ai/generate` with `solverType: 'math'`
5. Controller validates input, creates `AiJob` with `solverType: 'math'`
6. Controller dispatches `SolverScheduleGenerationJob` to TaskEngine high queue
7. Controller returns `{jobId, status: 'pending', ablyChannel}` to frontend
8. Frontend subscribes to Ably channel and shows progress indicator
9. TaskEngine worker picks up job from high queue
10. Job calls `SolverScheduleOptimizer.generateSuggestions()`
11. `ScheduleDataGatherer` collects shifts, employees, availability, time-off, hours, pay rates
12. `PythonSolverBridge` serializes problem as JSON and calls Python subprocess
13. Python OR-Tools builds CP-SAT model, solves within 60s timeout, writes JSON result
14. PHP parses result into `SolverResult` value object
15. `SolverExplanationService` sends constraint data to OpenAI for natural language explanations
16. `ScorecardService` calculates quality metrics from solver output
17. Job creates `AiSuggestion` with `solverType: 'math'`, scorecard, constraint report
18. Job saves to `aiScheduleSuggestions` and caches in Redis
19. Job sends Ably notification: `{event: 'solver.completed', jobId, suggestionId}`
20. Frontend receives notification, loads suggestion, renders preview panel
21. Manager reviews assignments with explanations, accepts/rejects, applies

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Calendar UI
    participant API as API Controller
    participant Queue as Redis Queue
    participant Worker as TaskEngine Worker
    participant Solver as SolverScheduleOptimizer
    participant Python as Python CP-SAT
    participant LLM as OpenAI (Explanations)
    participant DB as Store Database
    participant Ably as Ably Real-time

    Manager->>UI: Click "Generate Schedule"
    UI->>UI: Show config modal (solver selector)
    Manager->>UI: Select "Math Optimizer" + priorities
    UI->>API: POST /generate {solverType: 'math', priorities}
    API->>DB: Create AiJob {solverType: 'math'}
    API->>Queue: Dispatch SolverScheduleGenerationJob
    API-->>UI: {jobId, ablyChannel}
    UI->>Ably: Subscribe to channel

    Queue->>Worker: Reserve job
    Worker->>Solver: generateSuggestions()
    Solver->>DB: Gather shifts, employees, availability, time-off
    Solver->>Python: proc_open() with JSON stdin
    Python->>Python: Build CP-SAT model
    Python->>Python: Solve (5-60s)
    Python-->>Solver: JSON result via stdout

    alt OpenAI Available
        Solver->>LLM: Generate explanations from constraint data
        LLM-->>Solver: Natural language explanations
    else OpenAI Unavailable
        Solver->>Solver: Use structured fallback explanations
    end

    Solver->>DB: Save AiSuggestion {solverType: 'math', scorecard}
    Solver->>DB: Update AiJob → completed
    Worker->>Ably: Publish solver.completed

    Ably-->>UI: Notification {suggestionId}
    UI->>API: GET /suggestions/:weekStart?solverType=math
    API-->>UI: Suggestion with scorecard + explanations
    UI->>UI: Render preview panel with scorecard
    Manager->>UI: Review, accept/reject assignments
    Manager->>UI: Click "Apply"
    UI->>API: POST /apply {suggestionId, acceptedShiftIds}
    API->>DB: Assign employees to shifts
    API-->>UI: Success
```

### Error Handling

| Error Type | Detection | Response | Recovery |
|------------|-----------|----------|----------|
| **Python not installed** | `SolverHealthCheck` on app init | Hide "Math Optimizer" option in UI; log warning | Install Python 3.9+ and OR-Tools |
| **OR-Tools not installed** | `PythonSolverBridge::checkHealth()` | Same as above | `pip3 install ortools` |
| **Python subprocess crash** | Non-zero exit code from `proc_open()` | Job marked failed; user sees "Solver failed, try AI Scheduler" | Retry once; if persistent, check logs |
| **Solver timeout (>60s)** | CP-SAT time limit parameter | Return best feasible solution found with "Near-optimal" status | Increase timeout or reduce problem size |
| **Solver INFEASIBLE** | CP-SAT status = INFEASIBLE | Show infeasibility explanation with resolution options | User adjusts constraints (e.g., allow overtime) |
| **OpenAI unavailable** | `OpenAIException` during explanation step | Structured fallback explanations from solver data; banner shown | Solver result unaffected; retry LLM later |
| **Invalid JSON from Python** | JSON parse failure on stdout | Job marked failed with stderr content | Check Python script for bugs |
| **Rate limit (AI only)** | `AiUsageTracker::canGenerate()` returns denied | Error response with runs used/remaining/reset date | Math Optimizer has no limit; switch solver |
| **Stale data** | Schedule modified between generate and apply | Warn user; require re-generation | Re-run solver with current data |
| **Redis unavailable** | Redis connection failure | Cache miss → query DB directly | Redis reconnect; cache warms on next request |
| **Ably unavailable** | Ably publish failure | Fallback: user can poll job status via GET /job/:jobId | Poll-based fallback built into frontend |
| **Comparison: missing solver result** | Only one solver type has results for the week | Return error: "Generate with both solvers to compare" | UI shows "Run [missing solver] first" prompt |
| **Resolution re-run: invalid adjustment** | Adjustment params invalid or conflict | 400 error with specific message | Frontend validates before submission |
| **Why-not: employee not in pool** | Employee not schedulable for the week | Return "Employee not in scheduling pool for this week" | Frontend filters employee dropdown to pool |

### Complex Logic: CP-SAT Model Construction

```
ALGORITHM: Build and Solve CP-SAT Model
INPUT: shifts[], employees[], availability[], timeOff[], lockedShifts[], priorities[]
OUTPUT: SolverResult {assignments, scorecard, constraintReport}

1. INITIALIZE:
   - Create CpModel instance
   - Set random seed for determinism (seed = 42)
   - Set solve time limit from config (default 60s)

2. CREATE DECISION VARIABLES:
   - For each (employee, open_shift) pair:
     x[e,s] = BoolVar (1 = assigned, 0 = not assigned)
   - For each open shift:
     filled[s] = BoolVar (1 = shift has someone, 0 = unfilled)

3. APPLY HARD CONSTRAINTS (11 constraints from AiPromptBuilder):
   C1: Each open shift assigned to at most 1 employee
       ∀s: Σ_e x[e,s] ≤ 1
   C2: Locked shifts are immutable (not in decision variables)
   C3: Role qualification: x[e,s] = 0 if employee.role > shift.minRoleId
   C4: Opening/closing: if shift.minRoleId ≤ 3, only employees with role ≤ 3
   C5: Availability: x[e,s] = 0 if employee not available on shift day/time
       Handle overnight: check both start-date and next-date availability
   C6: Time-off: x[e,s] = 0 if employee has approved time-off on shift date
   C7: Hours max: Σ_s (x[e,s] * shift.hours) + currentHours ≤ hoursMax
   C8: One shift per day: ∀e,d: Σ_{s on day d} x[e,s] ≤ 1
   C9: No overlapping shifts (handled by C8 for same-day; cross-midnight via interval logic)
   C10: Max 5 shifts/week: ∀e: Σ_s x[e,s] + existingShifts ≤ 5
   C11: hoursMax is absolute cap (same as C7)

4. BUILD OBJECTIVE FUNCTION (weighted soft constraints):
   For each priority p with weight w = 10^(6 - rank):

   position_coverage (w):
     Maximize: w * Σ_s filled[s]

   labor_cost (w):
     Minimize: w * Σ_{e,s} x[e,s] * hourlyRate[e] * hours[s]
     (Implemented as negative term in maximization)

   hours_fairness (w):
     Minimize deviation from requested hours:
     w * Σ_e |actualHours[e] - requestedHours[e]|
     (Linearized via auxiliary variables)

   seniority (w):
     Prefer lower role numbers (more senior):
     w * Σ_{e,s} x[e,s] * (6 - role[e])

   minimize_overtime (w):
     Penalize hours beyond weekly/daily thresholds:
     w * Σ_e max(0, totalHours[e] - 40) * overtimePenalty

   employee_preferences (w):
     Reward shifts on preferred days:
     w * Σ_{e,s} x[e,s] * isPreferred[e, shift.day]

5. SOLVE:
   solver = CpSolver()
   solver.parameters.max_time_in_seconds = timeout
   solver.parameters.random_seed = seed
   solver.parameters.num_search_workers = 1 (MUST be 1 for determinism; multi-threading makes CP-SAT non-deterministic even with fixed seed)
   status = solver.Solve(model)

6. EXTRACT RESULTS:
   IF status == OPTIMAL or FEASIBLE:
     For each (e,s) where solver.Value(x[e,s]) == 1:
       Create assignment with factors from constraint analysis
     Calculate gap = (upper_bound - objective) / objective * 100
     Build scorecard and constraint report
   IF status == INFEASIBLE:
     Run conflict detection using CP-SAT assumption literals:
       a. Add Boolean assumption literal for each shift's "must-be-filled" constraint
       b. Call solver.SolveWithAssumptions() to get sufficient assumptions for unsat
       c. The returned assumptions identify which shifts cannot be filled simultaneously
       d. For each infeasible shift, relax one constraint at a time (role, hours, availability)
          to identify the binding constraint(s) preventing assignment
       e. Time budget: max 10 seconds for conflict analysis (fail fast with "unable to determine")
     Build infeasibility explanation data with identified conflicts

7. FORMAT AND RETURN:
   Serialize to JSON output schema
   Include constraint report for LLM explanation generation
```

---

## Deployment View

### Single Application Deployment
- **Environment**: Same PHP server as existing application; Python 3.9+ co-located
- **New Dependencies**:
  - Python 3.9+ (`python3` accessible from PHP process PATH)
  - OR-Tools package: `pip3 install ortools>=9.9` (~50MB)
  - No new PHP dependencies
- **Configuration**:
  - `SOLVER_PYTHON_PATH` env var (default: `python3`)
  - `SOLVER_SCRIPT_PATH` env var (default: `userfrosting/solver/schedule_solver.py`)
  - `SOLVER_TIMEOUT_SECONDS` env var (default: `60`)
  - No change to existing env vars
- **Performance**:
  - Solve time: 5-60s for ≤30 employees, ≤150 shifts
  - Memory: ~100-200MB for Python process during solve
  - CPU: 1 core utilized during solve (single-threaded required for determinism)
  - LLM explanation: 5-15s additional (reuses existing OpenAI timeout)
- **Rollback**: Remove solver UI toggle; existing AI Scheduler unaffected

### Deployment Checklist
1. Run database migrations: `php userfrosting/conductor run`
2. Install Python dependencies: `pip3 install -r userfrosting/solver/requirements.txt`
3. Verify Python: `python3 userfrosting/solver/schedule_solver.py --test`
4. Deploy PHP code (includes new classes and modified controller)
5. Deploy frontend assets (updated JS with solver selector)
6. Build CSS: `php userfrosting/conductor build-css --minify`
7. Restart TaskEngine workers: they'll pick up new job class from registry

---

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: TaskEngine async job processing
  relevance: CRITICAL
  why: "SolverScheduleGenerationJob follows same BaseJob pattern as AiScheduleGenerationJob"

- pattern: Ably real-time notifications
  relevance: HIGH
  why: "Same channel/event pattern for solver completion notifications"

- pattern: OpenAI structured output
  relevance: HIGH
  why: "Reused for LLM explanation generation (different prompt, same client)"

- pattern: AiSuggestion storage and preview
  relevance: CRITICAL
  why: "Shared suggestion table with solverType discriminator"

- pattern: proc_open subprocess
  relevance: MEDIUM
  why: "Existing pattern (PageSchema YUI compressor) adapted for Python solver"

# New patterns created
- pattern: Python subprocess bridge (PythonSolverBridge)
  relevance: HIGH
  why: "Reusable pattern for calling Python from PHP with JSON I/O"

- pattern: Solver explanation from constraint data
  relevance: MEDIUM
  why: "LLM explains mathematical results rather than generating them"
```

### System-Wide Patterns

- **Security**: Same session auth + store group check + CSRF. Python subprocess has no elevated privileges. No user input passes directly to Python (only serialized problem data). All new endpoints (solver/health, solver/compare, solver/resolutions, solver/rerun, solver/why-not) enforce `checkAccess('uri_schedule_ai')` and `checkStoreGroup($typeNum)`. POST endpoints require valid CSRF token.
- **Error Handling**: Layered: Python errors → PHP bridge catches → Job marks failed → Ably notifies → UI shows error message. LLM errors → graceful degradation to structured explanations.
- **Performance**: Solver runs in background (TaskEngine), not blocking HTTP request. Redis caching for suggestion lookup. Python process released immediately after solve.
- **Logging**: PHP error_log for job lifecycle. Python stderr captured by PHP bridge. TaskEngine execution logs in `task_execution_logs` table.

### Implementation Patterns

#### Code Patterns and Conventions

- Follow existing PSR-4 autoloading under `BuyerKiosk\Scheduling\AiScheduling\` namespace
- Value objects are `readonly` with `toArray()` and `jsonSerialize()` methods
- Repository methods return domain models (not raw arrays)
- Controllers extend `BaseApiController` with `$this->errorResponse()` pattern
- Python code follows PEP 8 with type hints

#### State Management Patterns

- Job status managed via `AiJob` model: pending → processing → completed/failed
- Suggestion status managed via `AiSuggestion` model: pending → applied/dismissed/expired
- Redis used for suggestion caching (same `AiSuggestionCacheService`)
- Abort signals via Redis key: `task-engine:abort:{executionId}`

#### Performance Characteristics

- CP-SAT solver uses 1 search worker (required for determinism; multi-threading makes CP-SAT non-deterministic even with fixed seed)
- Solve time bounded by `SOLVER_TIMEOUT_SECONDS` env var
- Best feasible solution returned if timeout reached (never returns nothing)
- LLM explanation is single batch call (all assignments explained in one request)
- Frontend polls via Ably subscription (not HTTP polling)

#### Integration Patterns

- **PHP → Python**: JSON over stdin/stdout pipes. No HTTP, no files, no shared memory.
- **Solver → LLM**: Solver provides structured data; LLM generates text. LLM never affects solver output.
- **Solver → UI**: Same path as AI Scheduler: suggestion saved → Ably notification → UI loads suggestion.

#### Component Structure Pattern

```pseudocode
# SolverScheduleOptimizer follows same 8-step pattern as AiScheduleOptimizer
COMPONENT: SolverScheduleOptimizer
  DEPENDENCIES:
    PythonSolverBridge, ScheduleDataGatherer, SolverExplanationService,
    ScorecardService, AiSuggestionRepository, AiSuggestionCacheService,
    OwnerPrefsService, PDO $storeDb, PDO $usersDb, string $typeNum

  METHOD: generateSuggestions(weekStart, priorities, userId, options)
    1. GATHER DATA via ScheduleDataGatherer
       (same queries as AiScheduleOptimizer, extracted for sharing)
    2. CALL SOLVER via PythonSolverBridge
       - Serialize problem to JSON
       - Execute subprocess with timeout
       - Parse result JSON
    3. GENERATE EXPLANATIONS via SolverExplanationService
       - Send constraint data to OpenAI
       - Fallback to structured explanations if unavailable
    4. CALCULATE SCORECARD via ScorecardService
       - Labor cost, fairness CV, coverage, OT hours
    5. CREATE SUGGESTION
       - Build AiSuggestion with solverType='math'
       - Include scorecard and constraint report
    6. PERSIST & NOTIFY
       - Save to DB and Redis cache
       - Return SuggestionResult
```

#### Data Processing Pattern

```pseudocode
FUNCTION: PythonSolverBridge.solve(problemData: array): SolverResult
  VALIDATE: Python path exists, OR-Tools installed (health check cached 5 min)
  SERIALIZE: JSON encode problem data
  EXECUTE:
    $process = proc_open(
      [$pythonPath, $solverScript],
      [0 => ['pipe', 'r'], 1 => ['pipe', 'r'], 2 => ['pipe', 'r']],
      $pipes
    )
    fwrite($pipes[0], $jsonInput)
    fclose($pipes[0])
    $stdout = stream_get_contents($pipes[1])
    $stderr = stream_get_contents($pipes[2])
    $exitCode = proc_close($process)
  VALIDATE:
    IF exitCode !== 0: throw SolverException($stderr)
    IF empty($stdout): throw SolverException('No output from solver')
  PARSE: json_decode($stdout, true)
  CONSTRUCT: SolverResult::fromArray($parsed)
  RETURN: SolverResult
```

#### Error Handling Pattern

```pseudocode
FUNCTION: SolverScheduleGenerationJob.handle(): JobResult
  TRY:
    $result = $optimizer->generateSuggestions(...)
    $this->markJobCompleted($result->suggestion->suggestionId)
    $this->notifySuccess($result)
    RETURN JobResult::success($result->toArray())
  CATCH SolverException:
    $this->markJobFailed("Solver error: " . $e->getMessage())
    $this->notifyFailure("Schedule optimization failed. Please try again or use AI Scheduler.")
    RETURN JobResult::failure($e->getMessage())
  CATCH OpenAIException:
    // LLM failure is NOT a job failure - solver result is still valid
    $this->log('warning', 'LLM explanation failed, using structured fallback')
    // Continue with structured explanations...
  CATCH Throwable:
    $this->markJobFailed($e->getMessage())
    $this->notifyFailure("Unexpected error. Please try again.")
    RETURN JobResult::failure($e->getMessage())
```

#### Test Pattern

```pseudocode
TEST_SCENARIO: "Math Optimizer produces valid schedule for simple store"
  SETUP:
    - Mock PythonSolverBridge to return known JSON result
    - Mock OpenAIClient for explanation generation
    - Create test shifts (5 open, 2 locked) and employees (3 with availability)
  EXECUTE:
    $result = $optimizer->generateSuggestions($weekStart, $priorities, $userId)
  VERIFY:
    - SuggestionResult returned with solverType='math'
    - All assignments satisfy hard constraints (role, availability, hours)
    - Scorecard has 0 constraint violations
    - Explanations present for each assignment
    - Suggestion persisted to database
    - Redis cache updated

TEST_SCENARIO: "Solver handles infeasible problem gracefully"
  SETUP:
    - 5 shifts requiring Role ≤ 3 (Shift Lead+)
    - Only 1 employee with Role ≤ 3 (can only work 3 shifts)
  EXECUTE:
    $result = $optimizer->generateSuggestions(...)
  VERIFY:
    - solverStatus = 'FEASIBLE' or 'INFEASIBLE'
    - 3 shifts filled, 2 unfilled
    - Unfilled shifts have infeasibility reasons
    - constraintReport.infeasibleSubset identifies the conflicting constraints

TEST_SCENARIO: "OpenAI unavailable - structured fallback explanations"
  SETUP:
    - Mock OpenAIClient to throw OpenAIException
    - Valid solver result available
  EXECUTE:
    $result = $optimizer->generateSuggestions(...)
  VERIFY:
    - Suggestion returned successfully (solver not affected)
    - Explanations contain structured data ("Role qualified, 5h below target hours")
    - No OpenAI tokens consumed
    - Job completed (not failed)

TEST_SCENARIO: "Python subprocess crash recovery"
  SETUP:
    - Mock proc_open to return exit code 1 with stderr "Segmentation fault"
  EXECUTE/VERIFY:
    - SolverException thrown with stderr content
    - Job marked failed with descriptive error
    - User sees "Solver failed, try AI Scheduler"
```

---

## Additional Feature Designs (PRD Must-Haves)

### Feature 5: LLM-Powered Improvement Suggestions

**Component:** `SolverImprovementSuggestionService` (NEW)

```pseudocode
COMPONENT: SolverImprovementSuggestionService
  DEPENDENCIES:
    OpenAIClient, SolverResult, ScheduleDataGatherer

  METHOD: generateSuggestions(SolverResult $result, array $scheduleData): array
    1. EXTRACT analysis data from solver result:
       - Binding constraints (employees at limits)
       - Slack constraints (employees with room)
       - Unfilled shifts and their reasons
       - Overtime distribution
       - Fairness metrics
    2. BUILD LLM prompt with structured data:
       - Immediate actions: constraint relaxation, shift swaps, overtime adjustments
       - Long-term actions: cross-training, hiring, availability pattern changes
    3. CALL OpenAI with structured output schema:
       - suggestions[]: {tier: 'immediate'|'long_term', action: string, impact: string, tradeoff: string}
    4. VALIDATE and return suggestions array
    5. GRACEFUL DEGRADATION: If OpenAI unavailable, hide suggestions section (PRD Feature 5 requirement)

  STORAGE:
    - Suggestions stored as JSON in AiSuggestion.improvementSuggestions column
    - Cached in Redis with suggestion (same TTL)
```

**Schema Addition:**
```yaml
Table: aiScheduleSuggestions (store DB)
  ADD COLUMN: improvementSuggestions TEXT NULL COMMENT 'JSON array of improvement suggestions' AFTER scorecard
```

**LLM Prompt Pattern:**
```
Analyze this schedule optimization result and suggest 2-5 improvements.

Schedule Stats:
- Total labor cost: $4,523.75
- Fairness CV: 0.12 (High)
- Coverage: 28/30 shifts filled
- Overtime: 2.5h (Ryan only)

Binding Constraints:
- Ryan at 40h weekly max
- Kay at 40h weekly max
- Only 2 Shift Leads available

Unfilled Shifts:
- Sunday 6pm-10pm Shift Lead: all qualified employees at hour limits

Separate suggestions into:
1. "Immediate" (this week): shift swaps, overtime allowances
2. "Long-term" (ongoing): cross-training, hiring, availability changes

Format each as: action, expected impact, tradeoff
```

### Feature 6: Infeasibility Resolution Options

**Component:** `InfeasibilityResolutionService` (NEW)

```pseudocode
COMPONENT: InfeasibilityResolutionService
  DEPENDENCIES:
    PythonSolverBridge, SolverExplanationService, OpenAIClient

  METHOD: generateResolutions(SolverResult $infeasibleResult, array $scheduleData): array
    1. For each unfilled shift, analyze conflict type:
       - ROLE_CONSTRAINT: not enough qualified employees
       - HOURS_LIMIT: qualified employees at max hours
       - AVAILABILITY: no qualified employees available on that day/time
       - COMBINED: multiple constraints interact
    2. Generate 2-3 resolution options per unfilled shift:
       a. ALLOW_OVERTIME: Calculate cost of extending specific employee's hours
       b. SWAP_SHIFT: Identify a shift swap that frees capacity
       c. RELAX_ROLE: Show impact of lowering minRoleId for the shift
       d. MANUAL_FILL: "This shift must be filled manually"
    3. Each option includes:
       - Description (plain English)
       - Tradeoff (cost, fairness impact)
       - Actionable: boolean (can system auto-apply?)
    4. LLM generates natural language for options from structured data

  METHOD: reRunWithAdjustment(string $weekStart, string $adjustmentType, array $params): SolverResult
    Supported adjustments:
      - 'allow_overtime': {employeeId, additionalHours} → increases hoursMax temporarily
      - 'relax_role': {shiftId, newMinRoleId} → relaxes role requirement for one shift
      - 'swap_shift': {shiftId1, shiftId2} → locks a specific swap before re-solving
    Process:
      1. Clone original problem data
      2. Apply adjustment to cloned data
      3. Re-run solver with adjusted constraints
      4. Return new SolverResult (includes new scorecard showing impact)
```

**API Endpoints:**
```yaml
# NEW endpoint
Endpoint: Get Resolution Options
  Method: GET
  Path: /:typeNum/api/schedule/solver/resolutions/:suggestionId
  Response:
    success:
      resolutions: array<{
        shiftId: int,
        conflictType: string,
        options: array<{
          type: string,
          description: string,
          tradeoff: string,
          actionable: boolean,
          params: object
        }>
      }>

# NEW endpoint
Endpoint: Re-run with Adjustment
  Method: POST
  Path: /:typeNum/api/schedule/solver/rerun
  Request:
    weekStart: string (YYYY-MM-DD)
    adjustmentType: string ('allow_overtime'|'relax_role'|'swap_shift')
    params: object (adjustment-specific parameters)
    originalSuggestionId: int
  Response:
    success:
      jobId: string (UUID)
      status: 'pending'
      ablyChannel: string
```

### Feature 8: Solver Result Comparison

**Component:** `SolverComparisonService` (NEW)

```pseudocode
COMPONENT: SolverComparisonService
  DEPENDENCIES:
    AiSuggestionRepository, ScorecardService, OpenAIClient

  METHOD: compare(string $weekStart, string $typeNum): ?ComparisonResult
    1. QUERY suggestions for weekStart:
       - Latest math suggestion (solverType='math', status IN ('pending','applied'))
       - Latest ai suggestion (solverType='ai', status IN ('pending','applied'))
    2. If both exist, build comparison:
       - Side-by-side scorecards with winner badges
       - Diff assignments (which employees differ between solvers)
       - Key differences list
    3. GENERATE LLM summary (2-3 sentences):
       - Input: both scorecards + diff assignments
       - Output: plain English comparison summary
    4. GRACEFUL DEGRADATION: If LLM unavailable, show numeric comparison only
    5. LOG comparison event for analytics

  ENTITY: ComparisonResult
    FIELDS:
      mathScorecard: SolverScorecard
      aiScorecard: SolverScorecard
      winnerByMetric: array<string, string>  # metric → 'math'|'ai'|'tie'
      diffAssignments: array<{shiftId, mathEmployee, aiEmployee, costDiff}>
      llmSummary: ?string
    BEHAVIORS:
      toApiArray(): array
```

**API Endpoint:**
```yaml
# NEW endpoint
Endpoint: Get Solver Comparison
  Method: GET
  Path: /:typeNum/api/schedule/solver/compare/:weekStart
  Response:
    success:
      comparison: {
        mathScorecard: SolverScorecard,
        aiScorecard: SolverScorecard,
        winnerByMetric: {laborCost: 'math', fairness: 'ai', ...},
        diffAssignments: [{shiftId, mathEmployee, aiEmployee, costDiff}],
        llmSummary: string|null
      }
    error:
      errorCode: 'NO_COMPARISON_DATA'
      message: 'Generate with both solvers to compare results'
```

**Frontend Comparison View:**
```
The existing preview panel will gain a "Compare" tab that appears when both solver results exist:
1. Side-by-side scorecard cards with green badges on winning metrics
2. Table of differing assignments with cost impact
3. LLM summary paragraph at top
4. "Apply Math Result" / "Apply AI Result" buttons
```

### Feature 9: "Why Not This Employee?" (Should Have)

**Component:** Extension to `SolverExplanationService`

```pseudocode
METHOD: explainWhyNot(int $shiftId, int $employeeId, SolverResult $result): string
  1. Look up constraint data from solver result for this employee/shift pair
  2. Identify which hard constraints prevent assignment:
     - Role too low? → "Casey's role (4) doesn't meet the Shift Lead requirement (≤3)"
     - Not available? → "Casey isn't available on Sunday (no availability window set)"
     - At hours max? → "Casey is at 40/40 weekly hours"
     - Already assigned that day? → "Casey already has a shift on Sunday"
     - Time off? → "Casey has approved time off on Sunday"
  3. If no hard constraint blocks, explain why not chosen (soft constraint):
     - Lower priority employee? → "Hellen was preferred: 10h below target vs Casey's 2h below"
  4. Return structured explanation (no LLM needed - direct from constraint data)
```

**API Endpoint:**
```yaml
# NEW endpoint
Endpoint: Why Not Employee
  Method: GET
  Path: /:typeNum/api/schedule/solver/why-not
  Query Parameters:
    suggestionId: int (required)
    shiftId: int (required)
    employeeId: int (required)
  Response:
    success:
      explanation: string
      constraintType: string ('role'|'availability'|'hours'|'timeoff'|'overlap'|'soft_preference')
      details: object
```

### Analytics Instrumentation Plan

All PRD tracking events will be implemented using the existing event logging pattern. Events are logged to the `analyticsEvents` table in the central database.

```yaml
Implementation:
  Service: ScheduleAnalyticsService (NEW)
  Location: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/ScheduleAnalyticsService.php
  Pattern: Static log methods called from controller and job handlers

Events:
  solver_generation_started:
    Trigger: AiSchedulingApiController.dispatchGeneration() after job dispatch
    Properties: storeId, weekStart, solverType, priorities[], includeOwners

  solver_generation_completed:
    Trigger: SolverScheduleGenerationJob.handle() on success
    Properties: storeId, solverType, duration, status, proposedCount, unfilledCount, objectiveValue, optimalityGap

  solver_assignment_accepted:
    Trigger: AiSchedulingApiController.applyAssignments() per accepted assignment
    Properties: storeId, solverType, shiftId, employeeId

  solver_assignment_rejected:
    Trigger: AiSchedulingApiController.applyAssignments() per rejected assignment
    Properties: storeId, solverType, shiftId, employeeId

  solver_result_applied:
    Trigger: AiSchedulingApiController.applyAssignments() summary
    Properties: storeId, solverType, acceptedCount, rejectedCount, unfilledCount, totalLaborCost

  solver_comparison_viewed:
    Trigger: SolverComparisonService.compare() called from API
    Properties: storeId, mathCost, aiCost, mathFairness, aiFairness, chosenSolver

  solver_explanation_expanded:
    Trigger: Frontend sends beacon on expand click
    Properties: storeId, solverType, assignmentId

  solver_suggestion_actioned:
    Trigger: InfeasibilityResolutionService.reRunWithAdjustment() or suggestion link click
    Properties: storeId, suggestionType, action

  solver_infeasibility_shown:
    Trigger: Frontend renders infeasibility panel
    Properties: storeId, conflictCount, resolutionChosen

  solver_why_not_queried:
    Trigger: Why Not endpoint called
    Properties: storeId, employeeId, shiftId
```

### LLM Chunking Strategy

For large stores (>20 assignments), the LLM explanation call will be chunked:

```pseudocode
STRATEGY: Chunked LLM Explanation Generation
  MAX_ASSIGNMENTS_PER_CALL = 20  # Keeps prompt under 4K tokens
  MAX_RETRIES = 2

  1. Group assignments into chunks of MAX_ASSIGNMENTS_PER_CALL
  2. For each chunk:
     a. Build prompt with assignment factors for this chunk only
     b. Call OpenAI with 30-second timeout
     c. Parse structured response (explanations keyed by shiftId)
     d. On failure: use structured fallback for this chunk only
  3. Merge all chunk responses into single explanation map
  4. Any chunk that failed LLM gets structured fallback explanations
  5. If ALL chunks fail: set suggestion.llmUnavailable = true, show banner
```

### Solver Type Persistence

```yaml
Storage: Existing `aiScheduleDefaultPrefs` JSON column on `stores` table
Write Path: When manager generates with a solver type, save to store prefs
  - AiSchedulingApiController.dispatchGeneration() updates store prefs if saveDefaults=true
  - Also saves lastSolverType regardless of saveDefaults flag
Read Path: AiDefaultPrefsService.getDefaultPrefs() returns lastSolverType
Schema: No migration needed - `aiScheduleDefaultPrefs` is JSON, add key:
  {
    "priorities": [...],
    "lastSolverType": "math"  // NEW key
  }
```

### Overtime Handling Clarification

```yaml
Clarification: Overtime is modeled as BOTH hard and soft:
  HARD CONSTRAINT:
    - hoursMax is an absolute cap (never exceeded) - Constraint C7/C11
    - This prevents scheduling beyond the employee's contractual maximum
  SOFT CONSTRAINT (priority-weighted penalty):
    - Overtime penalty: penalizes hours between 40 (OT threshold) and hoursMax
    - Daily OT: penalizes daily hours > 8 (California-style threshold)
    - The penalty strength depends on "minimize_overtime" priority ranking
  Example:
    - Employee hoursMax = 50, current hours = 36, shift = 8h
    - Hard: 36 + 8 = 44 ≤ 50 → ALLOWED
    - Soft: 44 > 40 weekly OT threshold → penalty of (44-40) * weight applied
    - Daily: if this makes daily hours > 8 → additional penalty
  Daily OT modeling in solver:
    - Track daily_hours[e,d] = sum of shift durations for employee e on day d
    - daily_ot[e,d] = max(0, daily_hours[e,d] - 8)
    - Penalty term: minimize_overtime_weight * sum(daily_ot + weekly_ot)
```

### Position Coverage Precedence

```yaml
Clarification: Position coverage "always takes precedence" (PRD) is implemented as:
  1. Coverage is modeled as a hard constraint soft-wrapper:
     - Each shift has a "filled" BoolVar linked to assignment variables
     - Objective heavily penalizes unfilled shifts (weight = 10^7, above any user priority)
  2. Manager priority ranking affects the ORDER of remaining objectives
  3. This means: the solver will ALWAYS try to fill shifts before optimizing other priorities
  4. User cannot rank coverage low enough to leave shifts intentionally unfilled
  Implementation:
    - coverage_penalty = 10_000_000 * sum(1 - filled[s] for s in shifts)
    - This is ADDED to the objective BEFORE user-ranked priority terms
    - Even if user ranks coverage 6th (weight=1), the hard coverage penalty dominates
```

---

## Architecture Decisions

- [x] ADR-1 **OR-Tools CP-SAT via Python subprocess**: Use Google OR-Tools CP-SAT solver running as a Python subprocess called from PHP via `proc_open()`.
  - Rationale: CP-SAT is the leading open-source constraint programming solver, proven optimal for problems of our size (10-30 employees). No PHP-native CP solver exists with comparable quality. Python subprocess is simpler than a microservice.
  - Alternatives rejected: (a) PHP-native greedy algorithm - no optimality guarantee, complex to maintain. (b) Python microservice - adds Docker/deployment complexity for minimal benefit. (c) Z3/MiniZinc - less mature for scheduling, smaller community.
  - Trade-offs: Python dependency on server; subprocess overhead (100ms startup); process isolation (no shared memory).
  - User confirmed: 2026-02-18

- [x] ADR-2 **Shared suggestion storage with solverType discriminator**: Add `solverType` ENUM('ai','math') column to existing `aiScheduleSuggestions` and `aiScheduleJobs` tables rather than creating separate tables.
  - Rationale: Maximizes code reuse (repository, cache, preview UI all work with minor filtering). Enables side-by-side comparison queries. Keeps migration simple.
  - Alternatives rejected: Separate `solverSuggestions` table - doubles repository code, complicates comparison queries, requires new cache strategy.
  - Trade-offs: Table name `aiScheduleSuggestions` becomes slightly misleading (now stores both AI and math results). Acceptable since the UI abstraction is "Smart Scheduling" covering both.
  - User confirmed: 2026-02-18

- [x] ADR-3 **LLM for explanation only, never for generation**: The solver produces assignments mathematically. The LLM receives structured constraint data and generates human-readable explanations. The LLM never influences which employee gets which shift.
  - Rationale: Eliminates LLM hallucination risk for the schedule itself. Explanations are verifiable against solver data. Graceful degradation when OpenAI is down.
  - Alternatives rejected: LLM post-processing that could modify assignments - introduces unpredictability, defeats the purpose of deterministic solving.
  - Trade-offs: Explanations may be less "creative" than AI-generated reasoning. Acceptable because accuracy is more valuable than creativity for scheduling decisions.
  - User confirmed: 2026-02-18

- [x] ADR-4 **Extract ScheduleDataGatherer from AiScheduleOptimizer**: Create a new shared service that both `AiScheduleOptimizer` and `SolverScheduleOptimizer` use for data collection (shifts, employees, availability, time-off, hours).
  - Rationale: The data gathering logic in `AiScheduleOptimizer` (steps 2-3) is identical for both solvers. Extracting prevents code duplication and ensures consistency.
  - Alternatives rejected: (a) Duplicate data gathering in solver - violates DRY. (b) Make solver call AI optimizer's methods - tight coupling. (c) Leave in AI optimizer and make solver depend on it - breaks single responsibility.
  - Trade-offs: Requires refactoring `AiScheduleOptimizer` to delegate data gathering. This is a safe refactor since the public API doesn't change.
  - User confirmed: 2026-02-18

- [x] ADR-5 **Exponential priority weighting**: Priority rank maps to weight via formula: weight = 10^(6 - rank). Rank 1 = 100,000; Rank 6 = 1.
  - Rationale: Ensures clear separation between priority levels. The highest priority is 100,000x more important than the lowest. Avoids the "everything is equally important" problem of linear weights.
  - Alternatives rejected: (a) Linear weights (1-6) - insufficient separation, priorities blend together. (b) User-adjustable sliders - too complex for V1, deferred to Feature 12 (Could Have).
  - Trade-offs: May cause lower-ranked priorities to be effectively ignored in some edge cases. This is intentional - the manager chose the ranking.
  - User confirmed: 2026-02-18

- [x] ADR-6 **Fix controller priority validation as part of this spec**: Update `AiSchedulingApiController` line 208 to accept all 6 priorities from `AiDefaultPrefsService::AVAILABLE_PRIORITIES` instead of hardcoded 4.
  - Rationale: Both solvers need all 6 priorities. The current hardcoded list is a bug that prevents `minimize_overtime` and `employee_preferences` from being used by either solver.
  - Trade-offs: Minor backward compatibility consideration - but since the AI Scheduler already defines 6 priorities in `AiDefaultPrefsService`, the controller was simply behind.
  - User confirmed: 2026-02-18

- [x] ADR-7 **No rate limit for Math Optimizer**: Math Optimizer runs have zero API cost, so no rate limit is applied. AI Scheduler keeps its existing 5-runs-per-pay-week limit.
  - Rationale: The rate limit exists to control OpenAI API costs. Math Optimizer has no external API cost. Limiting it would reduce the feature's value proposition (unlimited optimization).
  - Trade-offs: Potential for excessive server CPU usage from many solver runs. Mitigated by solver timeout (60s max) and TaskEngine queue backpressure.
  - User confirmed: 2026-02-18

---

## Quality Requirements

| Requirement | Metric | Target | Test Method |
|-------------|--------|--------|-------------|
| **Solve Speed** | 95th percentile solve time | < 30 seconds for ≤30 employees, ≤150 shifts | Unit test with large fixture data; production logging |
| **Optimality** | Gap percentage for solutions within timeout | < 5% gap for 95% of runs | Solver reports gap; logged per run |
| **Constraint Satisfaction** | Hard constraint violations | 0 violations (100% satisfaction) | Unit tests verify all 11 constraints; integration test against real data |
| **Determinism** | Same inputs produce same output | 100% reproducible (fixed seed) | Run solver 3x on same input; compare outputs |
| **Availability** | Math Optimizer available when Python installed | 100% uptime (no external API dependency) | Health check endpoint; monitoring |
| **LLM Graceful Degradation** | Solver works without OpenAI | Solver result + structured explanations always returned | Unit test with mocked OpenAI failure |
| **Subprocess Reliability** | Python process starts and completes | 99.9% success rate (excluding legitimate infeasibility) | Integration test; production error rate monitoring |
| **Memory Usage** | Python process peak memory | < 500MB for largest stores | Profile with 30-employee, 150-shift fixture |
| **API Response Time** | Time from POST to job creation | < 500ms | Controller creates job and returns immediately |
| **Explanation Quality** | Explanations reference actual constraint data | 100% of explanations contain numeric facts | Unit test: every explanation includes at least 2 constraint references |

---

## Risks and Technical Debt

### Known Technical Issues

- **Priority validation bug**: `AiSchedulingApiController` line 208 only accepts 4 of 6 priorities. Must be fixed for both solvers.
- **AI Scheduler data gathering is private**: Methods like `getOpenShiftsForWeek()`, `getSchedulableEmployees()` are private in `AiScheduleOptimizer`. Extracting `ScheduleDataGatherer` requires careful refactoring.
- **`aiScheduleSuggestions` naming**: Table name implies AI-only but will now store math solver results. Not worth renaming (would break existing code), but may confuse future developers. Add table comment.

### Technical Debt

- **Data gathering duplication**: Until `ScheduleDataGatherer` extraction is complete, there's risk of data gathering logic diverging between AI and math solvers. The extraction in ADR-4 addresses this.
- **Table naming**: `aiScheduleSuggestions` and `aiScheduleJobs` contain non-AI data. Future rename to `scheduleSuggestions`/`scheduleJobs` is desirable but low priority.

### Implementation Gotchas

- **PDO named params**: PHP PDO doesn't allow reusing named params (`:foo` twice = `HY093: Invalid parameter number`). Use unique names when binding same value to multiple placeholders.
- **proc_open PATH**: The PHP process may have a different PATH than the shell. Use absolute path to Python or configure via env var.
- **CP-SAT integer arithmetic**: OR-Tools CP-SAT works with integers only. Multiply all floats (hours, costs) by 100 for cent-precision, then divide in result formatting.
- **Overnight shifts**: Shifts spanning midnight have `endTime < startTime`. The solver must handle these correctly for one-shift-per-day constraint (count on start date) and hours calculation.
- **Store timezone**: All shift times in DB are UTC. Convert to store local time before sending to solver (same as `AiScheduleOptimizer` does).
- **Owner inclusion**: Owners are excluded from the assignment pool by default. When `includeOwnerIds` is provided, those owners are added to the pool. Owner recurring schedules and existing assignments are treated as locked shifts (visible for labor cost calculation, counted toward total hours, but not modifiable by the solver). This matches the existing `AiScheduleOptimizer.getSchedulableEmployees()` logic with `ownerPrefsService`.
- **Large stdout**: Python solver output for 150 shifts could be 50-100KB of JSON. `stream_get_contents()` handles this, but set `memory_limit` appropriately.
- **MariaDB strict mode**: `INT <> ''` comparisons trigger `1292 Truncated incorrect DECIMAL value` errors. Use `> 0` instead of `<> ''` for INT columns in any new queries.
- **Fairness CV edge cases**: When computing coefficient of variation, handle: (a) employees with zero requested hours → exclude from CV calculation; (b) all employees have identical hours → CV = 0.0 (perfect fairness); (c) only one employee → CV = 0.0 (trivially fair). The denominator in CV (mean of requested hours) must never be zero.

---

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Happy Path - Simple Store Schedule**
```gherkin
Given: A store with 5 open shifts (Mon-Fri 9am-5pm) and 3 employees with full availability
And: All employees have role 4 (Buyer), shifts require role ≤ 5
And: Priority order: position_coverage, labor_cost, hours_fairness, seniority, minimize_overtime, employee_preferences
When: Manager generates schedule with Math Optimizer
Then: All 5 shifts are filled
And: solverStatus is "OPTIMAL"
And: optimalityGap is 0.0
And: constraintViolations is 0
And: Each assignment has explanations
And: Scorecard shows total labor cost, fairness CV, coverage 5/5
```

**Scenario 2: Role Qualification Constraint**
```gherkin
Given: 2 shifts require Shift Lead (minRoleId = 3)
And: Only 1 employee has role ≤ 3
And: 3 other employees have role 4
When: Manager generates schedule
Then: 1 Shift Lead shift is filled by the qualified employee
And: 1 Shift Lead shift is unfilled with reason "Only 1 qualified employee available"
And: Other non-restricted shifts filled by remaining employees
```

**Scenario 3: Hours Max Hard Constraint**
```gherkin
Given: Employee Casey has hoursMax = 32 and currentPeriodHours = 24
And: 2 available shifts of 8 hours each
When: Solver runs
Then: Casey is assigned to at most 1 shift (24 + 8 = 32, at max)
And: Second shift assigned to someone else or unfilled
```

**Scenario 4: Overnight Shift Handling**
```gherkin
Given: Shift from 10pm to 6am (crosses midnight)
And: Employee is available Mon 6pm-12am and Tue 12am-8am
When: Solver runs
Then: Shift is correctly identified as overnight
And: Both Monday evening and Tuesday morning availability are checked
And: Employee is eligible and can be assigned
And: Shift counted on Monday for one-shift-per-day constraint
```

**Scenario 5: Python Unavailable - Graceful Degradation**
```gherkin
Given: Python 3.9 is not installed on the server
When: Application initializes
Then: Health check returns pythonAvailable: false
And: "Math Optimizer" option is hidden in the UI
And: "AI Scheduler" remains fully functional
And: Warning logged: "Python not available, Math Optimizer disabled"
```

**Scenario 6: Deterministic Output**
```gherkin
Given: Identical input data (same shifts, employees, priorities)
When: Solver runs 3 times consecutively
Then: All 3 runs produce identical assignments
And: All 3 runs produce identical scorecard values
And: objectiveValue is the same across all runs
```

**Scenario 7: OpenAI Down - Structured Fallback**
```gherkin
Given: Solver successfully generates assignments
And: OpenAI API returns 503 Service Unavailable
When: Explanation generation is attempted
Then: Solver result is saved successfully (not blocked)
And: Each assignment has structured explanation from constraint data
And: Format: "Factors: Role qualified, 5h below target hours, availability confirmed"
And: Banner shown: "AI explanations temporarily unavailable - showing constraint summary"
And: Improvement suggestions section is hidden (not shown empty)
```

**Scenario 8: Infeasible Problem with IIS**
```gherkin
Given: 10 shifts all requiring Role ≤ 2 (Manager+)
And: Only 2 managers available, each limited to 40 hours/week
And: Shifts total 80 hours
When: Solver runs
Then: solverStatus is "INFEASIBLE" or "FEASIBLE" (partial)
And: Some shifts filled (up to 80 hours of manager capacity)
And: Unfilled shifts have specific constraint explanations
And: constraintReport.infeasibleSubset identifies the role/hours conflict
```

### Test Coverage Requirements

- **Business Logic**: All 11 hard constraints individually tested. All 6 soft constraints/priorities tested. Exponential weighting verified. Scorecard calculation tested.
- **Integration Points**: `PythonSolverBridge` tested with mock subprocess. `SolverExplanationService` tested with mock OpenAI. Full pipeline integration test with real Python solver.
- **Edge Cases**: Overnight shifts, zero open shifts, zero employees, all employees at max hours, single employee, 30+ employees (performance), solver timeout behavior.
- **Error Handling**: Python crash, invalid JSON output, OpenAI failure, Redis unavailable, database failure.
- **Performance**: Solve time benchmarks for 5, 10, 20, 30 employee stores. Memory usage profiling.
- **Security**: No user input reaches Python subprocess (only serialized problem data). CSRF on POST endpoints. Permission checks on all API calls.

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Math Optimizer | The deterministic constraint solver using OR-Tools CP-SAT | User-facing name for the new solver |
| AI Scheduler | The existing OpenAI-based schedule generator (Spec 026) | User-facing name for the LLM-based solver |
| Optimality Gap | Percentage difference between the best solution found and the theoretical best possible | 0% = proven optimal; shown on scorecard |
| Fairness CV | Coefficient of Variation of assigned-vs-requested hours across employees | Lower = more fair; thresholds: High <0.15, Med 0.15-0.30, Low >0.30 |
| IIS | Irreducible Infeasible Subset - the minimal set of constraints that conflict | Used to explain why shifts can't be filled |
| Binding Constraint | A constraint that is at its limit (e.g., employee at exactly 40 hours) | Reported in constraint report; key for explanations |
| Slack | How much room a constraint has before hitting its limit | e.g., 16 hours of slack on hoursMax means 16h available |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| CP-SAT | Constraint Programming - Satisfiability solver by Google OR-Tools | Core solving engine |
| OR-Tools | Google's open-source optimization toolkit (Apache 2.0 license) | Python package providing CP-SAT |
| proc_open | PHP function to spawn a subprocess with stdin/stdout/stderr pipes | How PHP calls the Python solver |
| BoolVar | Binary decision variable (0 or 1) in CP-SAT model | x[e,s] = 1 means employee e assigned to shift s |
| Objective Function | Mathematical expression to maximize/minimize | Weighted sum of soft constraint penalties |
| TaskEngine | BuyerKiosk's async job processing system with Redis queue backend | Runs solver job in background |
| Ably | Real-time messaging service for browser notifications | Notifies UI when solver completes |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| solverType | Discriminator field: 'ai' or 'math' | Added to suggestions and jobs tables |
| solverStatus | Solver outcome: 'OPTIMAL', 'FEASIBLE', or 'INFEASIBLE' | Only present for math solver results |
| constraintReport | JSON object with binding constraints, slack, and IIS data | Stored in aiScheduleSuggestions for explanation generation |
| scorecard | JSON object with quality metrics (cost, fairness, coverage, OT) | Stored in aiScheduleSuggestions for scorecard display |
