Scheduling recipes

Wiki / Graph data science / Native Scheduler

On this page

Each recipe creates its own small dataset, solves it with gdb.schedule.solve and shows what to do with the result. Run each one in an empty database, one statement at a time. Times are minutes from midnight unless a recipe uses datetime values. The names are invented.

Home care visits

Visits have a preferred time and a place, some need two people, and regular clients prefer people who have visited them before. Nobody works more than three hours without a 30-minute break.

CREATE (ann:Staff {name:'Ann', start:420, end:900, location:point({x:0, y:0})}),
       (raj:Staff {name:'Raj', start:420, end:900, location:point({x:4000, y:0})}),
       (mei:Staff {name:'Mei', start:600, end:1080, location:point({x:0, y:4000})}),
       (c1:Client {name:'Client 1'}), (c2:Client {name:'Client 2'}),
       (ann)-[:VISITED]->(:Visit {name:'old visit'})-[:FOR]->(c1),
       (:Visit {name:'wash', target:480, duration:30, location:point({x:500, y:800})})-[:FOR]->(c1),
       (:Visit {name:'lunch', target:720, duration:30, location:point({x:500, y:800})})-[:FOR]->(c1),
       (:Visit {name:'hoist', target:540, duration:45, people:2, location:point({x:3000, y:1000})})-[:FOR]->(c2),
       (:Visit {name:'meds', target:600, duration:15, location:point({x:2000, y:3000})})-[:FOR]->(c2),
       (:Visit {name:'tea', target:1020, duration:30, location:point({x:1000, y:2500})})-[:FOR]->(c2);
MATCH (v:Visit) WHERE v.duration IS NOT NULL WITH collect(v) AS visits
MATCH (s:Staff) WITH visits, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: visits, resources: staff, randomSeed: 1,
  task: {required: 'coalesce(t.people, 1)'},
  pair: {affinity: 'size([(r)-[:VISITED]->(:Visit)-[:FOR]->(c)<-[:FOR]-(t) | 1])'},
  constraints: [{type: 'maxContinuous', amount: 180, breakLength: 30}]
})
YIELD task, resources, start, end
UNWIND resources AS person
CREATE (person)-[:BOOKED {start: start, end: end}]->(task)
RETURN task.name, person.name, start ORDER BY start;

wash and lunch go to Ann because she has visited Client 1 before; hoist is booked on two people at one start.

Guaranteed hours and fair rotas

Each person is guaranteed at least four hours, nobody works more than eight in a day, and long idle gaps cost something. The guarantee is a soft rule with a penalty, so the plan comes as close as the work allows.

UNWIND ['Ola', 'Pat', 'Rui'] AS name
CREATE (:Staff {name: name, start: 360, end: 1320});
UNWIND range(0, 11) AS i
CREATE (:Shift {name: 'shift ' + i, duration: 120, target: 360 + (i % 6) * 150});
MATCH (s:Shift) WITH collect(s) AS shifts
MATCH (p:Staff) WITH shifts, collect(p) AS staff
CALL gdb.schedule.solve.stats({
  tasks: shifts, resources: staff, randomSeed: 1,
  constraints: [
    {type: 'minWork', amount: 240, penalty: 10},
    {type: 'maxWork', amount: 480, period: 1440},
    {type: 'idle', penalty: 0.1}
  ],
  objective: ['unassigned', 'penalty', 'workload', 'drift']
})
YIELD tasksAssigned, objective
RETURN tasksAssigned, objective.penalty, objective.workload;

Dance competition

Two stages with two sessions each. Dancers are participants, so the data decides who is in each dance, and every dancer needs 15 minutes between dances. A stage needs 5 minutes to change style, the heat ends an hour before the final, and nobody dances more than three times in a session.

CREATE (main:Stage {name:'Main stage', windows:[[540, 720], [780, 1020]]}),
       (studio:Stage {name:'Studio', windows:[[540, 720], [780, 1020]]}),
       (ava:Dancer {name:'Ava'}), (ben:Dancer {name:'Ben'}), (cleo:Dancer {name:'Cleo'}),
       (heat:Dance {name:'Latin heat', minutes:15, style:'latin'}),
       (final:Dance {name:'Latin final', minutes:15, style:'latin'}),
       (waltz:Dance {name:'Waltz', minutes:10, style:'ballroom'}),
       (trio:Dance {name:'Trio', minutes:20, style:'street'}),
       (ava)-[:DANCES_IN]->(heat), (ben)-[:DANCES_IN]->(heat),
       (ava)-[:DANCES_IN]->(final), (ben)-[:DANCES_IN]->(final),
       (ava)-[:DANCES_IN]->(waltz), (cleo)-[:DANCES_IN]->(waltz),
       (ava)-[:DANCES_IN]->(trio), (ben)-[:DANCES_IN]->(trio), (cleo)-[:DANCES_IN]->(trio);
MATCH (d:Dance) WITH collect(d) AS dances
MATCH (s:Stage) WITH dances, collect(s) AS stages
MATCH (heat:Dance {name:'Latin heat'}), (final:Dance {name:'Latin final'})
CALL gdb.schedule.solve({
  tasks: dances, resources: stages, randomSeed: 1,
  task: {duration: 't.minutes', kind: 't.style',
         participants: '[(p:Dancer)-[:DANCES_IN]->(t) | p]'},
  participant: {changeover: 15},
  setups: [{from: '*', to: '*', time: 5}],
  constraints: [
    {type: 'precedence', tasks: [heat, final], minGap: 60},
    {type: 'maxTasks', per: 'participant', count: 3, period: 240}
  ]
})
YIELD task, resource, start, end
RETURN task.name, resource.name, start, end ORDER BY start;

Hospital theatre list

Every operation needs a surgeon of the right speciality, an anaesthetist and a theatre at once. Theatres need 20 minutes of cleaning between cases. The emergency is fixed in place and has the highest priority.

CREATE (:Staff:Surgeon {name:'Dr Hale', speciality:'ortho', start:480, end:1080}),
       (:Staff:Surgeon {name:'Dr Ito', speciality:'general', start:480, end:1080}),
       (:Staff:Anaesthetist {name:'Dr Kerr', start:480, end:1080}),
       (:Staff:Theatre {name:'Theatre 1', start:480, end:1080, changeover:20}),
       (:Staff:Theatre {name:'Theatre 2', start:480, end:1080, changeover:20}),
       (:Op {name:'hip', speciality:'ortho', duration:90, target:480}),
       (:Op {name:'knee', speciality:'ortho', duration:60, target:480}),
       (:Op {name:'hernia', speciality:'general', duration:45, target:480}),
       (:Op {name:'appendix', speciality:'general', duration:60, fixedStart:780, priority:10});
MATCH (o:Op) WITH collect(o) AS ops
MATCH (s:Staff) WITH ops, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: ops, resources: staff, randomSeed: 1,
  needs: [
    {count: 1, where: 'r:Surgeon AND r.speciality = t.speciality'},
    {count: 1, where: 'r:Anaesthetist'},
    {count: 1, where: 'r:Theatre'}
  ]
})
YIELD task, resources, start, end
RETURN task.name, [r IN resources | r.name] AS team, start, end ORDER BY start;

One anaesthetist means no two operations overlap, whichever theatre they use.

Exam timetable

Exams use as many seats as they have students, so a hall holds several at once. No cohort sits two exams at the same time, cohorts get 30 minutes between exams and at most two a day, and starts snap to the half hour. The days are datetime values.

CREATE (:Hall {name:'Great Hall', seats:120,
               windows:[{start: datetime('2026-06-01T09:00:00Z'), end: datetime('2026-06-01T17:00:00Z')},
                        {start: datetime('2026-06-02T09:00:00Z'), end: datetime('2026-06-02T17:00:00Z')}]}),
       (a:Cohort {name:'History'}), (b:Cohort {name:'Maths'}), (c:Cohort {name:'Physics'}),
       (e1:Exam {name:'Paper 1', students:80, duration: duration({hours: 2})}),
       (e2:Exam {name:'Paper 2', students:40, duration: duration({hours: 2})}),
       (e3:Exam {name:'Paper 3', students:60, duration: duration({minutes: 90})}),
       (e4:Exam {name:'Paper 4', students:60, duration: duration({hours: 3})}),
       (a)-[:SITS]->(e1), (b)-[:SITS]->(e2), (b)-[:SITS]->(e3), (c)-[:SITS]->(e3),
       (a)-[:SITS]->(e4), (c)-[:SITS]->(e4), (b)-[:SITS]->(e4);
MATCH (e:Exam) WITH collect(e) AS exams
MATCH (h:Hall) WITH exams, collect(h) AS halls
CALL gdb.schedule.solve({
  tasks: exams, resources: halls, randomSeed: 1,
  task: {demand: 't.students', participants: '[(c:Cohort)-[:SITS]->(t) | c]'},
  resource: {units: 'r.seats'},
  participant: {changeover: duration({minutes: 30})},
  timeStep: duration({minutes: 30}),
  constraints: [{type: 'maxTasks', per: 'participant', count: 2, period: duration({days: 1})}]
})
YIELD task, start, end
RETURN task.name, toString(start), toString(end) ORDER BY start;

Sports fixtures

Teams are participants that need three days' rest between matches. Pitches are resources, each team plays at most once per round of a week, and every fixture aims for the start of the season, so the rounds come as early as the rules allow.

UNWIND ['Lions', 'Owls', 'Foxes', 'Hares'] AS name CREATE (:Team {name: name});
CREATE (:Pitch {name:'North pitch'}), (:Pitch {name:'South pitch'});
MATCH (a:Team), (b:Team) WHERE a.name < b.name
CREATE (m:Fixture {name: a.name + ' v ' + b.name, duration: duration({hours: 2})}),
       (a)-[:PLAYS]->(m), (b)-[:PLAYS]->(m);
MATCH (m:Fixture) WITH collect(m) AS matches
MATCH (p:Pitch) WITH matches, collect(p) AS pitches
CALL gdb.schedule.solve({
  tasks: matches, resources: pitches, randomSeed: 1,
  horizon: {start: datetime('2026-09-05T00:00:00Z'), end: datetime('2026-10-03T00:00:00Z')},
  task: {participants: '[(team:Team)-[:PLAYS]->(t) | team]',
         target: datetime('2026-09-05T00:00:00Z')},
  participant: {changeover: duration({days: 3})},
  timeStep: duration({days: 1}),
  constraints: [{type: 'maxTasks', per: 'participant', count: 1, period: duration({days: 7})}]
})
YIELD task, resource, start
RETURN task.name, resource.name, toString(start) ORDER BY start;

Conference programme

Talks go in rooms, speakers are never in two places at once, and the keynote needs every room so nothing runs alongside it.

CREATE (r1:Room {name:'Room A', start:540, end:1020}),
       (r2:Room {name:'Room B', start:540, end:1020}),
       (ana:Speaker {name:'Ana'}), (bo:Speaker {name:'Bo'}),
       (k:Talk {name:'Keynote', duration:60, target:540, rooms:2}),
       (t1:Talk {name:'Graphs at scale', duration:45}),
       (t2:Talk {name:'Rotas from Cypher', duration:45}),
       (t3:Talk {name:'Ask me anything', duration:30}),
       (ana)-[:GIVES]->(k), (ana)-[:GIVES]->(t1), (bo)-[:GIVES]->(t2), (ana)-[:GIVES]->(t3), (bo)-[:GIVES]->(t3);
MATCH (t:Talk) WITH collect(t) AS talks
MATCH (r:Room) WITH talks, collect(r) AS rooms
CALL gdb.schedule.solve({
  tasks: talks, resources: rooms, randomSeed: 1,
  task: {required: 'coalesce(t.rooms, 1)', participants: '[(s:Speaker)-[:GIVES]->(t) | s]',
         earliest: 't.target'}
})
YIELD task, resources, start
RETURN task.name, size(resources) AS rooms, start ORDER BY start;

Clinic appointments

The clinic has two bays, so at most two appointments run at once whichever clinician takes them. Appointments that need the scanner go to clinicians trained on it, and the follow-up comes 60 to 120 minutes after the first appointment.

CREATE (bays:Clinic {name:'Clinic', units:2}),
       (scan:Skill {name:'scanner'}),
       (:Clinician {name:'Nurse Ola', start:480, end:720})-[:HAS]->(scan),
       (:Clinician {name:'Nurse Pim', start:480, end:720}),
       (:Clinician {name:'Dr Quist', start:480, end:720})-[:HAS]->(scan),
       (a:Appointment {name:'scan', duration:30, target:480})-[:NEEDS]->(scan),
       (b:Appointment {name:'review', duration:20, target:480}),
       (c:Appointment {name:'bloods', duration:15, target:480}),
       (f:Appointment {name:'follow-up', duration:20}),
       (a)-[:AT]->(bays), (b)-[:AT]->(bays), (c)-[:AT]->(bays), (f)-[:AT]->(bays);
MATCH (a:Appointment) WITH collect(a) AS appts
MATCH (c:Clinician) WITH appts, collect(c) AS clinicians
MATCH (first:Appointment {name:'scan'}), (next:Appointment {name:'follow-up'})
CALL gdb.schedule.solve({
  tasks: appts, resources: clinicians, randomSeed: 1,
  task: {place: '[(t)-[:AT]->(c) | c][0]'},
  participant: {units: 'p.units'},
  pair: {eligible: 'all(s IN [(t)-[:NEEDS]->(s) | s] WHERE EXISTS { (r)-[:HAS]->(s) })'},
  constraints: [{type: 'precedence', tasks: [first, next], minGap: 60, maxGap: 120}]
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;

Factory floor

Jobs run on lines with sequence-dependent setup times: dark to light paint takes longest. Each job has a due time, and lateness costs more the later it is, while finishing early costs almost nothing.

CREATE (:Line {name:'Line 1', start:360, end:1320}), (:Line {name:'Line 2', start:360, end:1320});
UNWIND range(0, 9) AS i
CREATE (:Job {name: 'job ' + i, minutes: 45 + (i % 3) * 15, due: 480 + i * 60,
              colour: ['white', 'grey', 'black'][i % 3]});
MATCH (j:Job) WITH collect(j) AS jobs
MATCH (l:Line) WITH jobs, collect(l) AS lines
CALL gdb.schedule.solve({
  tasks: jobs, resources: lines, randomSeed: 1,
  task: {duration: 't.minutes', target: 't.due - t.minutes', kind: 't.colour', earlyWeight: 0.02},
  lateCurve: {points: [[10, 2.5], [20, 15], [60, 100]], smooth: true},
  setups: [{from: 'white', to: 'grey', time: 20}, {from: 'grey', to: 'black', time: 15},
           {from: 'black', to: 'grey', time: 40}, {from: 'black', to: 'white', time: 60},
           {from: 'grey', to: 'white', time: 40}, {from: 'white', to: 'black', time: 25}]
})
YIELD task, resource, start, drift
RETURN task.name, resource.name, start, drift ORDER BY resource.name, start;

Field service with overtime

Engineers hold certifications, and a senior certification covers the junior one. Evening hours cost more, overtime past six hours is allowed at a rising price, and jobs are reached by travel between their locations.

CREATE (senior:Cert {name:'gas senior'})-[:IMPLIES]->(junior:Cert {name:'gas'}),
       (e1:Engineer {name:'Tia', location:point({x:0, y:0}),
                     windows:[{start:480, end:1020}, {start:1020, end:1200, costRate:1.5}]})-[:HAS]->(senior),
       (e2:Engineer {name:'Uri', location:point({x:8000, y:0}),
                     windows:[{start:480, end:1020}, {start:1020, end:1200, costRate:1.5}]})-[:HAS]->(junior),
       (:Job {name:'boiler service', duration:60, target:540, location:point({x:1000, y:500})})-[:REQUIRES]->(junior),
       (:Job {name:'gas leak', duration:90, target:600, priority:5, location:point({x:7000, y:800})})-[:REQUIRES]->(senior),
       (:Job {name:'meter swap', duration:45, target:840, location:point({x:4000, y:4000})})-[:REQUIRES]->(junior);
MATCH (j:Job) WITH collect(j) AS jobs
MATCH (e:Engineer) WITH jobs, collect(e) AS engineers
CALL gdb.schedule.solve({
  tasks: jobs, resources: engineers, randomSeed: 1,
  pair: {eligible: 'all(c IN [(t)-[:REQUIRES]->(c) | c] WHERE EXISTS { (r)-[:HAS]->(:Cert)-[:IMPLIES*0..]->(c) })'},
  constraints: [{type: 'maxWork', amount: 360, curve: {slope: 2, after: 0}}],
  objective: ['unassigned', 'penalty', 'cost', 'drift', 'travel']
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;

The gas leak can only go to Tia, whose senior certification implies the junior one.

Re-planning after a change

Bookings already exist as relationships. When someone is unavailable, the baseline keeps every booking that still works, so only the affected tasks move.

CREATE (a:Staff {name:'Vic', start:480, end:1020}), (b:Staff {name:'Wen', start:480, end:1020, sick:true}),
       (c:Staff {name:'Xia', start:480, end:1020}),
       (a)-[:BOOKED {start:540}]->(:Task {name:'one', duration:60, target:540}),
       (b)-[:BOOKED {start:600}]->(:Task {name:'two', duration:60, target:600}),
       (c)-[:BOOKED {start:660}]->(:Task {name:'three', duration:60, target:660});
MATCH (t:Task) WITH collect(t) AS tasks
MATCH (s:Staff) WITH tasks, collect(s) AS staff
CALL gdb.schedule.solve({
  tasks: tasks, resources: staff, randomSeed: 1,
  task: {baselineStart: '[(r)-[b:BOOKED]->(t) | b.start][0]',
         baselineResources: '[(r)-[:BOOKED]->(t) | r]'},
  resource: {windows: 'CASE WHEN r.sick THEN [] ELSE [{start: r.start, end: r.end}] END'}
})
YIELD task, resource, start
RETURN task.name, resource.name, start ORDER BY start;

Only two moves; one and three keep their people and times.

Native Scheduler · Graph data science

Planning a deployment? Review compatibility and licence setup for your instance.