Native Scheduler
On this page
- Schedule in one call
- Timelines
- Tasks
- Resources, participants and places
- Needs and eligibility
- Changeover, travel and setups
- Rules
- Penalty curves
- Objective
- Re-planning
- Results
- Projection route
- Search and performance
- Configuration
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.
| Timeline | Who chooses it | Examples |
|---|---|---|
| Resource | the scheduler, from the task's eligible pool | carer, stage, theatre, exam hall, van, machine |
| Participant | the data | dancer, student, team, patient, speaker |
| Place | the 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.
| Field | Default | Meaning |
|---|---|---|
duration | t.duration | Required; positive. |
target | t.target | Preferred start. Drift from it is a cost. |
earliest, latest | t.earliest, t.latest | Hard: start no earlier, end no later. |
fixedStart | t.fixedStart | Hard: start exactly here. |
priority | t.priority (1) | Cost of leaving the task unplaced. |
earlyWeight, lateWeight | 1 | Weight on early and late drift. |
earlyCurve, lateCurve | none | A curve for this task's drift. |
required | t.required (1) | Resources needed, when there is one need. |
demand | t.demand (1) | Units the task uses on each chosen resource (students in a hall). |
kind | t.kind | Category for setup times. |
location | t.location | A point, for travel. |
participants | none | A list of the task's participants. |
place | none | One participant, typically a place. |
fixedResources | none | Only these resources may take it. |
baselineStart, baselineResources | none | The 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.
| Field | Default | Meaning |
|---|---|---|
windows | [{start: x.start, end: x.end}], else x.windows | A list of {start, end, costRate} maps or [start, end] pairs. null = always available; [] = never. |
units | x.units (1) | Tasks held at once (a room's parallel sessions, a hall's seats with demand). |
changeover | x.changeover (0) | Minimum gap between its tasks. |
maxWork | x.maxWork | Hard limit on total work. |
location | x.location | Point, for distance limits. |
costRate, fixedCost | 0 | Cost per unit of work and once if used at all. A window's costRate overrides the rate inside it. |
travels | true for resources, false for participants | Whether 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:
| Field | Meaning |
|---|---|
eligible | Boolean: may r take t? |
cost | Extra cost when r takes t (agency staff, overtime). |
affinity | Preference 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: fromtravelentries[{from, to, time, distance}], else the distance between theirlocationpoints attravelSpeedKmh(times then read as minutes); - for resources, the
setupstable by taskkind: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})).
| Rule | Options | Use |
|---|---|---|
maxWork | amount | Contracted hours; overtime when soft. |
minWork | amount | Guaranteed hours: "needs at least 4 hours". |
maxTasks, minTasks | count | A dancer in at most 3 dances; one match per round. |
maxSpan | amount | First start to last end: no long split shifts. |
maxContinuous | amount, breakLength | No run longer than amount without a gap of breakLength. |
idle | soft only | Cost per unit of gap between tasks in one window. |
precedence | tasks (in order), minGap, maxGap | Heat before final; follow-up within 2–4 hours. |
sameResource | tasks | Continuity across a care plan. |
differentResources | tasks | Four-eyes checks. |
sameStart | tasks, soft only | Linked appointments. |
allOrNothing | tasks | A course's sessions, a multi-leg job. |
notTogether | resources | Two trainees never pair. |
together | resources, soft only | A 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.
| Shape | Example | Cost 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).
| Tier | Value (lower is better) | Default tolerance |
|---|---|---|
violations | Shortfall on hard minimums | 0 |
unassigned | Sum of unplaced tasks' priorities (always included) | 0 |
stability | Tasks moved from their baseline | 0 |
penalty | Sum of soft-rule costs | 0 |
drift | Mean drift cost over placed tasks with a target | 0.1 |
travel | Travel time on travelling timelines, plus distanceWeight per km | 0.05 |
cost | Cost rates × work, fixed costs and pair costs | 0.05 |
affinity | Minus the summed pair affinity | 0.1 |
workload | workloadWeight × (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:
| Column | Value |
|---|---|
task | The task node or map. |
resource, resources | The first chosen resource and all of them. |
start, end | In the input's time type. |
drift | start − target, as a number or duration. |
previous, next | The neighbouring tasks on the first resource's timeline. |
unassignedReason | Why 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:
| Reason | Meaning |
|---|---|
EmptyTaskWindow | earliest, latest, fixedStart and maxDrift leave no start. |
NoEligibleResource | A need has no eligible resource. |
NoFittingWindow | Eligible resources exist, but no window or capacity fits. |
InsufficientResources | A need asks for more distinct resources than can take the task. |
ParticipantUnavailable | A participant has no window the task fits. |
ParticipantsBusy | Participants are booked whenever it could run. |
ResourcesBusy | Enough resources are never free together. |
RuleLimit:<rule> | A hard rule such as maxWork stops it. |
NotChosen | It 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 element | Default | Meaning |
|---|---|---|
| Resource / task nodes | :Resource, :Task | Properties 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-lists | EXCLUDES, 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
| Option | Default |
|---|---|
tasks, resources | Required list (resources may be empty) |
task, resource, participant, pair | Field expressions, above |
needs | One 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, driftExponent | none, none, 1 |
distanceWeight, workloadWeight | 0, 1 |
windowSlack, maxDrift | 0, unbounded |
horizon | {start, end}; default spans the data |
timeStep | 0 (off); e.g. 5 snaps starts to 5-minute marks |
travelSpeedKmh | 30 |
parameters | Values for $name in expressions |
label | coalesce(x.name, x.title, x.id) |
randomSeed, seedRuns, maxIterations, patience | random, 4, 200, 0 (off) |
destroyFraction, destroyLimit | 0.2, 50 |
initialTemperature, finalTemperature | 50, 0.5 |
searchMillis, timeoutMillis, memoryLimitBytes | off |
Reserved words work as map keys, so {where: 'r:Surgeon'} needs no quoting.
Related articles
Graph projections and catalogue · GDS algorithms and execution modes · GDS resources and cancellation · Graph data science