Native Scheduler

Wiki / Graph data science

On this page

The Native Scheduler places tasks in time and on the people, rooms or machines they need: carers on visits, dances on stages, operations in theatres, exams in halls, jobs on machines. It runs inside the database, reads its rules straight from the graph through Cypher expressions, and returns nodes you can write back with ordinary Cypher. See it work in the Native Scheduler demo, and find complete examples in the scheduling recipes.

Schedule in one call

MATCH (v:Visit) WITH collect(v) AS visits
MATCH (c:Carer) WITH visits, collect(c) AS carers
CALL gdb.schedule.solve({tasks: visits, resources: carers})
YIELD task, resource, start, end
CREATE (resource)-[:BOOKED {start: start, end: end}]->(task);

With no other options, a task reads duration and an optional target start, and a resource reads its working window from start and end. Everything else is optional and has a default. tasks and resources may be nodes or plain maps, so a schedule can also come from UNWIND of JSON:

CALL gdb.schedule.solve({
  tasks: [{name:'x', duration:30, target:60}, {name:'y', duration:30, target:60}],
  resources: [{name:'solo', windows:[[0, 240]]}]
}) YIELD task, resource, start RETURN task.name, resource.name, start;

Times can be numbers in any unit you choose, or datetime, localdatetime, date, time or localtime values; amounts can be numbers or duration values. Results come back in the same type. Numbers and temporal values can't be mixed in one schedule.

Timelines

Everything a task occupies is a timeline: something that can hold only so many tasks at once and may need time between them.

TimelineWho chooses itExamples
Resourcethe scheduler, from the task's eligible poolcarer, stage, theatre, exam hall, van, machine
Participantthe datadancer, student, team, patient, speaker
Placethe data (a participant with units)clinic with two bays, a loading dock

Every timeline has:

  • windows: when it is available; with none it is always available;
  • units: how many tasks it holds at once (default 1);
  • changeover: the minimum gap between its consecutive tasks.

Tasks

Every task field is a Cypher expression over the task, bound as t. The defaults read same-named properties.

FieldDefaultMeaning
durationt.durationRequired; positive.
targett.targetPreferred start. Drift from it is a cost.
earliest, latestt.earliest, t.latestHard: start no earlier, end no later.
fixedStartt.fixedStartHard: start exactly here.
priorityt.priority (1)Cost of leaving the task unplaced.
earlyWeight, lateWeight1Weight on early and late drift.
earlyCurve, lateCurvenoneA curve for this task's drift.
requiredt.required (1)Resources needed, when there is one need.
demandt.demand (1)Units the task uses on each chosen resource (students in a hall).
kindt.kindCategory for setup times.
locationt.locationA point, for travel.
participantsnoneA list of the task's participants.
placenoneOne participant, typically a place.
fixedResourcesnoneOnly these resources may take it.
baselineStart, baselineResourcesnoneThe current booking, for re-planning.
task: {duration: 't.minutes', target: 't.preferred',
       participants: '[(d:Dancer)-[:DANCES_IN]->(t) | d]'}

A value that isn't a string is a literal: participant: {changeover: 15}.

Resources, participants and places

Resource fields are expressions over r; participant and place fields over p.

FieldDefaultMeaning
windows[{start: x.start, end: x.end}], else x.windowsA list of {start, end, costRate} maps or [start, end] pairs. null = always available; [] = never.
unitsx.units (1)Tasks held at once (a room's parallel sessions, a hall's seats with demand).
changeoverx.changeover (0)Minimum gap between its tasks.
maxWorkx.maxWorkHard limit on total work.
locationx.locationPoint, for distance limits.
costRate, fixedCost0Cost per unit of work and once if used at all. A window's costRate overrides the rate inside it.
travelstrue for resources, false for participantsWhether travel time between its tasks applies.

Needs and eligibility

By default a task needs required resources from resources. List needs to ask for several kinds at once; each need is chosen separately, and one resource never fills two needs:

needs: [{count: 1, where: 'r:Surgeon'}, {count: 1, where: 'r:Anaesthetist'}, {count: 1, where: 'r:Theatre'}]

pair expressions see both t and r and are evaluated once per pair:

FieldMeaning
eligibleBoolean: may r take t?
costExtra cost when r takes t (agency staff, overtime).
affinityPreference when r takes t (continuity, familiarity).

Graph patterns make them graph-driven, including hierarchies through paths:

pair: {
  eligible: 'all(s IN [(t)-[:REQUIRES]->(s) | s] WHERE EXISTS { (r)-[:HAS]->(:Skill)-[:IMPLIES*0..]->(s) })',
  affinity: 'size([(r)-[:VISITED]->(:Visit)-[:FOR]->(c)<-[:FOR]-(t) | 1])'
}

Changeover, travel and setups

The gap a timeline needs between two consecutive tasks is the largest of:

  • its changeover (a costume change, cleaning a theatre, a rest day);
  • travel between the two tasks, when the timeline travels: from travel entries [{from, to, time, distance}], else the distance between their location points at travelSpeedKmh (times then read as minutes);
  • for resources, the setups table by task kind: setups: [{from:'latin', to:'ballroom', time:5}], with '*' matching any kind.

Each dancer needing 15 minutes between dances, and each stage needing 5 minutes to change between styles:

MATCH (d:Dance) WITH collect(d) AS dances
MATCH (s:Stage) WITH dances, collect(s) AS stages
CALL gdb.schedule.solve({
  tasks: dances, resources: stages,
  task: {duration: 't.minutes', kind: 't.style',
         participants: '[(p:Dancer)-[:DANCES_IN]->(t) | p]'},
  resource: {windows: '[(r)-[:HAS_SESSION]->(s) | {start: s.start, end: s.end}]'},
  participant: {changeover: 15},
  setups: [{from:'latin', to:'ballroom', time:5}, {from:'ballroom', to:'latin', time:5}],
  constraints: [{type:'maxTasks', count:3, per:'participant'}]
}) YIELD task, resource, start, end
RETURN task.name, resource.name, start, end ORDER BY start;

Rules

constraints is a list of rules. A rule without a penalty (or curve) is hard and never broken; with one it is soft and costs the penalty per unit of violation. Hard minimums can't always be met, so they are scored in the top violations tier instead.

Timeline rules apply to every resource by default; per: 'participant' or 'all' widens them, resources: [...] names timelines, and where filters with an expression over e. period applies a rule per day, shift or round (period: 1440 with minutes, or duration({days: 1})).

RuleOptionsUse
maxWorkamountContracted hours; overtime when soft.
minWorkamountGuaranteed hours: "needs at least 4 hours".
maxTasks, minTaskscountA dancer in at most 3 dances; one match per round.
maxSpanamountFirst start to last end: no long split shifts.
maxContinuousamount, breakLengthNo run longer than amount without a gap of breakLength.
idlesoft onlyCost per unit of gap between tasks in one window.
precedencetasks (in order), minGap, maxGapHeat before final; follow-up within 2–4 hours.
sameResourcetasksContinuity across a care plan.
differentResourcestasksFour-eyes checks.
sameStarttasks, soft onlyLinked appointments.
allOrNothingtasksA course's sessions, a multi-leg job.
notTogetherresourcesTwo trainees never pair.
togetherresources, soft onlyA trainee shadows a mentor.

Tasks that must never overlap, such as exams sharing students, share a participant: give each student as a participant, or a single shared node.

Penalty curves

A curve turns a violation amount (minutes late, hours over, tasks short) into cost. Use one for drift with lateCurve and earlyCurve (globally or per task), or on any soft rule as curve. Rules apply the curve to each violation separately, so two people ten minutes late cost less than one person twenty minutes late when the curve is steep.

ShapeExampleCost of amount x
Points[[10, 2.5], [20, 15], [60, 100]]Straight lines between the points from (0, 0), then on at the last slope.
Smooth points{points: [[10, 2.5], [20, 15]], smooth: true}A smooth curve through the same points that never overshoots them.
Slope{slope: 0.5}0.5 × x
Power{power: 2, scale: 0.025}0.025 × x²
Exponential{exponential: 0.1, scale: 1}e^(0.1x) − 1
Steps{steps: [[15, 50], [30, 200]]}A fixed charge once each threshold is reached.

Every shape also takes after (a free allowance, e.g. the first 5 minutes) and cap (the most it can cost). Without a curve, drift costs weight × |drift| ^ driftExponent.

Objective

Plans are compared tier by tier in objective order. A later tier only decides while the earlier ones are within their tolerances, relative to max(|value|, 1).

TierValue (lower is better)Default tolerance
violationsShortfall on hard minimums0
unassignedSum of unplaced tasks' priorities (always included)0
stabilityTasks moved from their baseline0
penaltySum of soft-rule costs0
driftMean drift cost over placed tasks with a target0.1
travelTravel time on travelling timelines, plus distanceWeight per km0.05
costCost rates × work, fixed costs and pair costs0.05
affinityMinus the summed pair affinity0.1
workloadworkloadWeight × (busiest − least busy resource)0

Reorder the list to change what matters most, or leave tiers out: objective: ['unassigned', 'penalty', 'travel', 'drift'].

Re-planning

Give each task its current booking with baselineStart and baselineResources and the scheduler starts from it. The stability tier counts tasks that moved, so after a change (a carer calls in sick, a dance is withdrawn) only the tasks that have to move do. Lock bookings outright with fixedStart and fixedResources.

task: {baselineStart: '[(r)-[b:BOOKED]->(t) | b.start][0]',
       baselineResources: '[(r)-[:BOOKED]->(t) | r]'}

Results

gdb.schedule.solve returns one row per task:

ColumnValue
taskThe task node or map.
resource, resourcesThe first chosen resource and all of them.
start, endIn the input's time type.
driftstart − target, as a number or duration.
previous, nextThe neighbouring tasks on the first resource's timeline.
unassignedReasonWhy an unplaced task is unplaced.

Each timeline is a path, so writing NEXT relationships turns the plan into a graph you can query and run algorithms on:

... YIELD task, next WHERE next IS NOT NULL CREATE (task)-[:NEXT]->(next)

Unassigned reasons:

ReasonMeaning
EmptyTaskWindowearliest, latest, fixedStart and maxDrift leave no start.
NoEligibleResourceA need has no eligible resource.
NoFittingWindowEligible resources exist, but no window or capacity fits.
InsufficientResourcesA need asks for more distinct resources than can take the task.
ParticipantUnavailableA participant has no window the task fits.
ParticipantsBusyParticipants are booked whenever it could run.
ResourcesBusyEnough resources are never free together.
RuleLimit:<rule>A hard rule such as maxWork stops it.
NotChosenIt fits, but placing it would cost more than leaving it.

gdb.schedule.solve.stats returns counts, objective values, iterations, timings and schedule, a map Explorer can draw as a Gantt chart:

{_schedule: 1, dimensions: ['time', 'resource', 'participant'],
 timelines: [{key, label, kind, units, windows}],
 items: [{task, label, start, end, on: [timeline indexes]}],
 unassigned: [{task, label, reason}], objective: {...}}

label (default coalesce(x.name, x.title, x.id)) names items in it. Explorer's Schedule view draws it, and its schedule builder writes the gdb.schedule.solve call from forms. gdb.schedule.audit(config) lists structural problems without solving.

Projection route

The same engine runs over a projection with gdb.schedule.stream, .stats, .write, .mutate, .<mode>.estimate and .audit(graph, config). It reads the graph by convention, and every label, relationship type and property name is an option:

Graph elementDefaultMeaning
Resource / task nodes:Resource, :TaskProperties as in the tables above.
Availability(:Resource)-[:HAS_AVAILABILITY]->(:Availability {start, end, costRate})Windows; a resource without them uses its own start/end.
Capabilities(:Task)-[:REQUIRES]->(x)<-[:HAS]-(:Resource)Every required node must be held.
Exclusions, allow-listsEXCLUDES, ALLOWS between resource and task
Affinity(:Resource)-[:AFFINITY {weight}]->(task or a node the task links to)
Travel(:Task)-[:TRAVEL {time, distance}]->(:Task)
Participants, places(:Task)-[:INVOLVES]->(p), (:Task)-[:AT]->(place)units, changeover on the node.

.write creates (resource)-[:<writeRelationshipType> {start, end}]->(task), and with nextRelationshipType links consecutive tasks on each resource. Stream columns are taskId, resourceId, start, end, drift, travelTime, unassignedReason. Projection-only options: maxDistance (metres between resource and task locations) and resourceMaxWork.

Search and performance

Hardest tasks are seeded first (priority, then fewest candidates, then most needs). Large neighbourhood search then repeatedly removes a group of tasks (at random, close in time, or everything on one timeline), re-inserts them and other waiting tasks at their cheapest feasible start, re-times their neighbours toward their targets, and accepts by simulated annealing. Every state satisfies the hard rules, and the final plan is re-checked from scratch. The same randomSeed always gives the same schedule.

Moves are undone from a journal rather than by copying the plan, timelines store only task ids, and expressions are evaluated once per task, resource or pair while the problem is built, never during the search.

On Galactus DB 1.7.0 (Intel Core i9-13900K, 64 GB RAM), a day of 2,000 tasks on 100 resources, with travel from points, 80 double-ups and a break rule, solves in about 2.5 s with the default four seed runs and 100 search iterations.

searchMillis stops the search after a time budget and returns the best plan; timeoutMillis cancels the call with an error.

Configuration

OptionDefault
tasks, resourcesRequired list (resources may be empty)
task, resource, participant, pairField expressions, above
needsOne need of required resources
travel[{from, to, time, distance}]
setups[{from, to, time}] by task kind
constraints[]
objective['violations','unassigned','stability','penalty','drift','travel','cost','affinity','workload']
tolerances{drift: 0.1, travel: 0.05, cost: 0.05, affinity: 0.1}
lateCurve, earlyCurve, driftExponentnone, none, 1
distanceWeight, workloadWeight0, 1
windowSlack, maxDrift0, unbounded
horizon{start, end}; default spans the data
timeStep0 (off); e.g. 5 snaps starts to 5-minute marks
travelSpeedKmh30
parametersValues for $name in expressions
labelcoalesce(x.name, x.title, x.id)
randomSeed, seedRuns, maxIterations, patiencerandom, 4, 200, 0 (off)
destroyFraction, destroyLimit0.2, 50
initialTemperature, finalTemperature50, 0.5
searchMillis, timeoutMillis, memoryLimitBytesoff

Reserved words work as map keys, so {where: 'r:Surgeon'} needs no quoting.

Graph projections and catalogue · GDS algorithms and execution modes · GDS resources and cancellation · Graph data science

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