PowerSolver developer guide
How PowerSolver is built, where each piece lives, and how to change it safely. Version 2.0 (engine on Timefold Solver 2.0, Java 21, Spring Boot 4; wizard in plain JavaScript and Alpine.js).
1. Architecture
Browser PowerSolver engine (powersolver-api, Java) PowerAdmin (separate product)
┌──────────────────────┐ ┌──────────────────────────────────────────┐ ┌─────────────────────────┐
│ powersolver-wizard │ JSON │ /api/validate ProblemValidator │ │ users, sign-in (tenant) │
│ steps 0-7, Results, │──────▶ │ /api/check FeasibilityCheck (+ trial)│ │ wizard form fields per │
│ Compare │ │ /api/solve SolverService → Timefold │ │ problem type, config │
│ Excel import/export │◀────── │ /api/version, /api/health │ └─────────────────────────┘
└──────────────────────┘ └──────────────────────────────────────────┘ ▲
│ shared files (results page, rules, texts) │ wizard reads
▼ │ fields + config
PowerOrchestrator UI and API (pipelines call /api/check and /api/solve)
- The engine is stateless: each request carries the whole problem;
/api/solveanswers on the same request (no stored job). Up tomax-concurrent-solves(default 4) solves run at once (SolveGate). - A problem is a JSON document (collections, variables, optional shadows and derived shadows, constraints, config). At run time the engine generates the Timefold domain classes for it (ByteBuddy) and builds the constraints from the rule catalogue. No Java is written per problem.
- The wizard has no build step. It reads its form fields and configuration from PowerAdmin, and posts to the engine.
2. Repository layout
PowerSolver/
├── powersolver-api/ engine (Maven)
│ ├── src/main/java/com/powersolver/
│ │ ├── controller/ SolverController (/api/validate, /api/check, /api/solve), ProblemController (/api/problems…), ResponseEnvelope, SolveGate, Health, Version
│ │ ├── store/ Database (SQLite + migrations), ProblemStore, AuditLog, Ids, StoreConfig
│ │ ├── schema/ ProblemParser → Problem, VariableDefinition, ConstraintDefinition, shadows
│ │ ├── validation/ ProblemValidator → ValidationIssue (code, path, message)
│ │ ├── rules/ RuleCatalog (reads resources/rules/catalog.json), ParamSpec, Values (value conventions)
│ │ ├── generation/ DomainAssembler and generators: element, host, value pools, solution, derived shadows
│ │ ├── expression/ derived-shadow expression language (parser, evaluator, interceptor)
│ │ ├── constraint/ ConstraintBuilder (one builder per rule), dynamic ConstraintProvider factory
│ │ ├── service/ SolverService, SearchSettings, InitialSolutionBuilder, ScoreBreakdown,
│ │ │ SolutionResultBuilder, FeasibilityCheck + FeasibilityChains + FeasibilityCounting
│ │ └── adapter/, model/, config/
│ ├── src/main/resources/ application.yml (limits, store path), rules/catalog.json (the 19 rules), db/migrations/*.sql
│ ├── test-cases/ corpus of problems + assertions (run against a deployed server)
│ └── Dockerfile, docker-compose.yml
├── powersolver-verify/ independent checker in Python: verify_solution.py, rule examples, generators, pytest
├── powersolver-wizard/ wizard, Results, Compare (static files)
│ ├── js/steps/ step0 (AI intake) … step7 (check and plan)
│ ├── js/components/ data-import (Excel/CSV), field-renderer, card-grid, slot-generator, modals, PowerAI UI
│ ├── js/*.js see §4
│ ├── locales/ en, fr, de, es, nl
│ ├── tests/ node tests (§6)
│ └── tools/ check-i18n, stamp-version, sync-shared, make-demo-kit
├── powersolver-demos/ 20 demos: generators, problems, solutions, Excel kits (input/), scenarios, expected results
├── powersolver-template/ problem-type starter definitions
├── timefold-templates/ sequence-mode starter specs
├── scripts/quality-gate.* full gate (§6.4)
└── documentation/
3. Engine
3.1 Request flow
ProblemParser.parseturns the JSON into aProblemand runsProblemValidator. Any issue → HTTP 400 with a list of{code, path, message}(on/api/check: verdictINVALID)./api/check→FeasibilityCheck.check(problem)(no solving): data that breaks a rule by itself, empty windows, dependency loops, pins that break a rule, items with no option, chains that cannot fit, supply against demand (Hall's condition). Each finding has acode, the rule, an Englishmessage,itemsandparams(the facts, used by clients to write the sentence in their language). With?trialSeconds=Na short solve runs when nothing is impossible./api/solve→SolverService:DomainAssemblergenerates the classes,ConstraintBuilderbuilds one Timefold constraint per rule,SearchSettingsconfigures the search (construction heuristic, local search, combined moves for 2+ decisions,unimprovedSeconds,maxSteps),InitialSolutionBuilderapplies pins, Timefold solves,SolutionResultBuilderandScoreBreakdownwrite assignments, shadows and the per-rule score.ResponseEnvelopewraps every answer:jobId, status (success | partial | failed), errors[], payload, trace.
3.2 Rules — three implementations that must agree
Every rule is implemented three times, on purpose:
| Where | Role |
|---|---|
ConstraintBuilder | what the solver optimises (Timefold constraint streams) |
ScoreBreakdown | counts each rule's violations in a solution (Results page, feasibility of pins) |
powersolver-verify/verify_solution.py | an independent Python re-implementation that re-checks solutions |
catalog.json is the single description of each rule (parameters, kinds, counting text); RULE_REFERENCE.md is generated from it (python -I powersolver-verify/render_rule_reference.py).
Rules that accept resource_property (resource_unavailable, skill_match, capacity) can point at a resource fixed on each item (a reference list) instead of a decision; ProblemValidator.checkResourceSource requires exactly one of variable_field / resource_property.
3.3 Adding a rule — checklist
rules/catalog.json: id, label, sentence, counting, default severity, variable types, parameters (kind, value type, required, missing-on-item behaviour).ConstraintBuilder: the builder, registered in the switch.RuleCatalogTestchecks that required/optional parameters match what the builder reads.ScoreBreakdown: the counting, identical to the builder.verify_solution.py: the same counting in Python; add examples inpowersolver-verify/rule-examples/.ProblemValidator: any data check that makes the rule meaningless if wrong.FeasibilityCheck: add the rule toMONOTONE_RULESif a violation among pinned items can never be repaired; add a check if the data alone can make the rule impossible.- Wizard
js/rule-catalog.js: the rule, its parameters and value types (rule-catalog.test.mjscompares with the engine catalogue); labels and descriptionsrules.<id>.*in the 5 locales. - Regenerate
RULE_REFERENCE.md; runnode tools/sync-shared.mjs(the catalogue is shared with PowerOrchestrator). - Run the quality gate (§6.4).
3.4 Limits (application.yml)
max-items 5,000 per collection (→ 400 TOO_LARGE), max-timeout-seconds 600 (→ 400 BAD_CONFIG), max-concurrent-solves 4 (→ 429 BUSY, Retry-After: 30).
4. Wizard
| Module | Role |
|---|---|
wizard-state.js | Alpine store: problem type, entities, variables (decisions) with their values, reference lists, constraints, pins, config |
app.js | boot, navigation, importProblem (JSON → store), exportProblem, step bar |
steps/step1..7.js | the steps; step7._buildJson writes the problem JSON |
spec-shape.js | reads the shape of a problem (entity collection, decisions) from JSON or from the store |
rule-catalog.js | the rules the wizard may offer, their parameters and pickers (mirrors the engine catalogue) |
components/data-import.js | Excel/CSV/paste import: header mapping, value conversion as the engine reads values, row/column problems, workbook import |
workbook-setup.js | the plan workbook: Setup sheet ↔ problem skeleton, what a loaded workbook holds per step, Fill all steps now, step banners, template sheets (also used by tools/make-demo-kit.mjs) |
rule-import.js | Rules sheet ↔ constraints, checked against steps 1-3 with the same options as the step 4 form |
services/plans-api.js | PlansAPI: the stored plans (/api/problems…); Plan it stores the problem (sessionStorage.psProblemId) and solves it through /api/problems/{id}/solve, falling back to /api/solve on an engine without stored plans |
feasibility-check.js | the Can this work? panel: calls /api/check, writes each finding from its params in the user's language, quick plan |
feedback.js | one registry of every user-facing check and error: severity (block / review / info), the wizard step that fixes it, words in locales/*.json → feedback; turns engine JSON paths into "step 4: Rules › rule 'X' › setting 'y'" |
result-model.js, result-view.js | Results page: verdict, KPIs, checks (Check before you use it), plan grid; shared with PowerOrchestrator |
plan-calendar.js | calendar over the planning period, Excel export of grids |
replan-diff.js | Changes tab and compare.html: what changed between plans and in their data |
solution-common.js, run-files.js | shared helpers, downloading problem/solution files |
Form fields per problem type (PowerAdmin wizard_fields): one row per field the wizard shows, keyed by problem_type, time_grain (assignment or sequence), step_number (2 = items, 3 = options) and entity_or_value_type (Standard, or Temporal for time-slot lists); then field_name, field_type (text, number, select, checkbox, datetime-local), label, placeholder, help text, is_required, is_visible, sort_order. A required field makes the Excel import refuse a sheet without that column. Columns that match no field are kept as extra properties. Changing the fields is a PowerAdmin data change, not a wizard release.
User-facing messages: never write a sentence for a check or an engine code in a page. Add the code to feedback.js (severity, fix step) and its words under feedback.<code> in the 5 locales; feedback.test.mjs fails otherwise (it also reads the engine sources for codes the engine can send).
Shared with PowerOrchestrator: node tools/sync-shared.mjs copies the shared files and the results and feedback texts to PowerOrchestrator/powerorchestrator-ui, and the English feedback texts to powerorchestrator-api/app/engine/solver_feedback_en.json. --check only reports drift; shared-files.test.mjs fails on drift.
Themes and design tokens (full reference: Layout Guide): two themes, Midnight + Violet (violet, default) and Charcoal + Amber (amber), meant as the design system of all PowerXXXX products (applied to PowerSolver only so far).
css/theme.cssis the only place colours, the font (Plus Jakarta Sans), type sizes, spacing, radii and shadows are defined: palette tokens per theme (--ps-bg,--ps-surface,--ps-surface-raised,--ps-surface-selected,--ps-border,--ps-border-strong,--ps-border-input,--ps-text,--ps-text-secondary,--ps-text-muted,--ps-accentand its hover / active / soft / text / on-accent variants), status colours, and Bootstrap mapped onto them. Older names (--wizard-*,--primary-color...) are aliases. Components use tokens only, never a raw colour.js/theme.js(loaded in every page's<head>) sets<html data-theme>before the page paints, keeps the choice inlocalStorage(ppt_theme, likeppt_language), updates the open pages and mounts the switch (an accessible radio group) in the sidebar. A theme changes only the look, never the plan or its settings.- Logo: one mark per theme (
logo-violet.svg,logo-amber.svg; inline symbols inindex.html, shown by theme). - Contrast (WCAG AA) is designed in: text 4.5:1 on every surface (
--ps-accent-textfor accent-coloured text), form field edges 3:1 (--ps-border-input). - Files shared with PowerOrchestrator write
var(--ps-x, <old colour>)orcolor-mix(... var(--ps-x, <old colour>) ...), so they follow the theme here and look unchanged there; canvas charts read tokens at draw time (themeColourinsolution-common.js). Data palettes (category and priority colours) stay theme-independent.
Before shipping wizard changes: node tools/check-i18n.mjs (every key used exists in every locale) and node tools/stamp-version.mjs (cache-busting ?v= on script tags).
Stored plans (SQLite)
store/Database opens powersolver.store.path (default /data/powersolver.db) with WAL, foreign keys and a 5 s busy timeout on every connection, and applies db/migrations/NNNN_name.sql in order at start (table schema_migrations). A released migration is never edited: a change is a new numbered file. Writes go through Database.inTransaction (one writer at a time). Ids are UUIDv7 text, dates ISO-8601 UTC, every business row has created_at/by, updated_at/by, owner, deleted_at/by; audit_log records every change (actor_verified 0 until the product has its own users). These conventions are shared with PowerForecast and PowerDecision, so the users / roles and AI tables planned later can be added the same way in the three products.
5. Running locally
cd powersolver-api
mvn -q -o package -DskipTests
java -jar target/powersolver-api-2.0.0.jar --server.port=8091
Serve the wizard with any static server (python -m http.server 8092 in powersolver-wizard), sign in with a PowerAdmin account, and point it at a local engine with ?solver=http://localhost:8091 or localStorage.setItem('powersolver_solver_url_override', 'http://localhost:8091').
Build a demo workbook from any problem: node powersolver-wizard/tools/make-demo-kit.mjs <problem.json>.
6. Tests
| What | Command | Covers |
|---|---|---|
| Engine | cd powersolver-api && mvn -q -o test | parser, validator, every rule, feasibility checks (incl. real demo cases), controller, search |
| Engine, all demos solved and exported | mvn -o -q test -Ddifferential=true -Dtest=DifferentialExportTest | solves every shipped and generated problem |
| Independent checker | cd powersolver-verify && python -m pytest -q | re-checks every exported solution and the rule examples; the rule reference is up to date |
| Wizard | cd powersolver-wizard && for t in tests/*.test.mjs; do node $t; done | imports, rules sheet, workbook kits (every demo kit gives its shipped problem), messages on every demo in 5 languages, feasibility panel, Results, replan diff, shared files |
| Full gate | scripts/quality-gate.sh (or .ps1) | generates 500 problems, engine tests, differential export, independent checker |
Details: TESTS_USER_GUIDE.md.
7. Conventions
- Java 21; no Lombok; constraint code mirrors the catalogue's counting text.
- JavaScript: no framework beyond Alpine; pure functions where possible so that
nodetests can load them in avm. - Every user-facing sentence goes through the locales (5 languages); the English fallback in code is the same text.
- A plan or a check that cannot see a problem says so (e.g. Can this work? "no problem found — not a guarantee").
- Timefold upgrades follow TIMEFOLD_UPGRADES.md.
- Documentation: the Markdown in
documentation/is the only source; after editing it runpython -I documentation/tools/build_html.pyto regeneratedocumentation/html/. A temporary plan or study does not belong indocumentation/: fold its lasting content into the guides.