Skip to content

feat(objectives): add prefer-early-tours objective - #17

Merged
hutchinsp01 merged 3 commits into
masterfrom
feat/add-early-tours-heuristic
Aug 27, 2026
Merged

feat(objectives): add prefer-early-tours objective#17
hutchinsp01 merged 3 commits into
masterfrom
feat/add-early-tours-heuristic

Conversation

@hutchinsp01

@hutchinsp01 hutchinsp01 commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a prefer-early-tours objective that schedules work into the earliest shifts of the planning period. Given five days of vehicle shifts and two days of work, it puts the work on the first two days and leaves the rest empty.

Each shift in use is scored by how long after the fleet's earliest shift it starts. Only opening a shift costs anything — the objective picks which days are worked, and leaves what goes on each day to the cost objective below.

Why not minimize-arrival-time

minimize-arrival-time is currently the only way to get early packing, but its two halves do different jobs. Its insertion estimate is the shift's start (drives day selection), while its fitness is the average tour-end arrival — which pushes every route to finish as early as possible. Sitting above a cost objective in a lexicographic stack, a one-second-earlier finish beats any amount of cost saving.

One job with two candidate places, near/afternoon vs far/morning:

objectives place served tour ends distance
minimize-cost only near 15:00 15:11 1,870 m
+ minimize-arrival-time far 09:40 10:31 48,624 m
+ prefer-early-tours near 15:00 15:11 1,870 m

A 26× distance blowout bought purely to end the day earlier. prefer-early-tours uses the shift's static start and returns zero for activity-level moves, so it structurally cannot influence job order or timing within a day.

Why per tour and not per job

Scoring every job by its shift's delay reintroduces the same failure mode on the inter-day axis: since the objective outranks cost, it pays unbounded travel to drag one more job into an earlier day. Two clusters 22 km apart, five jobs each, six-job daily capacity:

objectives day 1 day 2 distance
baseline (no day objective) 5 jobs, all south (day 4) 5 jobs, all north 52.4 km
+ prefer-early-tours, per job 6 jobs, north and south 4 jobs, north 74.5 km
+ prefer-early-tours, per tour 5 jobs, all south 5 jobs, all north 52.4 km

Per-tour keeps the baseline's routing while moving the work from days 1+4 to days 1+2. See the third commit.

Placement

Neither this nor minimize-tours can veto the other during insertion — both charge only when a shift is opened. But their order still decides the outcome whenever a later set of shifts would use fewer tours, which needs non-uniform per-day capacity. Capacities 4/4/4/4/9, nine jobs — days 1-3 sum to a delay of 0+1+2 against day 5's 4:

ordering result summed delay
prefer-early-tours above minimize-tours 3 tours, days 1-3 259,200
minimize-tours above prefer-early-tours 1 tour, day 5 345,600

With a uniform fleet the two agree, since k days of work always means the earliest k shifts. Pick the order for what you are trading: earliest work, or fewest vehicle days.

It must sit below something that forces assignment — on its own, assigning nothing wins. maximize-value only covers that while every job carries a value, since it scores assigned jobs only and an unvalued job scores zero. Noted on the enum variant.

Known trade-off

Delays are summed, so several early shifts can tie exactly with one late shift (0 + 1 + 2 equals 3) — minimize-tours then breaks the tie from either side, preferring the single later tour. That is the intended reading: one vehicle day on Thursday beats three Mon-Wed. Uniform per-day capacity cannot reach this case at all.

Notes

  • Reads the first-job arrival floor when set, so it keeps working for vehicles allowing out-of-hours depot travel, where the shift start bound is relaxed and the actor's start time collapses to zero. Covered by a test that fails without the fallback.
  • Objective-only — no constraint or state.
  • Grouping it into a weighted-sum tier does not give a tunable trade-off: the weights apply only to the insertion estimate, while solution comparison uses Pareto dominance over raw fitness, which ties whenever members disagree.

Testing

12 vrp-core unit tests, 4 vrp-pragmatic tests. The pragmatic set includes a baseline asserting the solver picks the cheaper later shift with no objective set, so its paired test cannot pass vacuously. Full suite 1,068 passing; cargo fmt and clippy clean on new files.

🤖 Generated with Claude Code

@hutchinsp01
hutchinsp01 force-pushed the feat/add-early-tours-heuristic branch from c5dfabe to cb23219 Compare August 26, 2026 03:43
Comment on lines +95 to +109
fn estimate(&self, move_ctx: &MoveContext<'_>) -> Cost {
match move_ctx {
// only opening a shift costs its delay; once a shift is in use, moving work into it is
// free here, leaving the split between open shifts to the cost objective below
MoveContext::Route { route_ctx, .. } => {
if route_ctx.route().tour.has_jobs() {
Cost::default()
} else {
self.delay(route_ctx.route())
}
}
// the objective says nothing about where in the tour the job goes
MoveContext::Activity { .. } => Cost::default(),
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most important bit, and explains how the Objective is calculated

@bayangan1991 bayangan1991 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objective looks good but lets guard against the foot gun instead of failing silently

Comment thread vrp-pragmatic/src/format/problem/model.rs
Comment thread vrp-pragmatic/src/format/problem/model.rs Outdated
hutchinsp01 and others added 2 commits August 27, 2026 15:24
Schedules work into the earliest shifts of the planning period, so a plan
with more vehicle days than work fills the first days and leaves the last
ones empty.

Scores each shift in use by how long after the fleet's earliest shift it
starts, using the shift's static start time. Only opening a shift costs
anything, so the objective picks which days are worked and leaves what goes
on each of them to the cost objective below.

Why not minimize-arrival-time: its fitness is the average tour-END arrival,
which pushes every route to finish as early as it can. Sitting above a cost
tier, a one-second-earlier finish beats any cost saving. One job offering a
near afternoon place and a far morning place:

  minimize-cost only        near, ends 15:11    1,870 m
  + minimize-arrival-time   far,  ends 10:31   48,624 m

This objective reads the shift's static start and returns zero for
activity-level moves, so it cannot influence order or timing within a day.

Why per tour and not per job: scoring every job by its shift's delay makes
the objective pay unbounded travel to drag one more job into an earlier day,
the same failure rotated onto the inter-day axis. Two clusters 22km apart,
five jobs each, six-job daily capacity:

  minimize-tours + minimize-duration   day1: 5 south, day4: 5 north   52.4km
  + per-job weighting                  day1: 6 mixed, day2: 4 north   74.5km
  + per-tour weighting                 day1: 5 south, day2: 5 north   52.4km

Order against minimize-tours is a choice, and only bites when a later set of
shifts would use fewer tours, which needs non-uniform per-day capacity. With
capacities 4/4/4/4/9 and nine jobs, above minimize-tours gives three tours on
days 1-3 and below it gives one tour on day 5. A uniform fleet cannot tell
the two apart.

The objective scores assigned jobs only, so with nothing above it to reward
assignment the cheapest solution is the empty one. E1608 rejects that:
minimize-unassigned guards outright, maximize-value guards every job carrying
a value, and the guard must be ranked strictly higher, since layers resolve
in order and the first differing layer decides. The gap maximize-value leaves
- a plan mixing valued and unvalued jobs can still drop the unvalued ones -
is documented on the feature rather than validated, since checking it would
couple validation to plan contents more tightly than it is worth.

Reads the first-job arrival floor when set, so it keeps working for vehicles
allowing out-of-hours depot travel, where the shift start bound is relaxed
and the actor's start time collapses to zero.

Exposed in the pragmatic format as {"type":"prefer-early-tours"}.
Objective-only (no constraint/state).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vrp-core unit tests covering the delay charged for opening a shift, that a
shift already in use costs nothing however late it starts, the fitness sum
over shifts in use, empty routes scoring nothing, and the first-job arrival
floor fallback used when out-of-hours depot travel relaxes the shift start
bound.

vrp-pragmatic round-trip plus behavioural tests: a baseline asserting the
solver picks the cheaper later shift when no objective is set, so the paired
test that it picks the earliest shift cannot pass vacuously, and a case
covering the out-of-hours depot travel interaction.

E1608 validation tests covering the unguarded goal, both accepted guards, a
guard ranked below the objective, a guard sharing a tier with it, a guard in
a higher multi-objective tier, and a goal that never mentions the objective.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hutchinsp01
hutchinsp01 force-pushed the feat/add-early-tours-heuristic branch from 31fc266 to a81c8b8 Compare August 27, 2026 05:25
Cuts the release carrying the prefer-early-tours objective and its E1608
validation rule. Consumers pinning the published wheel move from 1.25.5.

The bump is required, not cosmetic: the publish step runs `s3pypi upload`
with neither --strict nor --force, so re-releasing an existing version
silently skips the upload. CI would go green, the index would keep the old
wheels, and the version would claim contents it does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hutchinsp01
hutchinsp01 merged commit c43cd4d into master Aug 27, 2026
5 checks passed
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.

2 participants