Construction scheduling with a CPM engine you can check, and a schedule-quality score that tells you whether to trust it.
Most schedule tools assume the schedule they are given is sound. Most are not. BBSchedule computes the critical path properly, then grades the schedule against the DCMA 14-Point Assessment and refuses to optimise one that fails.
git clone https://github.com/ibuilder/AIHackScheduler.git
cd AIHackScheduler
pip install -r requirements.txt
export SESSION_SECRET=dev-secret # Windows: set SESSION_SECRET=dev-secret
flask --app app db upgrade
flask --app app seed-demo
flask --app app runNo PostgreSQL, no Redis, no Azure account. Development falls back to SQLite and every
cloud integration is optional. Open http://localhost:5000 and sign in as
demo / demo1234.
seed-demo loads a 25-activity commercial fit-out. It is deliberately imperfect — a
dangling activity, a lead, excess lags, unresourced work — so the schedule assessment has
real defects to report:
Grade F | score 50.0 | data date 2026-11-23 | baseline present
PASS 1. Logic 1 of 25 activities are dangling
FAIL 2. Leads 1 relationship uses a lead; leads hide true logic
FAIL 3. Lags 3 of 27 relationships carry a lag
FAIL 4. Relationship Types 24 of 27 relationships are FS
PASS 5. Hard Constraints 1 activity is pinned by a date constraint
FAIL 6. High Float 7 activities carry more than 44 days of float
PASS 7. Negative Float 0 activities cannot meet their required dates
PASS 8. High Duration 1 activity runs longer than a reporting quarter
PASS 9. Invalid Dates 0 activities record work completed after the data date
FAIL 10. Resources 5 of 24 working activities carry no cost or resource
FAIL 11. Missed Tasks 2 of 4 completed activities slipped past baseline
PASS 12. Critical Path Test A 600-day delay moved the finish by 600 days
PASS 13. Critical Path Length Index CPLI of 1.000 on a 271-day critical path
FAIL 14. Baseline Execution Index 4 of 7 activities due by the data date are complete
All fourteen run, because the demo carries a baseline and recorded actuals. On a project with neither, checks 9, 11 and 14 report as skipped rather than passing — a score is only as honest as what it admits it could not measure.
Scheduling engine — core/
Pure Python, no Flask or SQLAlchemy imports, fully unit tested.
- Critical Path Method — topologically ordered forward and backward passes; all four relationship types (FS, SS, FF, SF) with positive or negative lag; total float and free float computed separately; cycle detection that names the loop; a driving-path walk that returns a connected chain rather than a bag of zero-float activities.
- Working calendars — configurable working weekdays and holidays. Finish dates are the last day worked, which is how planners read them.
- DCMA 14-point assessment — all fourteen checks, including the ones needing a baseline and recorded actuals.
- Progress measurement — Baseline Execution Index, finish variance against baseline, and forecast slippage. Its "behind" set is deliberately broader than DCMA check 11: that check counts only completed-but-late activities, so work that was due and never started is invisible to it — which is the more urgent problem.
- Monte Carlo risk — three-point duration estimates sampled through the network thousands of times, giving P10/P50/P80/P90 completion dates, the measured probability of meeting the deterministic date, and a criticality index per activity. Seeded, so the same schedule gives the same answer every run.
from core import Activity, Relationship, RelationType, calculate_cpm, assess_schedule
activities = [
Activity("A", "Excavate", 5),
Activity("B", "Pour footings", 8),
Activity("C", "Cure", 7),
]
links = [
Relationship("A", "B"),
Relationship("B", "C", RelationType.FS, lag=2),
]
result = calculate_cpm(activities, links)
print(result.project_duration) # 22
print(result.critical_path) # ['A', 'B', 'C']
print(result.activities["B"].total_float) # 0
report = assess_schedule(activities, links)
print(report.grade, report.is_optimisable)A Task carries CPM fields, station_start/station_end for linear (time–distance)
scheduling, and pull_plan_week for Last Planner–style pull planning. A contractor running
a linear infrastructure job alongside a vertical build does not need two products that
cannot talk to each other.
Schedule analysis, optimisation, and completion forecasting run CPM and the quality assessment first, then pass the results into the prompt as given. The model interprets; it does not calculate. The system prompt forbids claiming a saving on an activity that is not on the critical path, and the optimiser refuses to run at all against a schedule whose logic fails the blocking checks.
Optimising an unsound network produces confident nonsense. This is the difference between AI that helps and AI that launders guesswork.
Real schedules live in Primavera P6 and Microsoft Project. Nothing serious enters a scheduling tool through a web form.
| Format | Read | Write | |
|---|---|---|---|
Primavera XER (.xer) |
yes | yes | Pure Python, no dependency |
MS Project XML (.xml, MSPDI) |
yes | yes | Pure Python, no dependency |
MS Project (.mpp) |
yes* | no | *needs pip install -e ".[mpp]" and a JVM |
.mpp cannot be written — by anyone. The binary format is proprietary and
only partly understood;
MPXJ's own maintainer says so. Microsoft's
supported interchange for writing is MSPDI XML, which Project opens directly
and can then save as .mpp. Export MSPDI and you have your round trip.
flask --app app import-schedule plan.xer --company 1 --user 1
flask --app app export-schedule 1 --format mspdi --out plan.xml
flask --app app schedule-formats # what this deployment can actually doThe readers get the four things wrong importers get wrong. P6 writes
relationship types as PR_SS, not SS — store the raw string and every
start-to-start tie silently becomes finish-to-start. Durations are hours
against a calendar that is not always eight hours a day. Milestones are
zero-duration and clamping them to one day moves every downstream date. Costs
live on TASKRSRC and notes on TASKMEMO, not on TASK. Each has a named
test.
Round trips are tested by exporting, re-importing, and asserting the computed schedule has not moved — same duration, same finish, same driving path.
Anything a reader could not map is returned as a warning rather than dropped.
All endpoints are company-scoped; a request for another tenant's project is indistinguishable from a request for one that does not exist.
| Endpoint | Returns |
|---|---|
GET /api/schedule/projects/<id>/cpm |
Every activity with early/late dates, total float, free float, criticality |
GET /api/schedule/projects/<id>/health |
DCMA 14-point assessment, grade, and per-check offenders |
GET /api/schedule/projects/<id>/critical-path |
The driving path only, for chart overlays |
GET /api/schedule/projects/<id>/progress |
Baseline Execution Index, variance, worst slippage |
GET /api/schedule/projects/<id>/risk |
Monte Carlo percentiles and criticality index |
POST /api/schedule/projects/<id>/baseline |
Freeze the current plan as the baseline |
GET /api/financial/aged-receivables |
Outstanding balances bucketed by days overdue |
POST /api/schedule/import |
Upload a .xer, .xml or .mpp as a new project |
GET /api/schedule/projects/<id>/export/<format> |
Download as xer or mspdi |
GET /api/schedule/formats |
What this deployment can read and write |
curl -b cookies.txt http://localhost:5000/api/schedule/projects/1/health | jq '.grade, .score'core/ Pure scheduling algorithms — no Flask, no database
cpm.py Forward/backward passes, float, cycle detection
calendar.py Working-day ↔ calendar-date mapping
schedule_health.py DCMA 14-point assessment
progress.py Baseline Execution Index and variance
risk.py Monte Carlo simulation over the network
exchange.py Format-neutral schedule model
xer.py Primavera XER reader and writer
mspdi.py MS Project XML reader and writer
services/
schedule_analysis.py Bridge between the ORM and core/
schedule_risk.py Simulation against a stored project
schedule_optimizer.py Optimisation, gated on schedule quality
billing.py Payment recording and invoice balances
schedule_io.py File formats bridged to the ORM
azure_ai.py LLM interpretation, grounded in computed CPM
optional.py Lazy loading for every optional integration
blueprints/ Flask routes, one per feature area
models.py SQLAlchemy models, multi-tenant by company
tests/ 284 tests, hand-checked scheduling networks
seed_demo.py A realistic, deliberately imperfect demo project
The layering is the point: core/ knows nothing about the web application, so its
correctness can be established in isolation and its results reused anywhere.
Only two variables are needed to run:
| Variable | Required | Default |
|---|---|---|
SESSION_SECRET |
yes | — (production refuses to start without it) |
DATABASE_URL |
no | sqlite:///bbschedule-dev.db in development |
FLASK_ENV |
no | development |
Everything else is optional and the feature degrades cleanly when absent. See
.env.example.
pip install -e ".[postgres]" # PostgreSQL
pip install -e ".[async]" # Celery + Redis background tasks
pip install -e ".[integrations]" # Azure AI, Power BI, Stripe, Excel import
pip install -e ".[mpp]" # reading binary .mpp (needs a JVM)
pip install -e ".[dev]" # pytest, ruffpip install -e ".[dev]"
pytest # 284 tests
ruff check . # lint
ruff format . # formatCI runs tests on Python 3.11, 3.12 and 3.13, lints, builds the wheel and the Docker image, checks migrations match the models, and boots and seeds the application on every push.
Every dependency carries an upper bound, and the test and lint tooling is pinned. Without
that, a release on someone else's schedule fails a build nobody touched — which had already
started happening: ruff reformatted Markdown in a new minor and broke lint, and
flask-limiter had drifted from 3.x to 4.x unnoticed, working by luck rather than by
contract.
Bounds alone would just freeze the project, so Dependabot proposes raising them weekly. Upgrades then arrive as pull requests that run the full suite, rather than landing silently in the next build.
Its first run made the point better than the argument does. Seven proposals: the GitHub
Actions were three majors behind, the base image was three Python releases behind, and
redis, openai, stripe and mpxj had all published majors the bounds excluded. Two of
those proposals found real defects rather than needing them — see A bump that found a bug
below.
A green build has to mean something, so two things exist to stop it going hollow:
- The optional extras are installed and imported. None of them appear in
requirements.txt, so for a while a proposal widening theopenaibound passed all eight jobs without one line of the affected code being imported. Theextrasjob installs.[postgres,async,integrations,mpp], imports every optional package and boots the application with them present. - The image ships a Python the tests run.
tests/test_deployment.pyfails if the Dockerfile's base image is not in the CI matrix, so production is never the first place an incompatibility appears.
The base image bump failed, and the failure was worth more than the upgrade. The Dockerfile
named its Python version three times — twice on FROM lines and once, thirty lines below, in
COPY --from=builder /usr/local/lib/python3.11/site-packages. Nothing recognises a COPY
path as a version declaration, so any Python bump broke the build. Dependencies now install
into a virtualenv at /opt/venv and the version appears only where it is declared.
The mpp extra exposed something worse. read_mpp and _from_mpxj were written against the
MPXJ API and committed without ever being executed — MPXJ is a Java library reached through
JPype, and no machine here had a JVM, so _from_mpxj even carried a pragma: no cover
admitting it. The first run raised AttributeError: mpxj.initialize() does not exist.
Underneath that was a quieter one. The relationship type was looked up on
str(relation.getType()) with a .get(..., FS) default. MPXJ overrides toString() to a
display form, so the lookup missed every time and the default absorbed it: every
relationship in every .mpp import silently became Finish-Start, whatever the file said.
What caught it is that MPXJ's UniversalProjectReader reads MSPDI XML as well as binary
.mpp. tests/test_mpp.py puts one file through both MPXJ and this project's own
pure-Python reader and compares activities, durations and links — so no .mpp is needed, and
a disagreement means one of the two readers is wrong. It reported three Finish-Start links
where the file's type codes were 1, 3 and 0: FS, SS and FF. The mapping now reads
java.lang.Enum.name(), and an unrecognised type is recorded in schedule.warnings rather
than assumed.
That cross-check has a blind spot: it can only compare the two readers on MSPDI, because
MSPDI is the only format both understand. .mpp is the one format in this project that no
test can generate — nothing writes it but Microsoft Project — so tests/data/example.mpp is
vendored (MIT, from the author of MPXJ; provenance in tests/data/README.md).
The first run of it read three 3-day tasks as 0.38 days each. Duration.getDuration()
returns a bare number in whatever unit the file used — days, here — and the code took it as
hours and divided by hours-per-day, so every duration came out eight times too short.
Relationship lag had the same bug. Neither could show up in the MSPDI comparison, because
MSPDI genuinely does store hours.
It parsed cleanly, warned about nothing, and failed nothing. Durations now go through MPXJ's
convertUnits, and the test asserts what the file says rather than that a number came back:
name='example.mpp' activities=3 relationships=2 warnings=0
1 3.00d Task 1
2 3.00d Task 2
3 3.00d Task 3
project_duration=9 working days, critical_path=['1', '2', '3']
The last line is the one that matters: a real Microsoft Project file, read from binary and scheduled through the CPM engine.
docker compose -f deployment/docker-compose.yml upBrings up the application, PostgreSQL, Redis and the Celery worker and beat scheduler. The
build context is the repository root, so run it from there. Set DB_PASSWORD and
SESSION_SECRET first; add SEED_DEMO=1 to load the demo project on first start.
The container entrypoint runs flask db upgrade before starting, retrying until PostgreSQL
is accepting queries. Workers run with SKIP_DB_INIT=1 so they cannot race the web process.
flask --app app db upgrade # bring a database to the current schema
flask --app app db migrate -m # after changing a model
flask --app app db check # is anything unmigrated?0001_baseline is the schema exactly as it stood at the original commit. 0002_rebuild_schema
carries everything this rebuild added: baseline and actual dates, the data date, schedule
baselines, equipment usage and maintenance, the indexes, and moving project_number from a
global unique constraint to one scoped per company.
If your database predates this work, stamp it against the baseline first, then upgrade:
flask --app app db stamp 0001_baseline
flask --app app db upgradeDo not use init-db for a database you intend to keep. create_all only adds missing
tables — it never alters an existing one — so it would leave old columns and constraints
silently in place. It stays for scratch databases and test fixtures, and stamps itself at
head so a later upgrade does not double-apply.
CI fails if the models change without a migration.
deployment/azure-deploy.yml describes the same stack as Azure Container Apps.
This is working software with a solid core and an unfinished perimeter. Being specific about which is which:
Solid — CPM engine, all fourteen DCMA checks, working calendars, baseline and progress measurement, Monte Carlo risk, multi-tenant isolation, the schedule analysis API, payment recording and invoice balances, equipment utilisation from logged hours.
Working, thin — project and task management, Gantt/linear/pull-planning views, auth and roles, transaction ledger.
No facades left — reports/executive_dashboard.py measured nothing: twelve months of
revenue generated from a 2,500,000 base on a 2% growth curve, a 12% profit margin, four
hardcoded US regions, and per-size bands where small projects were always 15.2% margin.
Identical for every company. It now aggregates transactions and invoices by month, groups
by the location on each project and the template it came from, and returns None with a
stated reason where nothing supports a figure — never zero, because zero profit and unknown
profit are different statements.
It also separates realised margin from budget consumed. On unfinished work
(budget - spend) / budget is money not yet spent, and reporting it as margin made the
least advanced project look like the most profitable: the demo project read "86% margin"
four months into a two-year job.
Not attempted — card processing. Payments are recorded, not taken: no key management, no webhook reconciliation, no PCI scope. The README previously advertised "Stripe integration in progress" against no implementation of any kind.
Every page renders — the nine admin, Azure, project-template and reporting pages that
rendered templates nobody had written are built, and the two analytics endpoints whose
helpers did not exist are implemented. tests/test_all_routes.py walks the URL map and
requests all 90 GET routes signed in; 83 return 200, none return a server error. It walks
the map rather than a list, so a route added tomorrow is covered the day it appears.
Resource optimisation and portfolio insight — /api/ai/resource-optimization/<id>
reports utilisation per resource, names what is over-allocated and by how many units,
prices the excess at each resource's own unit cost, and ranks the moves.
/api/ai/company-insights reports completion rate, spend against approved budget,
throughput trend across six periods, and DCMA health per project. Both are deterministic:
the same data gives the same answer, which is the property a schedule review needs.
Azure OpenAI is not required for either.
The near-term priorities, in order:
- Schedule quality in the UI — grade badges, drill-down to offending activities, and a trend across baseline revisions. The data is all computed; nothing surfaces it yet.
- Resource-levelled scheduling — the optimiser still emits generic advice where it could run a real serial-schedule-generation pass over existing assignments.
- Schedule comparison between baselines — the snapshots are stored; diffing them is where delay analysis starts, and delay analysis is where the money is.
PLAN.md has the full assessment: what was wrong, what the market looks like in
2026, and why schedule quality is the position worth taking.
Issues and pull requests are welcome. Please keep core/ free of Flask and SQLAlchemy
imports, and add a hand-checkable test for any change to the scheduling maths — a
scheduling engine is only trustworthy if its answers can be verified by hand.
By contributing you agree that your contribution is licensed under the MIT licence.
MIT. Use it, fork it, ship it — the only condition is that the copyright notice travels with it.
The repository previously described itself as proprietary software developed for Balfour
Beatty US while carrying no LICENSE file at all, which granted nobody any rights and
asserted an affiliation the code does not evidence. Both are resolved: the licence is MIT
and the branding is the project's own.
Built on the observation that most construction schedules cannot support the decisions made from them, and that this is measurable.