pinakes 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pinakes/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """pinakes — a portable, agent-first knowledge base.
2
+
3
+ One directory is one knowledge base: human-readable sources, human-readable metadata, and a
4
+ disposable machine index. The architecture is specified in docs/DESIGN.md.
5
+ """
6
+
7
+ __version__ = "0.2.2"
8
+
9
+ __all__ = ["__version__"]
pinakes/_toml.py ADDED
@@ -0,0 +1,192 @@
1
+ """A strict reader over parsed TOML.
2
+
3
+ Every accessor consumes the key it reads; `done()` then fails on whatever is left. That is how
4
+ unknown keys become hard errors: a manifest saying `finall_k = 20` must not be silently ignored,
5
+ because the user would get default behaviour while believing they had configured something.
6
+
7
+ Errors name the file, the table and the key, in that order — a validation error the user cannot
8
+ locate is barely better than no validation.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+ from decimal import Decimal
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ from pinakes.errors import ManifestError
17
+
18
+ ROOT_NAME = "<root>"
19
+
20
+
21
+ class Table:
22
+ """One TOML table, read destructively so leftovers can be reported."""
23
+
24
+ def __init__(self, data: dict[str, Any], *, name: str, source: Path) -> None:
25
+ self._data = dict(data)
26
+ self._name = name
27
+ self._source = source
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ return self._name
32
+
33
+ def _child_name(self, key: str) -> str:
34
+ """`[retrieval]`, not `[<root>.retrieval]`: the name must match what the user typed."""
35
+ return key if self._name == ROOT_NAME else f"{self._name}.{key}"
36
+
37
+ def _fail(self, message: str, *, remedy: str | None = None) -> ManifestError:
38
+ return ManifestError(self._source, table=self._name, message=message, remedy=remedy)
39
+
40
+ def _take(self, key: str, *, required: bool) -> object | None:
41
+ """Read and consume a key. Returns `object`, not `Any`, so every caller must narrow it."""
42
+ if key in self._data:
43
+ return self._data.pop(key)
44
+ if required:
45
+ raise self._fail(f"missing required key `{key}`")
46
+ return None
47
+
48
+ def _string(self, key: str, *, required: bool) -> str | None:
49
+ value = self._take(key, required=required)
50
+ if value is None:
51
+ return None
52
+ if not isinstance(value, str):
53
+ raise self._fail(f"`{key}` must be a string, found {type(value).__name__}")
54
+ if not value.strip():
55
+ # An explicit empty string is a mistake, never a request for the default: silently
56
+ # substituting one would hide it until something far downstream failed obscurely.
57
+ raise self._fail(f"`{key}` must not be empty")
58
+ return value
59
+
60
+ def string(self, key: str) -> str:
61
+ """A required string. The return type is `str`, so callers need no assertion."""
62
+ value = self._string(key, required=True)
63
+ assert value is not None # `required=True` raised already; this is for the type checker
64
+ return value
65
+
66
+ def optional_string(self, key: str) -> str | None:
67
+ return self._string(key, required=False)
68
+
69
+ def string_or(self, key: str, default: str) -> str:
70
+ return self._string(key, required=False) or default
71
+
72
+ def choice(self, key: str, allowed: Sequence[str], *, default: str | None = None) -> str:
73
+ value = self.string(key) if default is None else self.string_or(key, default)
74
+ if value not in allowed:
75
+ raise self._fail(
76
+ f"`{key}` must be one of {', '.join(repr(option) for option in allowed)}, "
77
+ f"found {value!r}"
78
+ )
79
+ return value
80
+
81
+ def integer(self, key: str, *, default: int | None = None, minimum: int | None = None) -> int:
82
+ value = self._take(key, required=default is None)
83
+ if value is None:
84
+ value = default
85
+ # bool is an int subclass; `max_tokens = true` must not read as 1.
86
+ if not isinstance(value, int) or isinstance(value, bool):
87
+ raise self._fail(f"`{key}` must be an integer, found {type(value).__name__}")
88
+ if minimum is not None and value < minimum:
89
+ raise self._fail(f"`{key}` must be >= {minimum}, found {value}")
90
+ return value
91
+
92
+ def number(
93
+ self, key: str, *, default: float | None = None, minimum: float | None = None
94
+ ) -> float:
95
+ value = self._take(key, required=default is None)
96
+ if value is None:
97
+ value = default
98
+ if isinstance(value, bool) or not isinstance(value, int | float):
99
+ raise self._fail(f"`{key}` must be a number, found {type(value).__name__}")
100
+ value = float(value)
101
+ if minimum is not None and value < minimum:
102
+ raise self._fail(f"`{key}` must be >= {minimum}, found {value}")
103
+ return value
104
+
105
+ def decimal(
106
+ self, key: str, *, default: Decimal | None = None, minimum: Decimal | None = None
107
+ ) -> Decimal:
108
+ """A TOML number read as an exact `Decimal`, for a value that is ever compared or summed
109
+ as money — where `float`'s binary representation error would make a hard cap not actually
110
+ hard (I6a).
111
+
112
+ TOML has no native decimal type, so `tomllib` hands back a `float` regardless; this goes
113
+ through `Decimal(str(value))`, never `Decimal(value)` directly. The former recovers the
114
+ clean literal a user typed (`0.05` -> `Decimal("0.05")`); the latter reproduces the exact
115
+ binary value that literal only approximates (`0.05` ->
116
+ `Decimal("0.05000000000000000277555756156289135105907917022705078125")`) — verified
117
+ directly against every value this project's own `[budget]` defaults use.
118
+ """
119
+ value = self._take(key, required=default is None)
120
+ if value is None:
121
+ assert default is not None # `required=True` above already raised otherwise
122
+ decimal_value = default
123
+ else:
124
+ if isinstance(value, bool) or not isinstance(value, int | float):
125
+ raise self._fail(f"`{key}` must be a number, found {type(value).__name__}")
126
+ decimal_value = Decimal(str(value))
127
+ # Checked on both paths, not only the TOML-sourced one — `integer()`/`number()` above
128
+ # validate their own defaults for free, since they share one type with the parsed value;
129
+ # `decimal()`'s default is already a `Decimal` rather than a parsed float, so an early
130
+ # return here would let a below-`minimum` default slip past unminded.
131
+ if minimum is not None and decimal_value < minimum:
132
+ raise self._fail(f"`{key}` must be >= {minimum}, found {decimal_value}")
133
+ return decimal_value
134
+
135
+ def strings(self, key: str, *, default: Sequence[str] | None = None) -> tuple[str, ...]:
136
+ value = self._take(key, required=default is None)
137
+ if value is None:
138
+ return tuple(default or ())
139
+ if not isinstance(value, list):
140
+ raise self._fail(f"`{key}` must be a list of strings")
141
+ items: list[str] = []
142
+ for item in cast(list[object], value):
143
+ if not isinstance(item, str):
144
+ raise self._fail(
145
+ f"`{key}` must be a list of strings, found a {type(item).__name__}"
146
+ )
147
+ items.append(item)
148
+ return tuple(items)
149
+
150
+ def table(self, key: str) -> "Table | None":
151
+ value = self._take(key, required=False)
152
+ if value is None:
153
+ return None
154
+ if not isinstance(value, dict):
155
+ raise self._fail(f"`{key}` must be a table")
156
+ return Table(cast(dict[str, Any], value), name=self._child_name(key), source=self._source)
157
+
158
+ def tables(self, key: str) -> list["Table"]:
159
+ value = self._take(key, required=False)
160
+ if value is None:
161
+ return []
162
+ if not isinstance(value, list):
163
+ raise self._fail(f"`{key}` must be an array of tables")
164
+ tables: list[Table] = []
165
+ for index, item in enumerate(cast(list[object], value)):
166
+ if not isinstance(item, dict):
167
+ raise self._fail(f"`{key}` must be an array of tables")
168
+ tables.append(
169
+ Table(
170
+ cast(dict[str, Any], item),
171
+ name=f"{self._child_name(key)}[{index}]",
172
+ source=self._source,
173
+ )
174
+ )
175
+ return tables
176
+
177
+ def reject(self, key: str, *, because: str) -> None:
178
+ """Fail loudly on a key that moved, so an old manifest is corrected rather than ignored."""
179
+ if key in self._data:
180
+ raise self._fail(f"`{key}` is not valid here: {because}")
181
+
182
+ def done(self) -> None:
183
+ if self._data:
184
+ unknown = ", ".join(f"`{key}`" for key in sorted(self._data))
185
+ raise self._fail(
186
+ f"unknown key(s): {unknown}",
187
+ remedy=(
188
+ "Unknown keys are rejected rather than ignored — a typo would otherwise leave "
189
+ "you with default behaviour while believing you had configured something. "
190
+ "Check the spelling against docs/DESIGN.md §2.1."
191
+ ),
192
+ )
@@ -0,0 +1,4 @@
1
+ """The pure half of the money machinery (I6a, docs/DESIGN.md §5): estimation, per-call and
2
+ per-document reservation, and ledger-window aggregation. No I/O, no `anthropic` import — reading
3
+ `ledger.jsonl` and actually spending are I6b's job. Money is `Decimal` end to end.
4
+ """
@@ -0,0 +1,146 @@
1
+ """Estimate a PDF document's worst-case cost under a paid extraction backend (I6a, decision 8).
2
+
3
+ **A request is a K-page slice, never a whole document and never a single page.** Getting the unit
4
+ wrong is the difference between a reservation that bounds a run and one that is wrong by an order
5
+ of magnitude in either direction: a whole-document request makes input quadratic and stops fitting
6
+ the context window past a few hundred pages; a per-page request loses the neighbouring context a
7
+ table or sentence continuing across a page break needs. `K = 5` is a semantic constant, not a
8
+ tuning knob — it is hashed into the paid extractor's own request-shape version (I7b), because the
9
+ context a page is transcribed with is part of what produced its text.
10
+
11
+ **Worst case per request** = `(K * page_tokens + prompt_tokens) * input_price + max_tokens *
12
+ output_price`, and a document is `ceil(pages / K)` requests. `prompt_tokens` (the instructions plus
13
+ the JSON schema) is a measured module constant, not an afterthought a real "worst case" could
14
+ silently omit. There is deliberately no cache-write multiplier: the shared prefix (system prompt
15
+ plus instructions) is a few hundred tokens against the model's own cache minimum, so it very likely
16
+ cannot be cached at all, and even cached it is roughly 1% of a request dominated by page tokens.
17
+
18
+ **The whole request must also fit the model's documented context window before the first call** —
19
+ a check that costs nothing to run and, under the shipped constants, never fires (30,300 tokens
20
+ against 1,000,000), but names the exact limit that bounds it rather than letting a real 400 from a
21
+ call already in flight be how the limit is discovered.
22
+
23
+ Money is `Decimal` end to end and never quantised here — quantisation to the cent happens at
24
+ exactly one point, when a reservation or reconciliation is written to the ledger (I6b).
25
+ """
26
+
27
+ import math
28
+ from dataclasses import dataclass
29
+ from datetime import datetime
30
+ from decimal import Decimal
31
+ from typing import Final
32
+
33
+ from pinakes.budget.prices import Prices
34
+ from pinakes.errors import ContextWindowExceededError, StalePricesError
35
+
36
+ #: Pages per request. A semantic constant (I7b hashes it into the request-shape version) — never
37
+ #: read from configuration, because changing it changes what every cached response actually means.
38
+ K: Final = 5
39
+
40
+ #: Conservative ceiling until I7b measures the real figure per backend, `measured_on` recorded
41
+ #: beside it. Derived from the vendor's own documented high-resolution page budget (~4,784 visual
42
+ #: tokens per rendered page) plus that page's text, applied to the document's actual page count,
43
+ #: never the API's own maximum page count per request.
44
+ PAGE_TOKEN_CEILING: Final = 6_000
45
+
46
+ #: The instructions plus the JSON schema a real request sends, measured once as a module constant
47
+ #: — omitting it from the worst-case formula would make "worst case" a claim the formula does not
48
+ #: back.
49
+ PROMPT_TOKENS: Final = 300
50
+
51
+ #: Output ceiling per request. Caps thinking and response text *together* on `claude-opus-5`, so it
52
+ #: is the correct and only safe per-request output bound. ~4,000 tokens actually produced per
53
+ #: 5-page slice against this leaves 2x headroom, so a truncation retry is rare.
54
+ MAX_TOKENS: Final = 8_000
55
+
56
+ _TIMESTAMP_FORMAT: Final = "%Y%m%d %H:%M"
57
+
58
+ #: Documented maximum input tokens, by model name. Not a price, so it does not live in
59
+ #: `prices.toml` — a model absent here cannot be context-window-checked, which `estimate_document`
60
+ #: treats as "nothing to check" rather than a reason to refuse an otherwise-fine estimate.
61
+ MAX_INPUT_TOKENS: Final[dict[str, int]] = {
62
+ "claude-opus-5": 1_000_000,
63
+ }
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class Estimate:
68
+ """A worst-case cost estimate for one document, at the request granularity decision 8 fixes."""
69
+
70
+ model: str
71
+ pages_total: int
72
+ pages_estimated: int
73
+ requests: int
74
+ input_tokens_per_request: int
75
+ output_tokens_per_request: int
76
+ input_eur: Decimal
77
+ output_eur: Decimal
78
+
79
+ @property
80
+ def total_eur(self) -> Decimal:
81
+ return self.input_eur + self.output_eur
82
+
83
+ @property
84
+ def per_request_eur(self) -> Decimal:
85
+ """Every request costs the same under this model (same `K`, same prompt, same
86
+ `max_tokens`) — what `reserve()` checks before each individual call."""
87
+ return self.total_eur / self.requests
88
+
89
+
90
+ def estimate_document(
91
+ *,
92
+ pages: int,
93
+ model: str,
94
+ prices: Prices,
95
+ now: str,
96
+ max_price_age_days: int,
97
+ pages_estimated: int | None = None,
98
+ ) -> Estimate:
99
+ """Estimate the worst-case cost of extracting `pages` pages of a document with `model`.
100
+
101
+ `now` is an explicit `YYYYMMDD HH:MM` string, never read from the wall clock internally —
102
+ staleness is checked against whatever the caller supplies, which is what keeps this function
103
+ pure and deterministic under test. `pages_estimated` defaults to the whole document (`pages`);
104
+ a caller estimating only a remaining slice of a larger document may pass a smaller value, with
105
+ `pages` still naming the document's true total.
106
+ """
107
+ if pages < 1:
108
+ raise ValueError(f"estimate_document: pages={pages} must be >= 1")
109
+ estimated = pages if pages_estimated is None else pages_estimated
110
+ if not 1 <= estimated <= pages:
111
+ raise ValueError(
112
+ f"estimate_document: pages_estimated={estimated} must be between 1 and pages={pages}"
113
+ )
114
+
115
+ as_of = datetime.strptime(prices.as_of, _TIMESTAMP_FORMAT)
116
+ current = datetime.strptime(now, _TIMESTAMP_FORMAT)
117
+ if (current - as_of).days > max_price_age_days:
118
+ raise StalePricesError(as_of=prices.as_of, max_age_days=max_price_age_days)
119
+
120
+ model_price = prices.for_model(model)
121
+
122
+ request_input_tokens = K * PAGE_TOKEN_CEILING + PROMPT_TOKENS
123
+ max_input = MAX_INPUT_TOKENS.get(model)
124
+ if max_input is not None and request_input_tokens > max_input:
125
+ raise ContextWindowExceededError(
126
+ request_tokens=request_input_tokens, max_input_tokens=max_input, model=model
127
+ )
128
+
129
+ requests = math.ceil(estimated / K)
130
+ input_tokens_total = requests * request_input_tokens
131
+ output_tokens_total = requests * MAX_TOKENS
132
+
133
+ million = Decimal(1_000_000)
134
+ input_usd = (Decimal(input_tokens_total) / million) * model_price.input_per_mtok_usd
135
+ output_usd = (Decimal(output_tokens_total) / million) * model_price.output_per_mtok_usd
136
+
137
+ return Estimate(
138
+ model=model,
139
+ pages_total=pages,
140
+ pages_estimated=estimated,
141
+ requests=requests,
142
+ input_tokens_per_request=request_input_tokens,
143
+ output_tokens_per_request=MAX_TOKENS,
144
+ input_eur=input_usd / prices.usd_per_eur,
145
+ output_eur=output_usd / prices.usd_per_eur,
146
+ )
@@ -0,0 +1,81 @@
1
+ """`prices.toml` loading — the money-side twin of `extract/floors.py`.
2
+
3
+ Ships as package data beside `extract/floors.toml`, read through `importlib.resources` for the
4
+ same reason: a file that lives only in the source tree is invisible to an installed wheel, and
5
+ prices are consumed at runtime, not at repo-checkout time.
6
+
7
+ Every value in the file is a TOML *string*, not a bare number — parsed via `Decimal(the_string)`
8
+ directly, never `Decimal(float(the_string))`. TOML has no native decimal type, so a bare `5.00`
9
+ would come back from `tomllib` as a `float`, and `Decimal(5.00)` reproduces the exact binary value
10
+ that literal only approximates, not the clean decimal a human wrote (verified directly,
11
+ docs/RETROSPECTIVES.md). Because this file is entirely project-controlled — never user-authored,
12
+ unlike `pinakes.toml` — writing prices as strings costs nothing and removes the float
13
+ intermediary altogether, rather than reconstructing it from `str(float(...))` the way
14
+ `manifest.py`'s `Table.decimal()` has to for a user-authored TOML number.
15
+ """
16
+
17
+ import tomllib
18
+ from dataclasses import dataclass
19
+ from decimal import Decimal, InvalidOperation
20
+ from importlib import resources
21
+ from typing import Any, cast
22
+
23
+ from pinakes.errors import PricesMissingError, UnknownModelPriceError
24
+
25
+ PRICES_RESOURCE = "prices.toml"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ModelPrice:
30
+ input_per_mtok_usd: Decimal
31
+ output_per_mtok_usd: Decimal
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Prices:
36
+ as_of: str
37
+ usd_per_eur: Decimal
38
+ models: dict[str, ModelPrice]
39
+
40
+ def for_model(self, model: str) -> ModelPrice:
41
+ found = self.models.get(model)
42
+ if found is None:
43
+ raise UnknownModelPriceError(model, known=tuple(self.models))
44
+ return found
45
+
46
+
47
+ def load_prices() -> Prices:
48
+ """Read `prices.toml` through `importlib.resources` — never a repo-relative path, or a source
49
+ checkout would silently pass what an installed wheel cannot (the same lesson `floors.py`
50
+ already states)."""
51
+ try:
52
+ raw = (
53
+ resources.files("pinakes.budget").joinpath(PRICES_RESOURCE).read_text(encoding="utf-8")
54
+ )
55
+ except (FileNotFoundError, OSError) as exc:
56
+ raise PricesMissingError(reason=str(exc)) from exc
57
+
58
+ try:
59
+ data = tomllib.loads(raw)
60
+ raw_models = cast(dict[str, Any], data["models"])
61
+ models = {
62
+ name: ModelPrice(
63
+ input_per_mtok_usd=Decimal(str(entry["input_per_mtok_usd"])),
64
+ output_per_mtok_usd=Decimal(str(entry["output_per_mtok_usd"])),
65
+ )
66
+ for name, entry in raw_models.items()
67
+ }
68
+ return Prices(
69
+ as_of=str(data["as_of"]),
70
+ usd_per_eur=Decimal(str(data["usd_per_eur"])),
71
+ models=models,
72
+ )
73
+ except (
74
+ tomllib.TOMLDecodeError, # syntax
75
+ KeyError, # a required key absent
76
+ TypeError, # a value the wrong shape (e.g. `models` not a table)
77
+ ValueError,
78
+ InvalidOperation, # `Decimal(str(x))` on an unparsable value, e.g. "5,00" or "TBD"
79
+ AttributeError, # `models` present but not a table (`raw_models.items()` on a str/int)
80
+ ) as exc:
81
+ raise PricesMissingError(reason=f"malformed prices.toml ({exc})") from exc
@@ -0,0 +1,17 @@
1
+ # Ships as package data (I6a) — read at runtime through `importlib.resources`, never a
2
+ # repo-relative path, so an installed wheel sees exactly what a source checkout does.
3
+ #
4
+ # Every price is a *string*, not a bare TOML number: this file is entirely project-controlled
5
+ # (never user-authored, unlike pinakes.toml), so there is no reason to let a TOML float sit
6
+ # between the literal below and the `Decimal` this project does all money arithmetic in.
7
+ # `Decimal("5.00")` is exact; `Decimal(5.00)` is not (verified directly, docs/RETROSPECTIVES.md).
8
+ #
9
+ # usd_per_eur carries the same as_of as the model prices below: every EUR figure this project
10
+ # computes depends on it, and a rate with its own, different staleness would make "the prices are
11
+ # fresh" a claim about only half of what an estimate actually uses.
12
+ as_of = "20260728 16:31"
13
+ usd_per_eur = "1.08"
14
+
15
+ [models.claude-opus-5]
16
+ input_per_mtok_usd = "5.00"
17
+ output_per_mtok_usd = "25.00"
@@ -0,0 +1,153 @@
1
+ """The §5 accountant — pure: given an estimate, the configured caps, and what has already been
2
+ spent, decide whether a call (or a whole document) may proceed (I6a).
3
+
4
+ **Before every call, all three ceilings are checked** — `per_operation_eur`, `daily_eur`, and
5
+ `monthly_eur` — and if any of `spent.X + reserved` would exceed its cap, the call is never made.
6
+ One operation (one `pnk sync`, one `pnk ask --deep`) bounds a single invocation only; the day and
7
+ month windows are what stop a *sequence* of invocations, which is the failure mode a hook-driven KB
8
+ actually has.
9
+
10
+ **The whole document is checked before the first call**, not only per call: per-call reservation
11
+ alone bounds each call and nothing else, and a document that will certainly blow through a window
12
+ by call 15 should be refused at call 0, not discovered by watching it fail partway through.
13
+ `reserve_document` is that upfront check — it refuses with all three windows' current headroom,
14
+ the computed estimate, the complete manifest edit that would admit this run, and a line on the
15
+ ongoing exposure that edit creates (a raised cap is permanent; a one-run `--extract=` override for
16
+ a specific paid backend is not).
17
+
18
+ `confirm_above_eur` is evaluated **once, against the whole-document estimate** — never per call
19
+ (`reserve` itself does not touch it): a per-call reading against a several-cent slice would prompt
20
+ dozens of times for one multi-page document, which is how a confirmation becomes something a user
21
+ learns to hold down `y` through.
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal
26
+
27
+ from pinakes.budget.estimate import Estimate
28
+ from pinakes.budget.window import WindowTotals
29
+
30
+ _CENT = Decimal("0.01")
31
+
32
+
33
+ def _display(amount: Decimal) -> str:
34
+ """Round-half-up to the cent for a human-facing message only — the comparisons above run at
35
+ full `Decimal` precision throughout; quantisation for *storage* happens once, at ledger-write
36
+ time (I6b), and this is a separate, display-only rounding that never feeds back into a
37
+ decision."""
38
+ return str(amount.quantize(_CENT, rounding=ROUND_HALF_UP))
39
+
40
+
41
+ #: (attribute on `Caps`, attribute on `WindowTotals`, display name) — one row per window, checked
42
+ #: in this order everywhere the three are named together.
43
+ _WINDOWS: tuple[tuple[str, str, str], ...] = (
44
+ ("per_operation_eur", "operation", "per_operation_eur"),
45
+ ("daily_eur", "day", "daily_eur"),
46
+ ("monthly_eur", "month", "monthly_eur"),
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Caps:
52
+ per_operation_eur: Decimal
53
+ daily_eur: Decimal
54
+ monthly_eur: Decimal
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Decision:
59
+ """The outcome of one per-call `reserve()` check."""
60
+
61
+ allowed: bool
62
+ blocked_by: str | None = None
63
+ """Which window refused it — `"per_operation_eur"`, `"daily_eur"` or `"monthly_eur"` — `None`
64
+ when `allowed`."""
65
+ message: str | None = None
66
+ """A ready-to-print refusal, set only when `allowed` is `False`."""
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class DocumentDecision:
71
+ """The outcome of the whole-document `reserve_document()` precheck."""
72
+
73
+ allowed: bool
74
+ needs_confirmation: bool
75
+ """`confirm_above_eur` reached — a soft prompt, meaningful only when `allowed`."""
76
+ message: str | None = None
77
+ """The complete, multi-line refusal — every window's headroom, the estimate, the manifest
78
+ edit that would admit this run, and the ongoing-exposure line. Set only when not `allowed`."""
79
+
80
+
81
+ def reserve(reserved_eur: Decimal, caps: Caps, spent: WindowTotals) -> Decision:
82
+ """Check one call's estimated cost against all three windows, in order. The first window it
83
+ would breach is the one named — the call is refused before any of the others are even
84
+ checked, since one honest reason is clearer than three, and the caller cannot make the call
85
+ anyway."""
86
+ for cap_attr, spent_attr, name in _WINDOWS:
87
+ cap = getattr(caps, cap_attr)
88
+ already = getattr(spent, spent_attr)
89
+ would_be = already + reserved_eur
90
+ if would_be > cap:
91
+ message = (
92
+ f"refused: the {name} cap of €{_display(cap)} would be exceeded "
93
+ f"(already spent €{_display(already)} this window, this call would add "
94
+ f"€{_display(reserved_eur)}, total €{_display(would_be)})."
95
+ )
96
+ return Decision(allowed=False, blocked_by=name, message=message)
97
+ return Decision(allowed=True)
98
+
99
+
100
+ def reserve_document(
101
+ estimate: Estimate, caps: Caps, spent: WindowTotals, *, confirm_above_eur: Decimal
102
+ ) -> DocumentDecision:
103
+ """Check a whole document's worst-case estimate against all three windows before the first
104
+ call. Unlike `reserve`, a refusal here names *every* window at once — walking a user through
105
+ one manifest edit, then a second, then a third, to discover the actual ceiling is precisely
106
+ the defect this exists to avoid."""
107
+ total = estimate.total_eur
108
+ blocked = [
109
+ (cap_attr, spent_attr, name, getattr(caps, cap_attr), getattr(spent, spent_attr))
110
+ for cap_attr, spent_attr, name in _WINDOWS
111
+ if getattr(spent, spent_attr) + total > getattr(caps, cap_attr)
112
+ ]
113
+ if blocked:
114
+ return DocumentDecision(
115
+ allowed=False, needs_confirmation=False, message=_refusal_message(estimate, blocked)
116
+ )
117
+ return DocumentDecision(allowed=True, needs_confirmation=total > confirm_above_eur)
118
+
119
+
120
+ def _refusal_message(
121
+ estimate: Estimate, blocked: list[tuple[str, str, str, Decimal, Decimal]]
122
+ ) -> str:
123
+ lines = [
124
+ f"refused: extracting {estimate.pages_estimated} page(s) of {estimate.pages_total} with "
125
+ f"{estimate.model} is estimated at €{_display(estimate.total_eur)} "
126
+ f"({estimate.requests} request(s), worst case), which exceeds "
127
+ f"{len(blocked)} of the three budget windows:",
128
+ ]
129
+ edits: list[str] = []
130
+ for cap_attr, _spent_attr, name, cap, already in blocked:
131
+ headroom = cap - already
132
+ # Negative when a cap was lowered below already-recorded spend for this window (the cap
133
+ # check itself never lets `already` alone exceed a cap that held for the whole window) —
134
+ # "headroom €-1.00" reads as a typo, not as already being over.
135
+ headroom_text = (
136
+ f"headroom €{_display(headroom)}"
137
+ if headroom >= 0
138
+ else f"already €{_display(-headroom)} over cap"
139
+ )
140
+ minimum_cap = (already + estimate.total_eur).quantize(_CENT, rounding=ROUND_CEILING)
141
+ lines.append(
142
+ f" - {name}: cap €{_display(cap)}, already spent €{_display(already)} this window, "
143
+ f"{headroom_text} — this run needs €{_display(estimate.total_eur)}."
144
+ )
145
+ edits.append(f"{cap_attr} = {minimum_cap}")
146
+ lines.append("The complete manifest edit that would admit this run:")
147
+ lines.append(" [budget]")
148
+ lines.extend(f" {edit}" for edit in edits)
149
+ lines.append(
150
+ "Raising a cap is a permanent, ongoing exposure to every future run at that ceiling — a "
151
+ "one-run `--extract=<backend>` override changes only this invocation, not the manifest."
152
+ )
153
+ return "\n".join(lines)