Skip to content

fix(objectives): ignore work prefer-early-tours cannot move - #18

Open
hutchinsp01 wants to merge 2 commits into
masterfrom
fix/early-tours-ignore-pinned-work
Open

fix(objectives): ignore work prefer-early-tours cannot move#18
hutchinsp01 wants to merge 2 commits into
masterfrom
fix/early-tours-ignore-pinned-work

Conversation

@hutchinsp01

@hutchinsp01 hutchinsp01 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What

prefer-early-tours scored every shift in use. A shift held open by work the solver cannot move therefore counted as already paid for, and new work was drawn onto it in preference to an earlier empty day — the opposite of what the objective is for.

With Monday the fleet's earliest shift and a standing appointment pinned to Thursday by a relation:

where a new job goes shifts counted fitness
Thu, joining the appointment {Thu} 3 days
Tue, opening a shift {Tue, Thu} 1 + 3 = 4 days

Joining the late shift always wins, and it is not only the insertion estimate — fitness already charged Thursday unconditionally, so adding Tuesday could only make the sum worse.

This scores shifts carrying work the solver could have placed elsewhere instead. Only the first such job on a shift is charged, so the per-tour behaviour from #17 is unchanged, and a plan with no pinned work behaves exactly as before.

What is not new work

The feature takes the existing jobs — those already committed to a shift before the solve:

create_prefer_early_tours_feature(name, origin, existing_jobs: HashSet<Job>)

The pragmatic reader fills that from the problem's locks, which covers every relation type. It reads the locks rather than SolutionContext::locked, which is not the same set: only strict and sequence relations reach the solver as locked jobs, an any relation lands in reserved instead, and locked is additionally extended at runtime with reload and recharge jobs. Reading it would both miss real pins and invent ones that are not there. There is a test on strict specifically because it exercises the path any does not.

Separately, core skips any job that names a vehicle. VehicleId is a core job dimension set only on the break, reload and recharge jobs generated for a single vehicle shift, so this is a rule core can state for itself rather than something the format has to describe. None of those jobs can be taken on another day.

Breaks are why that second rule earns its place: one is required as soon as its route exists and the tour's span covers the break window, so counting it priced the break at its shift's delay — and since this objective outranks cost, the solver preferred to drop the break rather than pay it.

resulting late tour
break counted as movable departure, standing, arrival — break silently dropped
break excluded departure, [standing, break], arrival

So a fleet using breaks alongside this objective was losing them.

Ordering caveat

Unchanged from #17, but pinned work makes it much easier to hit: ranked below minimize-tours, new work still consolidates onto a shift a pinned job has already opened, because that saves a tour. The behavioural tests here leave minimize-tours out for exactly that reason. Noted on the enum variant.

Trade-off

New work will now open an earlier empty shift rather than joining a later one that is already staffed, which can cost an extra vehicle day. That is the intent — rank minimize-tours above if consolidating is worth more than earliness.

Testing

Full workspace suite passes. Coverage is deliberately thin — two vrp-core unit tests and one end-to-end pragmatic test — but each was checked against the unfixed code rather than just asserted to pass:

perturbation outcome
neither exclusion (previous behaviour) both crates fail
existing jobs excluded, vehicle-bound ones still counted both crates fail on the break
as proposed all pass

The core fitness test's last two cases are the regression pair: joining the appointment's shift scores four days against one for opening an earlier shift, where previously it scored four against five and joining won.

No version bump is included in this branch.

🤖 Generated with Claude Code

@hutchinsp01
hutchinsp01 force-pushed the fix/early-tours-ignore-pinned-work branch from 5b24b14 to 36ce610 Compare September 4, 2026 06:13
hutchinsp01 and others added 2 commits September 4, 2026 16:35
The objective scored every shift in use, so a shift held open by work the
solver cannot move counted as already paid for. Given an appointment pinned to
a later shift by a relation, putting a new job on that shift scored
delay(late) while putting it on an earlier one scored delay(early) +
delay(late) - so the later shift always won, and new work was drawn onto
whichever late day the standing commitments happened to have opened. The
opposite of the objective's purpose.

Score shifts carrying new work instead. Only the first such job on a shift is
charged, so the per-tour behaviour is unchanged, and a plan with no committed
work behaves exactly as before.

Two kinds of job are not new work. The caller passes the existing jobs - those
already committed to a shift before the solve - which the pragmatic reader
takes from the problem's locks, covering every relation type. That is read
from the locks rather than from SolutionContext::locked, which is not the same
set: an any relation lands in reserved instead, and locked is additionally
extended at runtime with reload and recharge jobs.

Beyond those, a job naming a vehicle is skipped. VehicleId is a core job
dimension set only on the break, reload and recharge jobs generated for a
single vehicle shift, none of which can be taken on another day, so the rule
belongs in core rather than in the format reader. Breaks are why it matters:
one is required as soon as its route exists and the tour's span covers the
break window, so counting it priced the break at its shift's delay - and since
this objective outranks cost, the solver preferred to drop the break rather
than pay it.

Ranking minimize-tours above this objective still consolidates new work onto a
shift a committed job has opened, since that saves a tour. Noted on the enum
variant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two vrp-core unit tests over a shift holding an appointment: one that the
delay is charged for a job which could have gone elsewhere and not for an
existing or vehicle-bound one, and one over fitness, whose last two cases are
the regression pair - joining the appointment's shift scores four days against
one for opening an earlier shift, where before it scored four against five and
joining won.

One vrp-pragmatic test end to end: a relation pins an appointment to the later
shift, which is parked next to the jobs so cost prefers joining it. Asserts
the new job still lands on the earliest shift and the break on the pinned
shift is still taken.

Checked against the unfixed code: with neither exclusion both crates fail,
and with existing jobs excluded but vehicle-bound ones still counted they
still fail on the break.

minimize-tours is left out of the objective list on purpose - ranked above, it
consolidates onto the pinned shift whatever this objective prefers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hutchinsp01
hutchinsp01 force-pushed the fix/early-tours-ignore-pinned-work branch from 36ce610 to 6ec84bb Compare September 4, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant