Context Window Planner — Ruby source
Paste your system prompt, docs, and history — see how they fill any model's context window, with overflow warnings and output headroom.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Context Window Planner — plan labeled prompt sections against a model's
# context window.
#
# Language: Ruby (Ruby 3.2, standard library only — `json` is a default gem)
# Source: CosmoDev polyglot showcase port of the Context Window Planner
# tool, ported from src/lib/contextPlanner.ts (the canonical
# TypeScript implementation).
# Live at: https://dev.cosmolabs.org/tools/context-window-planner
# License: display source — part of CosmoDev's polyglot tool pages.
#
# Design goals:
# - Pure + deterministic; never raises (public API returns plain values).
# - Functionally equivalent to the TS reference: same inputs -> same outputs.
# - Self-contained: stdlib only (no gems beyond the default set).
#
# Port notes: the TS lib delegates to two siblings — `estimateTokens` from
# src/lib/tokenEstimator.ts and `fitsWindow` from src/lib/ai/models.ts (which
# defaults to the bundled pricing snapshot, src/data/ai-models.json). A
# dependency-free port cannot load that file, so the estimator is inlined
# below in the exact form the planner uses it (`estimateTokens(text).tokens`,
# auto content type — the full heuristic lives in the token-estimator port),
# window math is inlined from `fitsWindow` and `models` is an explicit
# parameter, never re-derived.
#
# Faithfulness notes (the places Ruby's stdlib silently differs from JS):
# - Length: TS's `String.length` counts UTF-16 code units (an astral-plane
# character — emoji, rare CJK ext-B ideographs — counts as 2). Ruby's
# `String#length` counts code points, so line arithmetic goes through
# `utf16_len` to count the same unit.
# - JSON: `JSON.parse`'s default strictness matches `JSON.parse` in JS —
# it rejects NaN/Infinity literals (allow_nan defaults to false), rejects
# trailing garbage, and accepts any top-level scalar — so the whole-text
# gate needs no hand-rolled validator here, unlike the Rust/C ports.
# - Rounding: `Float#round` rounds halfway away from zero — equal to JS
# `Math.round` over the non-negative inputs used here; `js_round` states
# the contract with `floor(x + 0.5)`.
require 'json'
module ContextWindowPlanner
module_function
# One labeled block of the prompt (system / docs / history / ...).
# Mirrors the TS PlanSection interface. Use `sec` for the TS object
# literal `{ label, text }`.
PlanSection = Struct.new(:label, :text)
def sec(label, text)
PlanSection.new(label, text)
end
# The subset of the TS AiModel record the planner reads. Production code
# passes the full snapshot entry; only these fields influence the plan.
Model = Struct.new(:id, :context_window, :max_output)
# Sample table for standalone use (mirrors the shared test fixtures).
# Production code passes the model snapshot instead.
SAMPLE_MODELS = [
Model.new('alpha-mini', 200_000, 10_000),
Model.new('beta-pro', 1_000_000, 10_000),
Model.new('gamma-open', 100_000, 10_000)
].freeze
# Result of `plan_window`. Field-for-field twin of the TS WindowPlan
# interface (same keys, same meanings).
WindowPlan = Struct.new(
:id, # the model id planned against
:input_tokens, # sum of per-section token estimates
:context_window, # the model's context window
:free, # window - input; negative on overflow
:fits, # raw fit: free >= 0
:output_reserve_ok, # room for the output reserve: free >= reserve
:max_output, # the model's output cap (informational)
keyword_init: true
)
# Average characters per token, by content type. Mirrors CHARS_PER_TOKEN
# in src/lib/tokenEstimator.ts.
CHARS_PER_TOKEN = {
prose: 4,
code: 3.5,
json: 3,
cjk: 1.5
}.freeze
# CJK ideographs (U+4E00..U+9FFF), kana (U+3040..U+30FF), Hangul syllables
# (U+AC00..U+D7AF). Mirrors CJK_RE = /[一-鿿-ヿ가-]/ in the TS lib.
CJK_RE = /[一-鿿-ヿ가-]/
# Code-flavored symbols, counted over the raw line. Mirrors CODE_SYMBOL_RE.
CODE_SYMBOL_RE = /[{}();=<>\[\]#]/
# Length of `s` in UTF-16 code units — the unit TS's String.length counts.
# BMP code points are one unit, astral-plane ones two.
def utf16_len(s)
s.each_char.sum { |ch| ch.ord > 0xFFFF ? 2 : 1 }
end
# JS Math.round: halfway cases round up (floor(x + 0.5)).
def js_round(x)
(x + 0.5).floor
end
# Classifies a single line by its shape. Order: json, cjk, code, prose.
# Inlined from detectLineType() in src/lib/tokenEstimator.ts.
def detect_line_type(line)
trimmed = line.strip
# JSON-ish: opens like a JSON fragment AND carries a separator.
first = trimmed[0]
jsonish = first == '{' || first == '}' || first == '[' || first == '"'
return :json if jsonish && (line.include?(':') || line.include?(','))
# CJK ideographs / kana / Hangul pack roughly one token per 1.5 chars.
return :cjk if CJK_RE.match?(line)
# Code: symbol-dense, or a statement terminator / block opener at EOL.
length = utf16_len(line)
density = length.zero? ? 0.0 : line.scan(CODE_SYMBOL_RE).size.to_f / length
return :code if density > 0.08 || trimmed.end_with?(';', '{', '}')
:prose
end
# Whole-text JSON gate: a document that parses as JSON is json all the way
# down. Mirrors isValidJson() (JSON.parse in a begin/rescue);
# empty/whitespace text is not.
def valid_json?(text)
return false if text.strip.empty?
begin
JSON.parse(text)
rescue JSON::ParserError
false
else
true
end
end
# Token count of `text` under auto content detection — exactly the slice of
# estimateTokens() the planner consumes (`.tokens`): per non-empty line,
# max(1, round(utf16_len / chars_per_token)). Framing tokens are the
# caller's job.
def estimate_tokens(text)
# AUTO + whole-text JSON: json's 3 chars/token rate applies to every
# line, not just the reported content type.
whole_text_json = valid_json?(text)
tokens = 0
# Split on LF or CRLF (text.split(/\r?\n/)); a lone CR is NOT a break.
text.split(/\r?\n/, -1).each do |line|
next if line.strip.empty?
type = whole_text_json ? :json : detect_line_type(line)
tokens += [1, js_round(utf16_len(line) / CHARS_PER_TOKEN[type])].max
end
tokens
end
# Sum of per-section token estimates (framing tokens are the caller's job).
# Mirrors inputTokenTotal() in the TS lib.
def input_token_total(sections)
sections.sum { |s| estimate_tokens(s.text) }
end
# Plan one section set against one model's context window. Returns nil for
# an unknown model id (window math is fitsWindow's, never re-derived).
# Mirrors planWindow() in the TS lib.
def plan_window(sections, model_id, output_reserve: 0, models: [])
input_tokens = input_token_total(sections)
# Fit check inlined from fitsWindow() in src/lib/ai/models.ts.
m = models.find { |model| model.id == model_id }
return nil if m.nil?
free = m.context_window - input_tokens
WindowPlan.new(
id: model_id,
input_tokens: input_tokens,
context_window: m.context_window,
free: free,
fits: free >= 0,
output_reserve_ok: free >= output_reserve,
max_output: m.max_output
)
end
# Plan against several models; unknown ids are dropped from the result.
# Mirrors planAll() in the TS lib.
def plan_all(sections, model_ids, output_reserve: 0, models: [])
model_ids.filter_map { |id| plan_window(sections, id, output_reserve: output_reserve, models: models) }
end
end
# ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
if $PROGRAM_NAME == __FILE__
# A 1600-char single line of 'a' is pure prose: 1600 / 4 = 400 tokens.
line_a = 'a' * 1600
two_sections = [
ContextWindowPlanner.sec('sys', line_a),
ContextWindowPlanner.sec('docs', line_a)
]
cp = ContextWindowPlanner
# input totals
raise 'input totals' unless cp.input_token_total(two_sections) == 800
raise unless cp.input_token_total([]) == 0
raise unless cp.input_token_total([cp.sec('sys', '')]) == 0
# plans two 400-token sections against beta-pro
plan = cp.plan_window(two_sections, 'beta-pro', models: cp::SAMPLE_MODELS)
raise unless plan.id == 'beta-pro'
raise unless plan.input_tokens == 800
raise unless plan.context_window == 1_000_000
raise unless plan.free == 999_200
raise unless plan.fits
raise unless plan.output_reserve_ok
raise unless plan.max_output == 10_000
# reserve larger than free leaves raw fit true
plan = cp.plan_window(two_sections, 'beta-pro', output_reserve: 1_000_000, models: cp::SAMPLE_MODELS)
raise unless plan.fits
raise if plan.output_reserve_ok
# reserve exactly equal to free is ok
plan = cp.plan_window(two_sections, 'beta-pro', output_reserve: 999_200, models: cp::SAMPLE_MODELS)
raise unless plan.output_reserve_ok
# smaller window leaves 199,200 free
plan = cp.plan_window(two_sections, 'alpha-mini', models: cp::SAMPLE_MODELS)
raise unless plan.context_window == 200_000
raise unless plan.free == 199_200
raise unless plan.fits
# unknown model id returns nil
raise if cp.plan_window(two_sections, 'ghost', models: cp::SAMPLE_MODELS)
# no sections: full window free
plan = cp.plan_window([], 'beta-pro', models: cp::SAMPLE_MODELS)
raise unless plan.input_tokens.zero?
raise unless plan.free == 1_000_000
raise unless plan.fits
# overflow: fits false, reserve false
plan = cp.plan_window([cp.sec('big', 'z' * 4_400_000)], 'beta-pro', models: cp::SAMPLE_MODELS)
raise unless plan.input_tokens == 1_100_000
raise unless plan.free == -100_000
raise if plan.fits
raise if plan.output_reserve_ok
# plan_all drops unknown ids and keeps order
plans = cp.plan_all(two_sections, %w[beta-pro alpha-mini ghost], models: cp::SAMPLE_MODELS)
raise unless plans.length == 2
raise unless plans[0].id == 'beta-pro'
raise unless plans[1].id == 'alpha-mini'
raise unless plans[1].free == 199_200
raise unless cp.plan_all(two_sections, [], models: cp::SAMPLE_MODELS).empty?
puts 'context-window-planner (Ruby): all tests passed'
end
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 →