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 →