Skip to content

symfonic.services.budget.estimation

estimation

The offline estimator — one arithmetic rule, in one place.

Before this module the repository had four token estimators that disagreed with each other. documents/truncation.estimate_tokens floor-divided by four; triage/tool_routing.estimate_dropped_tokens floor-divided by four over a different string; prompt/messages_cache._estimate_message_tokens floor-divided and then added a per-tool-use overhead; the prompt compiler's default rounded up with a floor of one. Four call sites, three answers for the same text, and the budgets built on top of them were therefore not comparable.

The rule here is the compiler's, and the reason is the direction of the error. Rounding down answers zero tokens for a three-character string, so a budget filled with short fragments overflows while the arithmetic insists it fits — a provider 400 at the end of a request that already cost money. Rounding up can only refuse a prompt that would have fitted, which is a cheaper mistake and a visible one.

Nothing here is loaded, cached, or fetched: the estimate is a function of len(text). That is the entire reason a cold container compiles the same prompt as a warm one.

HeuristicTokenCounter dataclass

HeuristicTokenCounter(chars_per_token: int = DEFAULT_CHARS_PER_TOKEN)

Characters-per-token arithmetic — the offline default.

Named an estimate because that is what it is. A provider-exact counter is a legitimate substitution, but it arrives as a registered port (:mod:.registry) rather than as a download this module performs.

Implements both port spellings: count for this package and estimate for the prompting capability's :class:TokenEstimator, so one object can be handed to either without an adapter or an import edge.

count

count(text: str) -> int

Tokens in text, rounded up; empty text costs nothing.

The floor of one for non-empty text matters more than it looks: without it a very large chars_per_token makes short strings free, and a budget filled with free strings has no ceiling at all.

Source code in src/symfonic/services/budget/estimation.py
def count(self, text: str) -> int:
    """Tokens in ``text``, rounded up; empty text costs nothing.

    The floor of one for non-empty text matters more than it looks: without
    it a very large ``chars_per_token`` makes short strings free, and a
    budget filled with free strings has no ceiling at all.
    """
    if not text:
        return 0
    return max(1, -(-len(text) // self.chars_per_token))

estimate

estimate(text: str) -> int

Alias of :meth:count under the prompting capability's port name.

Source code in src/symfonic/services/budget/estimation.py
def estimate(self, text: str) -> int:
    """Alias of :meth:`count` under the prompting capability's port name."""
    return self.count(text)

TokenEstimatorAdapter dataclass

TokenEstimatorAdapter(counter: TokenCounter)

Exposes any :class:TokenCounter under the prompting port's name.

The one line of glue that lets an adopter's provider-exact counter budget a compiled prompt: the prompting capability binds estimate, this package speaks count, and neither imports the other.

count_parts

count_parts(parts: Iterable[str], counter: TokenCounter, *, per_part_overhead: int = 0) -> int

Total cost of parts counted separately, plus a per-part envelope.

Counting parts separately rather than concatenating them is deliberate and is what makes this a ceiling: a tokenizer merges across a join, so the concatenated cost is never higher. A budget wants the ceiling.

per_part_overhead is the structural cost a part carries beyond its text — a tool-use block's type, id, and name field. Empty parts are skipped entirely: an absent block has no envelope either.

Source code in src/symfonic/services/budget/estimation.py
def count_parts(
    parts: Iterable[str], counter: TokenCounter, *, per_part_overhead: int = 0
) -> int:
    """Total cost of ``parts`` counted separately, plus a per-part envelope.

    Counting parts separately rather than concatenating them is deliberate and
    is what makes this a *ceiling*: a tokenizer merges across a join, so the
    concatenated cost is never higher. A budget wants the ceiling.

    ``per_part_overhead`` is the structural cost a part carries beyond its text
    — a tool-use block's type, id, and name field. Empty parts are skipped
    entirely: an absent block has no envelope either.
    """
    if per_part_overhead < 0:
        raise BudgetPolicyError(
            f"per_part_overhead must be >= 0, got {per_part_overhead}"
        )
    return sum(counter.count(part) + per_part_overhead for part in parts if part)