Domain: booking-interval coalescing service

Issue:
Back-to-back bookings show up as separate schedule blocks instead of one continuous block. For example, a booking from 09:00 to 10:00 followed by another from 10:00 to 11:00 in the same room renders as two entries; it should render as a single 09:00-11:00 block. Bookings that actually overlap merge correctly. Expected behavior: bookings that meet end-to-end belong to the same continuous block, as the merge API documents — for any bookings, not just this example.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: # Pure standard library unless the environment image pre-bakes wheels.

path: requirements.lock

path: src/__init__.py

content: """Free-slot computation around booked blocks."""

from .merge import coalesce


def free_slots(bookings, day_start, day_end):
    """Free (start, end) gaps within the day around coalesced bookings."""
    slots = []
    cursor = day_start
    for start, end in coalesce(bookings):
        if start > cursor:
            slots.append((cursor, start))
        cursor = max(cursor, end)
    if cursor < day_end:
        slots.append((cursor, day_end))
    return slots

path: src/availability.py

content: """Human-readable rendering of schedule blocks."""


def fmt_minutes(minutes):
    """Render minutes since midnight as HH:MM."""
    return "{:02d}:{:02d}".format(minutes // 60, minutes % 60)


def block_label(start, end):
    """Render a block as 'HH:MM-HH:MM'."""
    return "{}-{}".format(fmt_minutes(start), fmt_minutes(end))


def schedule_lines(blocks):
    """One label per block, in order."""
    return [block_label(start, end) for start, end in blocks]

path: src/display.py

content: """Coalescing bookings into continuous schedule blocks."""


def coalesce(bookings):
    """Merge bookings into continuous blocks.

    Bookings that overlap or meet end-to-end belong to the same block. The
    result is a sorted list of (start, end) minute pairs.
    """
    if not bookings:
        return []
    ordered = sorted(bookings, key=lambda booking: (booking.start, booking.end))
    blocks = [(ordered[0].start, ordered[0].end)]
    for booking in ordered[1:]:
        start, end = blocks[-1]
        if booking.start < end:
            blocks[-1] = (start, max(end, booking.end))
        else:
            blocks.append((booking.start, booking.end))
    return blocks

path: src/merge.py

content: """Data model for room bookings.

Times are minutes since midnight (09:00 -> 540).
"""

from dataclasses import dataclass


@dataclass(frozen=True)
class Booking:
    """A single room booking."""

    room: str
    start: int
    end: int

    def __post_init__(self):
        if not 0 <= self.start < self.end:
            raise ValueError("booking must satisfy 0 <= start < end")

path: src/models.py

content: """Tests for booking coalescing, availability, and display."""

import pytest

from src.availability import free_slots
from src.display import block_label, fmt_minutes, schedule_lines
from src.merge import coalesce
from src.models import Booking


def bookings(*spans):
    return [Booking("main", start, end) for start, end in spans]


def test_overlapping_bookings_merge():
    assert coalesce(bookings((540, 620), (600, 660))) == [(540, 660)]


def test_disjoint_bookings_stay_separate():
    assert coalesce(bookings((540, 600), (720, 780))) == [(540, 600), (720, 780)]


def test_unsorted_input_is_ordered():
    blocks = coalesce(bookings((720, 780), (540, 620), (600, 660)))
    assert blocks == [(540, 660), (720, 780)]


def test_empty_and_invalid_bookings():
    assert coalesce([]) == []
    with pytest.raises(ValueError):
        Booking("main", 600, 600)
    with pytest.raises(ValueError):
        Booking("main", -10, 60)


def test_free_slots_around_blocks():
    slots = free_slots(
        bookings((540, 620), (600, 660), (900, 960)), day_start=480, day_end=1080
    )
    assert slots == [(480, 540), (660, 900), (960, 1080)]


def test_display_formats_blocks():
    assert fmt_minutes(540) == "09:00"
    assert block_label(540, 660) == "09:00-11:00"
    assert schedule_lines([(540, 660), (720, 780)]) == [
        "09:00-11:00",
        "12:00-13:00",
    ]

path: tests/public/test_public.py

Reference patch:
--- a/src/merge.py
+++ b/src/merge.py
@@ -13,7 +13,7 @@ def coalesce(bookings):
     blocks = [(ordered[0].start, ordered[0].end)]
     for booking in ordered[1:]:
         start, end = blocks[-1]
-        if booking.start < end:
+        if booking.start <= end:
             blocks[-1] = (start, max(end, booking.end))
         else:
             blocks.append((booking.start, booking.end))


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,32 @@
+"""Hidden evaluation tests. Never agent-visible (DEC-015).
+
+FAIL_TO_PASS: the buggy coalesce treats bookings that meet end-to-end as
+disjoint (strict comparison), so all three tests fail at baseline. The
+suite is deliberately generality-probing (DEC-036): the first test is the
+exact example cited in the issue text, the other two use structurally
+different data, so a fix that special-cases the reported example passes at
+most 1 of 3.
+"""
+
+from src.merge import coalesce
+from src.models import Booking
+
+
+def bookings(*spans):
+    return [Booking("main", start, end) for start, end in spans]
+
+
+def test_reported_example_merges():
+    assert coalesce(bookings((540, 600), (600, 660))) == [(540, 660)]
+
+
+def test_touching_chain_merges_to_one_block():
+    blocks = coalesce(bookings((600, 630), (630, 700), (700, 720)))
+    assert blocks == [(600, 720)]
+
+
+def test_mixed_touching_overlap_and_gaps():
+    blocks = coalesce(
+        bookings((800, 830), (700, 760), (760, 800), (900, 960), (920, 990))
+    )
+    assert blocks == [(700, 830), (900, 990)]