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 +9 -0
- pinakes/_toml.py +192 -0
- pinakes/budget/__init__.py +4 -0
- pinakes/budget/estimate.py +146 -0
- pinakes/budget/prices.py +81 -0
- pinakes/budget/prices.toml +17 -0
- pinakes/budget/reserve.py +153 -0
- pinakes/budget/window.py +80 -0
- pinakes/calibrate.py +173 -0
- pinakes/chunk.py +396 -0
- pinakes/cli.py +529 -0
- pinakes/doctor.py +532 -0
- pinakes/embed.py +280 -0
- pinakes/errors.py +442 -0
- pinakes/eval.py +338 -0
- pinakes/extract/__init__.py +221 -0
- pinakes/extract/cache.py +256 -0
- pinakes/extract/floors.py +49 -0
- pinakes/extract/floors.toml +18 -0
- pinakes/extract/layout.py +550 -0
- pinakes/extract/pdfium.py +174 -0
- pinakes/extract/quality.py +644 -0
- pinakes/extract/textpolicy.py +65 -0
- pinakes/hooks.py +117 -0
- pinakes/ids.py +58 -0
- pinakes/init.py +91 -0
- pinakes/lock.py +157 -0
- pinakes/manifest.py +512 -0
- pinakes/pairing.py +454 -0
- pinakes/search.py +407 -0
- pinakes/serve.py +272 -0
- pinakes/sidecar.py +331 -0
- pinakes/store.py +340 -0
- pinakes/sync.py +1289 -0
- pinakes/template.py +88 -0
- pinakes/templates/notes/README.md +12 -0
- pinakes/templates/notes/eval/questions.yaml +12 -0
- pinakes/templates/notes/pinakes.toml.j2 +52 -0
- pinakes/templates/notes/template.toml +5 -0
- pinakes/uri.py +99 -0
- pinakes-0.2.2.dist-info/METADATA +146 -0
- pinakes-0.2.2.dist-info/RECORD +45 -0
- pinakes-0.2.2.dist-info/WHEEL +4 -0
- pinakes-0.2.2.dist-info/entry_points.txt +2 -0
- pinakes-0.2.2.dist-info/licenses/LICENSE +202 -0
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
|
+
)
|
pinakes/budget/prices.py
ADDED
|
@@ -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)
|