Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 50 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@ A Python package that makes a TypeSafe Jev judgment usable as control flow: a ye
`if`, a choice is an exhaustive `match`, a score is a comparison. One distribution, `guideme`,
published to PyPI under `MIT OR Apache-2.0`. The public surface has two tiers:

- the 35 names in `__all__` in `src/guideme/__init__.py`, imported from `guideme` itself;
- the 44 names in `__all__` in `src/guideme/__init__.py`, imported from `guideme` itself;
- `guideme.api` and `guideme.policy` as whole modules, imported by their own path and not
re-exported at the top level: `guideme.api` is the wire mirror and `guideme.api.client`
holds `Client` and `AsyncClient`, and `guideme.policy` holds `resolve`.
- one name from a third module, `guideme.question.Question`: what every constructor in the
first tier returns, and the only way to write the type of a stored question down. The
promise covers that name and nothing else in `guideme.question` — `validate`, `Spec` and
the concrete question classes stay private, because a tier is a promise and this is the
narrowest one that lets a caller annotate. The README's **Lower layers** section documents
all of it.

Six modules declare an `__all__`: `guideme` itself, `guideme.api`, and the four it draws
names from — `question`, `policy`, `enums` and `errors`. Where a module has one, the list is
what it owns rather than what it happens to have imported. `guideme/__init__.py` is the one
exception to that reading, because it is a façade and every name in its list arrived by
import; `tests/test_surface.py` therefore holds only `guideme.api` to the stricter rule,
proving by AST that nothing it imported is re-exported. The rest — `receipt`, `scalars`,
`guide`, `api.client`, `telemetry`, `ask`, `_json` — declare none, and nothing here asks
them to: an `__all__` earns its place by being checked, and the check is the surface test. `guideme.question` no longer has a tier of its
own: `Question`, the three question classes and the three detail classes are all in the top
list now, because a caller annotating a stored question needed them and importing from a
module the README called private to do it was the wrong answer. `validate`, `Spec` and the
criteria shapes stay private, as do `render` and the `require_*` checks in `guideme.enums`
despite their public-looking names. The README's **Lower layers** section documents all of it.

Everything else in the package is private, whatever its name looks like.

Expand All @@ -43,19 +51,31 @@ two pages: `https://docs.typesafe.ai/api.md` covers `POST /v1/systemone` and
| `src/guideme/enums.py` | `Choice`, `Levels`, `option`, `level`, `fallback`, and the internals `render` and the `require_*` checks | a member's name is its wire key and its value is its rubric; both validate at class definition, and a repeated rubric text is refused there. `render` is the one place a rubric's examples become wire text, and its output is a cross-SDK contract item. `render`, `require_unshared_examples`, `require_no_counterexamples` and `require_no_fallback` are internal despite their names: they are imported by `question.py` and are in no tier, like `question.validate` |
| `src/guideme/question.py` | question kinds, constructors, `Ranked`, `Scored`, the unsure ladder | a question is inert until asked; the reader travels with it |
| `src/guideme/ask.py` | shapes: `encode`, `decode`, `Plan` | ids are `q0..qN` in encounter order, insertion order for a dict |
| `src/guideme/_ask_overloads.py` | the typed `ask` surfaces | GENERATED; edit `scripts/gen_ask_overloads.py` and run `mise run gen` |
| `src/guideme/receipt.py` | `Receipt` and the value-object `Usage` | its own module because the generated `ask` surfaces name `Receipt` in a return type, so it has to sit below them; it imports nothing from the package |
| `src/guideme/_ask_overloads.py` | the typed `ask` and `ask_with_receipt` surfaces | GENERATED; edit `scripts/gen_ask_overloads.py` and run `mise run gen` |
| `src/guideme/telemetry.py` | every span, event, log record and attribute | the names are the contract, documented in `docs/observability.md`; installs no provider |
| `src/guideme/api/__init__.py` | the wire mirror and the adapters to the core | mirrors `spec/schema/*.json` field for field; no policy here |
| `src/guideme/api/client.py` | HTTP, retries, statuses to errors, one span per attempt | the only importer of `httpx`; every decision it makes is made by the pure `step` |
| `src/guideme/guide.py` | `Guide`, `AsyncGuide`, `GuideBuilder`, `ModelInfo` | the two executors share `_prepare` and `_finish`; what is written twice is the two `await`s |
| `spec/` | the vendored schemas and golden vectors | read-only here; it is `guideme-rust`'s output, and `mise run spec-check` proves this copy matches |

**No pydantic type reaches the top-level surface.** `ModelInfo` copies the wire's
`ModelEntry` and `receipt.Usage` copies the wire's `api.Usage`, so what `models()` and
`ask_with_receipt` hand back is this package's own value object in both cases. The wire
models keep their names inside `guideme.api`, where naming pydantic is the point. A type on
the published surface must not carry a dependency's methods or change shape when that
dependency has a major release, and "it is only two integers" is not an exception to that —
it is how the first one would get in.

Modules keep a one-way import graph, which `pyright`'s `reportImportCycles` enforces:
`errors` imports nothing from the package; `_json` and `scalars` import `errors`; `policy`
imports `errors` and `scalars`; `enums` imports `errors` and `policy`; `question` imports the
above; `ask` imports `question`; `_ask_overloads` imports `_json` and `question`; `telemetry`
imports `errors` and `policy`; `api` imports `question` and below; `api.client` imports `api`
and `telemetry`. Nothing inside the package writes `from guideme import ...`: that would
above; `ask` imports `question`; `telemetry` imports `errors` and `policy`; `api` imports
`question` and below; `receipt` imports nothing from the package; `_ask_overloads` imports
`_json`, `question` and `receipt`; `api.client` imports `api` and `telemetry`. `receipt` sits where it does
because `_ask_overloads` names `Receipt` in a return type and `guide` imports
`_ask_overloads`, so the type cannot live in `guide`.
Nothing inside the package writes `from guideme import ...`: that would
import the package's own `__init__`, which imports the executors, which import `api`.

`docs/design.md` records the decisions and the sharp edges. Update it when a decision changes.
Expand Down Expand Up @@ -185,7 +205,15 @@ is made in `guideme-rust` first, not here.

## Tests

Few tests, high grade. The ceiling is 47 test functions; a parametrised function counts once.
Few tests, high grade. The ceiling is 51 test functions; a parametrised function counts once.
It was 47 before 0.2.0, which added four. Each one reaches something no existing function
could: the counted connection pool needs two guides over one pool, which nothing else builds;
the receipt needs the response's `model` and `usage` read back by a caller, where every other
test reads them off a span; an injected transport answers with no server at all, and every
other wire assertion is written against one; and the connection-failure retry needs a
transport that fails on demand, which no local server can be made to do. Everything else 0.2.0
added — the `529` carrying its `retry-after`, `GET /v1/models` being retried, and the five
transport-and-timeout refusals — went into a parameter of a test that was already there.
It was 40 before the logs signal, which is user-requested scope that the span assertions could
not cover: correlation, severity and routing each need a record to look at. The forty-third is
the pre-publish proof that a log sink which raises reaches neither the caller nor the ask span:
Expand All @@ -209,6 +237,16 @@ A new test must be one of:
- a structural import-boundary proof (AST) over `src/guideme`;
- a redaction proof.

The server-backed checks live in two modules, split at 0.2.0 when the one they were in
reached a thousand lines. `tests/test_wire.py` is what guideme puts on the wire and reads
back: the schemas, the docs examples, the request a shape builds, the errors a malformed
response raises, the unsure ladder, and what a configuration mistake refuses.
`tests/test_retries.py` is the request that does not simply succeed: the status-to-error
table, the retry policy on both endpoints, what is and is not resent before a response
arrives, an injected transport, and the pool two guides share. They are one subject each
because they are one code path each, and a helper both need — `answering_offline` and
`MODELS_BODY` — belongs in `conftest.py` rather than being imported across.

Two of the illegal states this package refuses cannot be checker errors: an enum body is opaque
to pyright, so a `Choice` with two `fallback` members and a `Levels` with one member are a
`ConfigError` raised at class definition, proven in `tests/test_enums.py`. Every other negative
Expand Down
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,96 @@

Nothing yet.

## 0.2.0 — 2026-09-22

One breaking change, and it is one nobody outside this repository can have depended on yet.
Everything else is additive.

### Breaking

- `OverloadedError` now takes the `retry-after` the API sent: `OverloadedError(retry_after)`
with a `retry_after` attribute, mirroring `RateLimitedError`. A `529` carries that header as
often as a `429` does, and both SDKs were throwing it away on the one path where it is the
only thing that says when to come back. Constructing the error by hand is the only code this
moves; catching it is unchanged.

### Added

- `ask_with_receipt` on `Guide` and `AsyncGuide`, with the same overload family as `ask`. It
returns `Receipt[T]` — `answer`, `model`, `usage` — so cost attribution and pinning a policy
to the model version that produced its numbers no longer need an OpenTelemetry pipeline.
`ask` is that call followed by `.answer`. `Receipt` and `Usage` are exported from `guideme`,
and `Usage` is a frozen dataclass of two `int`s copied out of the wire model, so no
pydantic type reaches the top-level surface — the trade `ModelInfo` already makes.
- `with` and `async with` on the two guides, each closing the guide on the way out. The pool a
guide holds is now counted: `with_policy(…)` takes a second hold on it, and closing either
guide leaves the other able to ask. Before this, closing a derived guide closed its parent's
pool, which was a documented sharp edge and would have been a trap under `with`.
- `GuideBuilder.transport(…)` and `.async_transport(…)`, taking an `httpx.BaseTransport` and an
`httpx.AsyncBaseTransport`. A proxy, a client certificate, or an `httpx.MockTransport` that
answers a test with no server, no port and no key — the README's new **Testing your code**
section is that test written out. A transport and `timeout(…)` refuse each other in either
order, because a custom transport is free to ignore the budget `httpx` hands it and a
silent no-op is worse than a `ConfigError`; so does building the wrong kind of guide from
one, and so does setting both transports on one builder, which could build neither.
- `GET /v1/models` is retried on `429` and `529`, through the same loop and the same spans an
ask uses. The API's docs say an SDK handles a `429` for you, and a `429` during startup used
to fail the start.
- A failed connection is retried inside the same `max_retries` budget and backoff:
`httpx.ConnectError`, which means the request never reached a server, so nothing was
judged and nothing is repeated. A disconnect part-way through a response and a body that
will not decode are still not retried — the request arrived, and a resend would buy the
same judgment twice. **No timeout is retried, of any phase**, `httpx.ConnectTimeout`
included: Rust sets one deadline over the whole attempt and cannot tell a connect timeout
from a read one, so retrying it here would make the two SDKs disagree about the same
failure, and a retried timeout multiplies the wall time `timeout(…)` exists to bound.
- `guideme.__all__` gains `Question`, `NoulQuestion`, `ChoiceQuestion`, `ScoreQuestion`,
`DetailedNoul`, `DetailedChoice`, `DetailedScore`, `Receipt` and `Usage`, reaching 44 names.
Annotating a stored question no longer means importing from a module the README calls
private. `guideme.api` gains an `__all__` of its own, so `import *` from it stops handing
back `BaseModel`, `Field` and `Mapping`; `guideme.question`, `guideme.policy`,
`guideme.enums` and `guideme.errors` each gained one too.

### Fixed

- `with_policy(…)` no longer leaks a hold on the connection pool when the patch it is given
cannot settle. Python evaluates arguments left to right, so the hold was taken before the
patch was validated and nothing released it: the guide that would have was never built.
The patch settles first now. A pool that never closes is invisible until a process runs
out of sockets, so the regression asserts the count rather than the symptom.
- The pool's count and every holder's spent-flag are taken under one lock. `Guide`'s
docstring says to share a guide across threads, so two threads closing two guides over one
pool is a documented thing to do, and a flag read, a flag flip and a decrement are three
steps that must not interleave. `ask` is untouched and takes no lock.
- Closing one guide twice no longer closes the connection pool under a guide derived from it
with `with_policy(…)`. `share()` now hands back a distinct client over the shared pool, each
carrying its own release-once flag, so a guide releases exactly once however many times it
is closed; a count alone cannot tell which holder a release came from. Sharing from an
already-closed guide is a `ConfigError` rather than a guide holding nothing.

### Changed

- `guideme.ask` walks a shape through four `TypeGuard` predicates instead of four `cast()`
calls. There are now no casts anywhere in `src/guideme`: a `TypeGuard` replaces the narrowed
type outright where an annotated assignment only intersects with it, so the element types
are `object` rather than unknown and a checker verifies what was being asserted before.

- `guideme.retry` carries `error.type = "transport"` and **no** `http.response.status_code`
when the attempt it is resending never got a response. Exactly one of the two is on every
such event. A dashboard grouping retries by cause has to tell a throttled API from an
unreachable one, so the cause is which field is present. `docs/observability.md` has the
table and `docs/contract.md` the retry policy in full.

### Documented

- The timeout's scope, which is per phase in `httpx` and per attempt in Rust's `reqwest`, is
now on `GuideBuilder.timeout`, in the README's configuration table and in `docs/contract.md`
as a stated divergence rather than something a reader has to find.
- Concurrency: build one guide, share it across threads or tasks, close it once. It was true
before and written down nowhere.
- `docs/contract.md` drops the rubric asymmetry between the SDKs' runtime constructors, which
guideme-rust closed at 0.2.0, and corrects a stale `Rubric::into_wire()` to `Rubric::render`.

## 0.1.1 — 2026-09-22

Additive. Nothing that worked in 0.1.0 sends different bytes.
Expand Down
Loading
Loading