Skip to content

Cache Breakpoint Planner — PHP source

Find what your prompts share — common prefix and suffix blocks — and place prompt-cache breakpoints where they pay, with an estimated cost saving. 100% client-side.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * Cache Breakpoint Planner — find the blocks a set of prompts share and
 * place cache breakpoints where they pay.
 *
 * Language: PHP (8.1+, standard library only)
 * Port of src/lib/cacheBreakpointPlanner.ts (the canonical TypeScript
 * implementation). javascript.js in this set carries the same port.
 * Tool page: https://dev.cosmolabs.org/tools/cache-breakpoint-planner
 */

declare(strict_types=1);

/** Cached reads bill at ~0.1x — the saving on the cached share is ~90%. */
const CACHE_READ_DISCOUNT = 0.1;

/**
 * The `type: 'prose'` path of the tokenEstimator, inlined: every non-empty
 * line costs max(1, round(length / 4)) tokens; empty text is 0.
 */
function cbp_tok(string $text): int
{
    if ($text === '') {
        return 0;
    }
    $tokens = 0;
    foreach (preg_split('/\r?\n/', $text) as $line) {
        if ($line !== '') {
            $tokens += max(1, (int) round(mb_strlen($line) / 4));
        }
    }
    return $tokens;
}

/**
 * Plan cache breakpoints for a set of prompt sessions.
 *
 * Each session is ['id' => string, 'blocks' => list<string>]. Sessions whose
 * blocks is not a list are ignored (the TS lib filters with Array.isArray);
 * zero usable sessions yields an empty plan carrying an explanatory warning.
 *
 * @param list<array{id: string, blocks: list<string>}> $sessions
 * @return array{
 *   prefix_blocks: list<string>, prefix_tokens: int,
 *   suffix_blocks: list<string>, suffix_tokens: int,
 *   breakpoints: list<array{after_block: int, label: string, reason: string, cached_tokens: int}>,
 *   per_session: list<array{id: string, total_tokens: int, unique_tokens: int, cached_ratio: float}>,
 *   estimated_savings: float, warnings: list<string>
 * }
 */
function plan_breakpoints(array $sessions): array
{
    $warnings = [];
    $valid = array_values(array_filter(
        $sessions,
        fn($s) => isset($s['blocks']) && is_array($s['blocks'])
    ));

    if ($valid === []) {
        return [
            'prefix_blocks' => [], 'prefix_tokens' => 0,
            'suffix_blocks' => [], 'suffix_tokens' => 0,
            'breakpoints' => [], 'per_session' => [],
            'estimated_savings' => 0.0,
            'warnings' => ['No sessions given — paste at least two prompts to compare.'],
        ];
    }
    if (count($valid) === 1) {
        $warnings[] = 'Only one session — a prefix needs at least two prompts to detect.';
    }

    // Common leading blocks by position.
    $shortest = PHP_INT_MAX;
    foreach ($valid as $s) {
        $shortest = min($shortest, count($s['blocks']));
    }
    $prefixEnd = 0;
    while ($prefixEnd < $shortest) {
        $shared = true;
        foreach ($valid as $s) {
            if ($s['blocks'][$prefixEnd] !== $valid[0]['blocks'][$prefixEnd]) {
                $shared = false;
                break;
            }
        }
        if (!$shared) {
            break;
        }
        $prefixEnd++;
    }

    // Common trailing blocks, matched from each session's own tail, never
    // overlapping the prefix.
    $suffixLen = 0;
    while ($suffixLen < $shortest - $prefixEnd) {
        $shared = true;
        foreach ($valid as $s) {
            $n = count($s['blocks']);
            if ($s['blocks'][$n - 1 - $suffixLen]
                !== $valid[0]['blocks'][count($valid[0]['blocks']) - 1 - $suffixLen]) {
                $shared = false;
                break;
            }
        }
        if (!$shared) {
            break;
        }
        $suffixLen++;
    }

    $prefixBlocks = array_slice($valid[0]['blocks'], 0, $prefixEnd);
    $suffixBlocks = $suffixLen > 0
        ? array_slice($valid[0]['blocks'], -$suffixLen)
        : [];
    $prefixTokens = cbp_tok(implode("\n", $prefixBlocks));
    $suffixTokens = cbp_tok(implode("\n", $suffixBlocks));

    $breakpoints = [];
    if ($prefixBlocks !== []) {
        $breakpoints[] = [
            'after_block' => $prefixEnd - 1,
            'label' => 'after the shared prefix',
            'reason' => count($prefixBlocks) . ' block(s) identical across every session — '
                . 'cache once, hit on every request.',
            'cached_tokens' => $prefixTokens,
        ];
    }
    if ($suffixLen > 0) {
        $breakpoints[] = [
            'after_block' => -1, // terminal: the shared tail sits at the end
            'label' => 'shared tail',
            'reason' => $suffixLen . ' trailing block(s) also identical — extend the cache '
                . 'segment or accept the re-read.',
            'cached_tokens' => $suffixTokens,
        ];
    }
    if ($breakpoints === []) {
        $warnings[] = 'No shared leading or trailing blocks — nothing to cache across these sessions.';
    }

    $perSession = [];
    foreach ($valid as $s) {
        $totalTokens = cbp_tok(implode("\n", $s['blocks']));
        $uniqueTokens = max($totalTokens - $prefixTokens - $suffixTokens, 0);
        $cachedRatio = $totalTokens > 0
            ? min(($prefixTokens + $suffixTokens) / $totalTokens, 1.0)
            : 0.0;
        $perSession[] = [
            'id' => $s['id'],
            'total_tokens' => $totalTokens,
            'unique_tokens' => $uniqueTokens,
            'cached_ratio' => $cachedRatio,
        ];
    }

    $avgTotal = array_sum(array_column($perSession, 'total_tokens')) / count($perSession);
    $cachedShare = $avgTotal > 0
        ? min(($prefixTokens + $suffixTokens) / $avgTotal, 1.0)
        : 0.0;
    $estimatedSavings = $cachedShare * (1.0 - CACHE_READ_DISCOUNT);

    return [
        'prefix_blocks' => $prefixBlocks,
        'prefix_tokens' => $prefixTokens,
        'suffix_blocks' => $suffixBlocks,
        'suffix_tokens' => $suffixTokens,
        'breakpoints' => $breakpoints,
        'per_session' => $perSession,
        'estimated_savings' => $estimatedSavings,
        'warnings' => $warnings,
    ];
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →