feat(objectives): add prefer-early-tours objective - #17
Merged
Conversation
hutchinsp01
force-pushed
the
feat/add-early-tours-heuristic
branch
from
August 26, 2026 03:43
c5dfabe to
cb23219
Compare
hutchinsp01
commented
Aug 27, 2026
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(), | ||
| } | ||
| } |
Collaborator
Author
There was a problem hiding this comment.
This is the most important bit, and explains how the Objective is calculated
bayangan1991
requested changes
Aug 27, 2026
bayangan1991
left a comment
There was a problem hiding this comment.
objective looks good but lets guard against the foot gun instead of failing silently
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
force-pushed
the
feat/add-early-tours-heuristic
branch
from
August 27, 2026 05:25
31fc266 to
a81c8b8
Compare
bayangan1991
approved these changes
Aug 27, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a
prefer-early-toursobjective 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-timeminimize-arrival-timeis 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:
minimize-costonly+ minimize-arrival-time+ prefer-early-toursA 26× distance blowout bought purely to end the day earlier.
prefer-early-toursuses 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:
+ prefer-early-tours, per job+ prefer-early-tours, per tourPer-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-tourscan 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. Capacities4/4/4/4/9, nine jobs — days 1-3 sum to a delay of0+1+2against day 5's4:prefer-early-toursaboveminimize-toursminimize-toursaboveprefer-early-toursWith 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-valueonly 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 + 2equals3) —minimize-toursthen 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
weighted-sumtier 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 fmtandclippyclean on new files.🤖 Generated with Claude Code