An itinerary came back with a hotel booked on the day the traveller flew home. The traveller doesn’t need a hotel for that. They need a taxi.
The model had done a decent job with the hotel research. It picked good places, sensible neighbourhoods, a fair spread of prices. It just couldn’t count.
The setup
Hotels are asked for one stay at a time, in parallel: Rome in one request, Florence in another, Venice in a third. Each one returns the recommended hotel, a couple of alternatives, and a day-by-day list saying which hotel the traveller is in on which day. The pieces get stitched together in trip order and handed to the spreadsheet builder.
The day-by-day list is the part that went wrong, in small ways that were easy to miss. A hotel sleeping the traveller on day seven, the departure day. Venice’s nights shifted a day early. A travel day, the one labelled “Florence to Venice”, assigned to Florence by one request and to Venice by another, because each was only looking at its own slice of the trip.
Where does a travel day belong?
First fix: decide it once, in code. A travel day belongs to the place where the night is spent. “Rome → Florence” counts as Florence, because that’s where you wake up the next morning. The split is just a regex that takes the last segment after an arrow or a “to”:
function stayPlace(string $location): string
{
$parts = preg_split('/\s*(?:→|->)\s*|\s+to\s+/iu', $location) ?: [$location];
return mb_strtolower(trim(explode(',', end($parts))[0]));
}
Run that over each day’s location, group consecutive days with the same place, and you have the stays. Each request is then told, in plain words, which days and which places it owns, and that a travel day goes with the place where the night is spent. Telling the model helps. Not depending on it helps more.
The departure day
Second fix. If the recommended hotels account for exactly one night fewer than there are days, the last day is the departure day and gets no hotel. That’s the whole rule.
I wrote it first as a patch: count the nights, compare against the days, drop the last entry. It worked, and I didn’t like it, because it was still reading the model’s day list and deleting the wrong bits.
Then I stopped asking
The better question was why I was asking for the day list at all.
Which night belongs to which hotel is arithmetic. We know the stays from the trip. We know the recommended hotel for each stay and how many nights it said. The first N days of that stay get that hotel, and whatever’s left over is the departure day. No research involved.
So the merge step now builds the day list itself. It walks the stays in order, takes the one recommended hotel for each, and fills in that many days. The model’s own entry for the hotel is kept as a template, so the neighbourhood and the rest of its detail survive. If things don’t line up (a different number of recommended hotels than places, a hotel that’s in the wrong city, a cruise, no nights at all) it gives up and keeps the model’s list exactly as it was. Better a slightly wrong answer from the source than a confident wrong answer from me.
The test uses a real shape of failure: Venice shifted by a day and a hotel on day seven. After the merge, days one to six are covered by three hotels in order, two nights each, and day seven is empty.
What I’d take from it
I keep rediscovering this one. If an answer can be computed from things you already have, compute it, and use the model for the parts that need judgement. A model picking a good hotel in Venice is research. A model deciding that two nights starting on day five ends on day six is counting, and counting is something a loop does better and never gets bored of.
The check for the sums is still there, mind you. It just runs on my numbers now.
