codex/binary-search-intervals.py

binary-search-intervals.py
"""Compute two binary-search traces and save a white PNG in the working directory.

Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Invoke from _media to put binary-search-intervals.png there.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch, Rectangle


VALUES = (2, 5, 8, 12, 16, 23, 38, 56, 72, 91, 104, 130, 160, 205, 260)


def search_trace(values, target):
    """Return (matching index or -1, trace, final inclusive endpoints)."""
    left, right = 0, len(values) - 1
    rows = []
    while left <= right:
        middle = left + (right - left) // 2
        value = values[middle]
        rows.append((left, right, middle, value))
        if value == target:
            return middle, rows, (left, right)
        if value < target:
            left = middle + 1
        else:
            right = middle - 1
    return -1, rows, (left, right)


def draw_trace(ax, target):
    result, rows, endpoints = search_trace(VALUES, target)
    for index in range(len(VALUES)):
        ax.text(index, 4.62, str(index), ha="center", color="#52606d", fontsize=9)
    ax.text(-1.1, 4.62, "Index", ha="right", fontsize=10)
    for step, (left, right, middle, value) in enumerate(rows, 1):
        y = 4.15 - (step - 1) * 0.9
        ax.text(-1.1, y, f"Step {step}", ha="right", va="center", fontsize=11)
        for index, entry in enumerate(VALUES):
            color = "#e8edf1"
            if left <= index <= right:
                color = "#c9dff4"
            if index == middle:
                color = "#f6bf75" if result != middle or step != len(rows) else "#8ed1a5"
            ax.add_patch(Rectangle((index - 0.45, y - 0.29), 0.9, 0.58,
                                   facecolor=color, edgecolor="white"))
            ax.text(index, y, str(entry), ha="center", va="center", fontsize=10,
                    color="#152b3c" if left <= index <= right else "#737e88")
        if value == target:
            decision = f"{target} = {value}: return index {middle}"
        elif value > target:
            decision = f"{target} < {value}: R = {middle - 1}"
        else:
            decision = f"{target} > {value}: L = {middle + 1}"
        ax.text(15.25, y, decision, va="center", fontsize=11)
    if result == -1:
        left, right = endpoints
        outcome = f"Empty range: L = {left} > R = {right}. Target absent."
    else:
        outcome = f"Found after {len(rows)} comparisons; candidate sizes: " + " → ".join(
            str(right - left + 1) for left, right, _, _ in rows)
    ax.text(-2.5, 0.35, outcome, fontsize=11, color="#152b3c")
    ax.set(xlim=(-2.6, 22.5), ylim=(0.05, 5.35))
    ax.set_title(f"Target {target}", loc="left", fontsize=13, weight="bold")
    ax.axis("off")


def main():
    found, successful, _ = search_trace(VALUES, 23)
    absent, failed, endpoints = search_trace(VALUES, 24)
    assert found == 5 and [row[2] for row in successful] == [7, 3, 5]
    assert absent == -1 and [row[2] for row in failed] == [7, 3, 5, 6]
    assert endpoints == (6, 5)
    plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 11})
    fig, axes = plt.subplots(2, 1, figsize=(11.5, 6.6), facecolor="white")
    for ax, target in zip(axes, (23, 24)):
        draw_trace(ax, target)
    fig.suptitle("Binary search: ordered data makes each exclusion safe", fontsize=16)
    fig.legend(handles=[Patch(facecolor="#c9dff4", label="Candidate"),
                        Patch(facecolor="#f6bf75", label="Middle entry"),
                        Patch(facecolor="#8ed1a5", label="Match"),
                        Patch(facecolor="#e8edf1", label="Already excluded")],
               loc="lower center", ncol=4, frameon=False)
    fig.subplots_adjust(left=0.025, right=0.985, top=0.89, bottom=0.085, hspace=0.26)
    fig.savefig(Path.cwd() / "binary-search-intervals.png", dpi=120,
                facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()