Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7b06dcc
Carry examples and counterexamples on a rubric
pedro-pscunha Sep 22, 2026
be48eac
Document rubric examples and why they are flattened
pedro-pscunha Sep 22, 2026
b12df1d
Bump to 0.1.1
pedro-pscunha Sep 22, 2026
b4fd732
Give noul criteria examples, and refuse contradictory ones
pedro-pscunha Sep 22, 2026
4566bd8
Default the example clauses to None, not an empty tuple
pedro-pscunha Sep 22, 2026
0211d5c
Prove the noul half of the invariant in the live test
pedro-pscunha Sep 22, 2026
4dd456b
Refuse a clause given as one string, and fallback off the runtime paths
pedro-pscunha Sep 22, 2026
6bd9655
State the clause labels in the contract, and settle the capture's ver…
pedro-pscunha Sep 22, 2026
9997dcf
Refuse a blank rubric only when examples are attached to it
pedro-pscunha Sep 22, 2026
6f52cc9
Pin the vector's shape, blankness and duplicate equality in the contract
pedro-pscunha Sep 22, 2026
4840a8f
Name the C0 block exactly, and why the two checks disagree about " a"
pedro-pscunha Sep 22, 2026
c7f6822
Make a rubric copyable and picklable again
pedro-pscunha Sep 22, 2026
30d0828
State every legality rule in the contract, and undate the changelog
pedro-pscunha Sep 22, 2026
3cf6a2f
Refuse a line break inside an example or counterexample
pedro-pscunha Sep 22, 2026
497ba1f
State that validation is over the declared items, not the rendered text
pedro-pscunha Sep 22, 2026
d519464
Vendor spec/ from guideme-rust and drive the golden test from the vector
pedro-pscunha Sep 22, 2026
24aefe7
Make two contract universals claims about the renderer, not the string
pedro-pscunha Sep 22, 2026
7045ec9
Date the 0.1.1 changelog heading
pedro-pscunha Sep 22, 2026
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
51 changes: 43 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 33 names in `__all__` in `src/guideme/__init__.py`, imported from `guideme` itself;
- the 35 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`.
Expand Down Expand Up @@ -40,7 +40,7 @@ two pages: `https://docs.typesafe.ai/api.md` covers `POST /v1/systemone` and
| `src/guideme/scalars.py` | `Probability`, `Confidence`, `Key`, `Rank`, `ApiKey`, `Model` | validation happens once, here; `ApiKey` never prints |
| `src/guideme/errors.py` | the `GuidemeError` tree and `kind` | `kind` is the cross-SDK name and the `error.type` value; imports nothing from `guideme` |
| `src/guideme/policy.py` | `resolve`, `Policy`, `Thresholds`, `Verdict`, the answer and outcome dataclasses | pure: no I/O, no caller enums, keys and level indices only |
| `src/guideme/enums.py` | `Choice`, `Levels`, `fallback` | 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 |
| `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` |
Expand Down Expand Up @@ -106,6 +106,32 @@ the rest are checked in review.
is a `ConfigError` on the class statement; so is `fallback(…)` on a `Levels`, which has
`.otherwise(level)` instead. Both fire where the enum is written, like the Rust derive's
compile errors.
- **A rubric's examples are composed into its text at the wire, and nowhere else.** A rubric's
value stays the bare text; `enums.render` is what turns an `option(…)`, `level(…)` or
`fallback(…)` into what the model reads, and every site that puts a rubric on the wire —
`Choice.rubric()`, `Levels.levels()`, `choose_among`, `score_levels` and
`NoulQuestion.criteria` — goes through it, so a rubric cannot arrive having quietly lost what
it carries. All three kinds of question take examples: a yes and a no are as confusable as
two options, and `option(…)` is the one constructor for a described alternative, so there is
no fourth. A bare string and a rubric with no examples render to their own bytes, so an
existing caller's request does not move. The rendered string is a contract item shared with
every other SDK, stated in `docs/contract.md` and pinned by `spec/vectors/rubric.json`, and
so is the order: examples and counterexamples render in the order written, never sorted and
never de-duplicated into a set.
- **A rubric's examples must be consistent, and that is checked where it is written.** A blank
or whitespace-only entry, a repeat within one clause, a clause given as one string rather
than a sequence of them, an empty clause written out (`examples` and `counterexamples`
default to `None`, so any empty sequence was typed), examples attached to a blank rubric,
a `fallback(…)` on a runtime path, a `U+000A` or `U+000D` inside an entry, and a
counterexample on a level are each a `ConfigError`. The newline test is the literal
codepoint, never `str.splitlines()`, which splits on eight and would refuse declarations the
Rust SDK accepts; `docs/contract.md` records why.
A blank rubric that carries no examples is **not** an error: it means what it meant in 0.1.0,
and this release does not redefine it. So are the two contradictions: one
string as an example of two alternatives of the same question, and one string as both an
example and a counterexample of the same alternative. One string as an example of one
alternative and a counterexample of another is **legal and required** — it is the confusable
pattern the feature exists for, and `tests/test_rubrics.py` sends it on the wire.
- **Nothing outside `api/` may see a `pydantic` exception.** A caller's state or instructions
that pydantic refuses leaves `api/` as a `ConfigError`, and a `NaN` or an infinity is refused
rather than serialised as `null`.
Expand Down Expand Up @@ -159,13 +185,19 @@ is made in `guideme-rust` first, not here.

## Tests

Few tests, high grade. The ceiling is 43 test functions; a parametrised function counts once.
Few tests, high grade. The ceiling is 47 test functions; a parametrised function counts once.
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:
it asserts the absence of a failure on a path where every other test asserts a presence, so no
existing test could carry it. Everything else that pass added went into a parameter of a test
that was already there. A new test must be one of:
existing test could carry it. Rubric examples added the last four, also user-requested scope:
the golden rendering table, the passthrough property that proves 0.1.0's bytes have not moved,
the wire proof that a rendered rubric reaches the request, and a live proof that examples move
the distribution — one function covering a choice and a noul, because both are the same
invariant and a second function would buy nothing. Each asserts a different thing about a
string no existing test looks at.
Everything else those two passes added went into a parameter of a test that was already there.
A new test must be one of:

- a property test (`hypothesis`) over a law of `policy.resolve`, the shapes, or the wire types;
- a wire or contract check through a real local HTTP server (`pytest-httpserver`), asserting on
Expand Down Expand Up @@ -280,9 +312,12 @@ green before the tag, not after.
1. Bump `version` in `pyproject.toml`. Then `uv lock` at the root and `uv lock` inside
`examples/otlp`: both lock files record the version, and both are resolved with `--locked`.
2. Move the `Unreleased` notes in `CHANGELOG.md` under the new version with today's date.
3. Refresh the **What arrives** capture in `docs/observability.md`, or elide the version in it.
Its `InstrumentationScope guideme X.Y.Z` lines carry the version the capture was taken at,
so they go stale on the first bump and a reader cannot tell a stale capture from a real one.
3. Leave the **What arrives** capture in `docs/observability.md` alone unless you re-take it.
Its `InstrumentationScope guideme X.Y.Z` lines carry the version the run actually emitted,
and the prose above it names that version, so a reader can tell the capture's age from a
claim about today. Do not edit the version forward to match a release no run produced, and
do not delete it either: that loses the provenance permanently. Re-take the capture and
update both together, or change nothing.
4. `mise run check`, then open a pull request and squash-merge it with CI green.
5. On `main`, at that commit: `git tag -a vX.Y.Z -m vX.Y.Z` and `git push origin vX.Y.Z`. The
tag ruleset refuses a tag that is later moved or deleted, so tag the commit you mean.
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@

Nothing yet.

## 0.1.1 — 2026-09-22

Additive. Nothing that worked in 0.1.0 sends different bytes.

- `option(rubric, examples=…, counterexamples=…)` and `level(rubric, examples=…)` join
`fallback(…)`, which now takes the same keywords. All three are exported from `guideme`,
bringing `__all__` to 35 names. A rubric written as a bare string keeps working everywhere.
- The parts are composed into the rubric where it becomes wire text: clauses joined with a
newline, items within one joined with `"; "`, in the order written, the text verbatim. A
rubric with no examples renders to its own text, byte for byte, so an existing request is
unchanged. The rendered string and its order are cross-SDK contract items, stated in
`docs/contract.md` and pinned by `spec/vectors/rubric.json`.
- All three kinds of question take them. `noul("…").criteria(yes, no)` accepts an `option(…)`
for either side: a yes and a no are as confusable as two options, and there is no fourth
constructor for them.
- Every site that puts a rubric on the wire renders — `Choice.rubric()`, `Levels.levels()`,
`choose_among`, `score_levels` and `NoulQuestion.criteria` — so examples cannot be silently
dropped by reaching a runtime constructor.
- `level(…)` takes no counterexamples: "not this option" means nothing on an ordered scale. An
`option(…)` carrying counterexamples written where a level belongs is a `ConfigError`, as is
a blank or whitespace-only entry, a repeat within one clause, examples attached to a blank
rubric, and an empty clause written out: `examples` and `counterexamples` default to `None`,
so any empty sequence that arrives was typed on purpose and says nothing. A blank rubric
carrying no examples is untouched — it means what it meant in 0.1.0.
- Contradictory examples are a `ConfigError` too: one string as an example of two alternatives
of the same question, or as both an example and a counterexample of the same alternative. One
string as an example of one alternative and a counterexample of another stays legal — that is
the confusable-options pattern the feature exists for.
- A clause given as one string is refused rather than shredded. A `str` is a `Sequence[str]` of
its own characters, so `examples="refund"` would have become six one-letter examples and no
type checker would have said so; it is a `ConfigError` naming the mistake, the same way
`score_levels` already refuses a scale given as one string.
- `fallback(…)` is refused by `choose_among`, `score_levels` and `noul(…).criteria(…)`. Those
answer in a `Key`, a `Rank` and a `bool`, none of which has a member to fall back to, so the
marking had nothing to act on and was being dropped in silence. Use `.otherwise(…)` on the
question, which is what the `Levels` rule has always said.
- An example or counterexample containing `U+000A` or `U+000D` is refused. Items are joined
onto one line, so a newline inside one would read as a clause the rubric never declared. The
rubric text itself is unrestricted; only the entries are. `"; "` inside an entry stays legal,
because it changes how many examples a reader sees rather than which clause they are in.
- A rubric built by `option(…)`, `level(…)` or `fallback(…)` can be copied and pickled again.
Carrying the parts meant `__new__` took four arguments where `str` hands back one, so
`copy.copy`, `copy.deepcopy` and `pickle` raised a `TypeError` — including on the
`copy.deepcopy({"key": option(…)})` a caller writes before `choose_among`. A bare string did
this in 0.1.0 and does it again.

## 0.1.0 — 2026-09-21

First release. Everything below is new, so this entry lists the surface rather than the
Expand Down
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,87 @@ A noul can carry `.criteria("what yes means", "what no means")`. Instructions ac
or any JSON-shaped value, so a question can reference structured data by field name the way
the TypeSafe docs describe.

### Examples in a rubric

Two alternatives that read alike are told apart by showing inputs rather than by describing
harder. `option(…)` takes the inputs that belong to an alternative and the ones that belong
somewhere else, `level(…)` takes the inputs that score at that level, and `fallback(…)` is an
`option(…)` that also marks the unsure member. All three kinds of question take them: a noul's
`.criteria(…)` accepts an `option(…)` for the yes and for the no.

```python
from guideme import Choice, Levels, fallback, level, option


class Department(Choice):
billing = option(
"Payments, invoicing, refunds",
examples=["My card was charged twice", "Where is my refund?"],
counterexamples=["The dashboard is down"],
)
technical = option("Bugs, outages, integrations", examples=["502 on every request"])
sales = fallback("Pricing, upgrades, new accounts", examples=["Do you have a team plan?"])


class Severity(Levels):
cosmetic = level("No impact to functionality", examples=["typo in a label"])
degraded = level("Broken feature, workaround exists", examples=["export fails in one browser"])
blocking = level("No workaround exists", examples=["cannot log in", "data loss"])
```

The member's value is still the bare rubric; the examples are composed into it only in the
request, as

```text
Payments, invoicing, refunds
Examples: My card was charged twice; Where is my refund?
Not this option: The dashboard is down
```

So a rubric with no examples sends exactly what it sent before, and the same strings work in
`choose_among("…", {"billing": option(…)})` and `score_levels("…", [level(…), …])`. Examples
and counterexamples render in the order they are written, always: that order is part of the
published contract.

A yes and a no are two alternatives of one question, so they take examples too, and this is
where they pay best — a vague pair is the easiest thing to get wrong:

```python
urgent = noul("Is this ticket urgent?").criteria(
option("Urgent", examples=["customers cannot log in", "money is moving to the wrong place"]),
option("Not urgent", examples=["a broken job with a manual workaround", "a cosmetic bug"]),
)
```

Asked about a nightly export job that has been failing since Tuesday while the numbers are
pulled by hand, a plain `Urgent` / `Not urgent` answers yes at 0.75. The criteria above answer
no at 0.17, because one of the not-urgent examples is what the ticket describes.

A string may be an example of one option and a counterexample of another. That is the point
when two options are confusable, and it is the one overlap that stays legal. Offering the same
string as an example of two options, or as both an example and a counterexample of the same
option, says an input belongs where it cannot, so each is refused.

`level(…)` has no counterexamples, because "not this option" means nothing on an ordered
scale — an input that does not belong at one level scores at another.

Leave a clause out to say there is none. An empty one written out — `examples=[]` or
`examples=()` — says nothing, so it is refused as the mistake it is, along with a clause given
as one string rather than a list of them (`examples="refund"` would otherwise be six one-letter
examples), a blank entry, a repeat within one clause, a newline or carriage return inside an
entry, a counterexample on a level, a `fallback(…)` given to `choose_among`, `score_levels` or
`.criteria(…)`, and the two contradictions above. Each is a `ConfigError` where the rubric is
written.

Entries go on one line each, so a newline inside one would read as a clause you never wrote.
`"; "` inside an entry is fine — `"card declined; retry failed"` is ordinary prose, and it
changes how many examples a reader sees rather than which clause they are in. The rubric text
itself may still contain newlines; only the entries are restricted.

Attaching examples to a blank rubric is refused too, because they describe something that is not
there. A blank rubric on its own is not: it means what it has always meant, and adding examples
to the language does not make an old declaration an error.

The state is anything JSON-shaped: a text literal, a `dict`, a list of them. A dataclass goes
through `dataclasses.asdict`, a pydantic model through `.model_dump()`.

Expand Down
Loading
Loading