Budgeting Tokens Across Map Layers

Allocate a geometry budget between layers by relevance rather than density, floor every requested layer, and report what each layer contributed and what it omitted.

A map view has layers, and they have wildly different densities: four hundred buildings, thirty road segments, two zoning polygons. A single feature limit hands the whole budget to the densest layer, which is almost never the one holding the answer. This guide allocates the budget deliberately, as the layer-aware half of context-window optimization for maps.

When to Use This Approach

Allocate per layer whenever more than one layer is in scope. For a single-layer view a global limit is equivalent and simpler.

Question Layer weighting Reason
“What is at this address” Buildings high, zoning low The answer is a specific feature
“Can I build here” Zoning and constraints high Buildings are context, not answer
“What is this area like” Even, favouring variety Character comes from the mix
“Where does the boundary run” One layer, high detail Everything else is a distraction
Unclassified Even, with floors Fails least badly

The fourth row is worth separating out because it inverts the usual advice. When a question is about one layer’s geometry, spending budget on other layers actively harms the answer by consuming detail the subject needed.

Density against relevance across three layersThe densest layer contributes the most features under a global limit while contributing the least to the answer, and the two-feature zoning layer that holds the answer is crowded out entirely.One view, three layers, and where the answer actually isbuildingsroadszoning400 features302the answer is in hereA global limit of thirty features shows thirty buildings and nothing else
Density and relevance are unrelated. The layer with the most features usually has the least to say per feature, and a limit expressed in features rather than in layers systematically prefers it.

Implementation

The allocator distributes the geometry budget by weight, guarantees every requested layer a floor, and returns the allocation so it can be logged and explained.

import logging
from dataclasses import dataclass
from typing import Mapping

log = logging.getLogger("layer_budget")

FLOOR_DIVISOR = 4              # every layer gets at least budget / (layers * 4)


@dataclass(frozen=True)
class Allocation:
    per_layer: dict[str, int]
    floor: int
    note: str


def allocate(available: Mapping[str, int], weights: Mapping[str, float],
             geometry_budget: int) -> Allocation:
    """Split a geometry budget across layers by weight, with a floor for each."""
    layers = [name for name, count in available.items() if count > 0]
    if not layers:
        return Allocation({}, 0, "no layer has any feature in view")
    if geometry_budget <= 0:
        raise ValueError("geometry budget must be positive")

    floor = max(1, geometry_budget // (len(layers) * FLOOR_DIVISOR))
    total_weight = sum(max(0.0, weights.get(name, 0.0)) for name in layers)
    if total_weight <= 0:
        even = geometry_budget // len(layers)
        log.info("no usable weights supplied — allocating evenly")
        return Allocation({name: even for name in layers}, floor,
                          "even allocation: no weights supplied")

    raw = {name: max(floor, int(geometry_budget * max(0.0, weights.get(name, 0.0)) / total_weight))
           for name in layers}

    # Floors can push the total over budget; trim proportionally from the layers above floor.
    overshoot = sum(raw.values()) - geometry_budget
    while overshoot > 0:
        trimmable = [n for n in layers if raw[n] > floor]
        if not trimmable:
            break                                  # every layer is at its floor: accept and log
        for name in sorted(trimmable, key=lambda n: -raw[n]):
            if overshoot <= 0:
                break
            take = min(raw[name] - floor, max(1, overshoot // len(trimmable)))
            raw[name] -= take
            overshoot -= take

    note = "" if overshoot <= 0 else f"floors exceed the budget by {overshoot} tokens"
    if note:
        log.info("layer floors exceed the geometry budget by %d", overshoot)
    return Allocation(raw, floor, note)

The floor is the load-bearing part. Without it a weighting that reflects the question — buildings at 0.7, zoning at 0.05 — gives the zoning layer a handful of tokens and effectively excludes it, which is precisely the failure the per-layer approach exists to prevent. With it, a low-weighted layer still contributes one or two features, and one zoning polygon is frequently the whole answer.

Accepting an overshoot when every layer sits at its floor is also deliberate. That condition means the budget is too small for the number of layers requested, and the honest response is to report it rather than to silently drop a layer.

Weighted allocation with and without a floorPure weighting starves a low-weighted layer to nothing, while the same weighting with a floor leaves each layer a small guaranteed share that is often enough to carry the answer.weights onlybuildingsroadswith a floorbuildingsroadszoningThe zoning layer had two polygons and one of them was the answer
A small guaranteed share is worth a large weighted one. Layers with few features tend to have high information per feature, so the floor recovers most of what pure weighting throws away for a small cost to the dominant layer.

Validation & Testing

def test_every_layer_with_features_gets_at_least_the_floor():
    a = allocate({"buildings": 400, "roads": 30, "zoning": 2},
                 {"buildings": 0.7, "roads": 0.25, "zoning": 0.05}, 8000)
    assert all(v >= a.floor for v in a.per_layer.values())


def test_empty_layers_are_excluded_from_the_split():
    a = allocate({"buildings": 400, "hydrology": 0}, {"buildings": 1.0}, 4000)
    assert "hydrology" not in a.per_layer


def test_total_does_not_exceed_the_budget_when_it_can_be_met():
    a = allocate({"a": 10, "b": 10, "c": 10}, {"a": 0.8, "b": 0.15, "c": 0.05}, 3000)
    assert sum(a.per_layer.values()) <= 3000


def test_impossible_budget_is_reported_not_hidden():
    a = allocate({f"l{i}": 5 for i in range(12)}, {}, 24)
    assert a.note and "exceed" in a.note


def test_missing_weights_fall_back_to_even():
    a = allocate({"a": 5, "b": 5}, {}, 1000)
    assert a.per_layer["a"] == a.per_layer["b"]

The fourth test is the one worth keeping through refactors. It asserts that an over-constrained allocation is visible rather than resolved by quietly dropping layers, which is what every straightforward implementation does when the arithmetic stops working.

Run these against the real weight table rather than fixtures of it. The weights are configuration that changes as question classes are added, and a test that supplies its own weights keeps passing long after the production table has acquired a class with no entry — which silently falls through to an even split.

Gotchas & Edge Cases

Weights derived from layer density. Weighting by how many features a layer has re-creates the problem the allocator exists to solve. Weights come from the question class, never from the data.

A floor set too high. With a dozen layers and a small budget, floors alone can consume everything and the weighting stops mattering. Scale the floor with the layer count, as above, and report when it binds.

Layers with features that are individually enormous. A single national boundary can exceed a whole layer’s allocation. Reduce within the layer first — the tokenizer’s reduction ladder handles this — and only then treat the layer as over budget.

Redistributing an allocation a layer cannot spendA layer with two small polygons leaves most of its allocation unused; a second pass returns the surplus to the layers that can absorb it, recovering budget that would otherwise be wasted.first passbuildingsroadszoning: unspentredistributedbuildingsroadsThe surplus goes to the layers that can use it, in weight order
Unspent budget is a real loss. A layer with two polygons cannot absorb a large share, and leaving those tokens idle costs exactly as much as allocating them badly would have.

Allocation computed before the question is classified. The weights depend on the question class, so an allocator called too early in the pipeline gets defaults every time. Classify first, allocate second.

Empty layers counted in the split. A layer with no features in view should not receive a floor; giving it one takes tokens from layers that could use them. Filter before allocating, as the implementation does.

A weight table with no entry for a class. The lookup falls through to an even split, which is a reasonable default and is not what anyone intended for that class. Log the fall-through and treat a recurring one as a missing table row.

Allocation not reported. The per-layer split is the single most useful line in a prompt-assembly log, because it explains at a glance why an answer talked about buildings and not zoning. Log it with the question class beside it.

Frequently Asked Questions

Where should layer weights come from?

A small table keyed on question class, hand-written and reviewed rather than learned. There are rarely more than half a dozen classes worth distinguishing, the weights are easy to reason about, and a table is inspectable in a way a fitted model is not. Learn them only if you have a labelled set large enough to fit them and the discipline to refit when the question mix changes.

Should the allocation be in tokens or in features?

Tokens, because features vary enormously in cost — a building footprint and a coastline differ by two orders of magnitude. Allocating features gives a layer of large geometries an unbounded share of the budget while appearing fair. Convert to features inside each layer, using measured token costs, once the token allocation is known.

What happens when a layer cannot use its allocation?

Return the surplus and redistribute. A layer with two small polygons will not spend a large allocation, and leaving the tokens unused wastes budget that the dense layers would happily absorb. A second pass that redistributes unspent allocation in weight order is a few lines and recovers a useful fraction on most requests.

Does this interact with retrieved prose?

Only through the split between prose and geometry made one level up. Once that split is fixed, this allocator divides the geometry half and never touches the prose. Keeping the two decisions separate is what makes both explainable: one answers "how much of the window is geometry", the other answers "which layers get it".

How should a layer that was omitted entirely be reported?

By name, in the prompt header, alongside the count it would have contributed. A model told that a hydrology layer exists in the view but was not included can say so when a question turns out to be about water; one that simply never saw the layer will answer as though the view contained no water features at all. This costs a handful of tokens and removes a whole class of confidently incomplete answers.

Is it worth allocating differently for follow-up questions?

Yes, and it is one of the clearest wins available. A follow-up narrows the subject — the user has asked about zoning specifically — so the weighting for that turn should shift decisively toward the layer they asked about. Treating every turn as an independent request with the same weights wastes most of the budget re-establishing context the conversation already has.