Repository navigation
Rubric examples and counterexamples - #6
Merged
Merged
Conversation
Callers want to attach inputs that belong to an option or a level, and inputs that do not, so the model has something concrete to match against. Measured against live Jev, the gain comes from the examples being present, not from sending the API's structured criteria objects: flattening scores the same, costs ~11% fewer input tokens, and leaves the wire types alone. `example` and `counterexample` are repeatable `#[guide(..)]` keys; the macro renders the final string at expansion time, so `Options::RUBRIC` and `Levels::LEVELS` keep their types and nothing below the derive changes. A variant with neither renders to its rubric itself, byte for byte, which is what keeps every existing request unchanged.
The rendered rubric string is now a cross-SDK contract item, so it needs an artifact the other SDKs can test against. `spec/vectors/rubric.json` carries the inputs and the rendering for each case, read back from real `#[derive(Choice)]` and `#[derive(Levels)]` enums: the vector is provably what the macro emits, not a second implementation of the algorithm. Declaring those enums inside the crate needs `extern crate self as guideme`, because the derives expand to `::guideme` paths. The schemas and the policy vectors regenerate byte for byte, which is the evidence that the wire is untouched.
Model output is not stable to two decimal places, so the opt-in live test asserts the invariant the feature exists for rather than a float: on the design note's confusable return_policy/return_status pair, naming the inputs that belong to return_status moves probability onto it (0.63 -> 0.71 against jev-1.13.0).
The composition is shared with every other SDK, so it belongs in docs/contract.md next to the wire schemas and the policy vectors, along with the one deliberate asymmetry: Rust renders in the proc macro, Python renders where a rubric becomes wire text. docs/design.md records why we flattened instead of sending the API's structured criteria, because the next person will ask why a documented API feature was ignored.
Purely additive, so a patch. `guideme-derive` is pinned by version as well as by path, so the pin moves with it: a `guideme` 0.1.1 that resolved `guideme-derive` 0.1.0 would not know the new attribute keys. `examples/otlp` pins the library through a path dependency and CI builds it `--locked`, so its lock file is refreshed here.
A noul's true/false criteria are as confusable as a choice's options, and measured the largest swing of the three kinds: on a ticket describing a broken job with a manual workaround, plain criteria answer 0.75 urgent and criteria carrying examples answer 0.25, which is the right answer. Leaving noul out was a real gap. A noul has no enum to hang attributes off, so the parts arrive as a value. A builder rather than a second criteria method because `criteria` already takes `impl Into<String>`: a Rubric drops in with no signature change, and a plain pair of strings keeps working.
An example asserts that an input belongs to this option, so the same string on two options asserts it belongs to both, and the same string as an example and a counterexample of one option asserts it is in and out. Neither can be true; both are now compile errors. The same string as an example of one option and a counterexample of another stays legal — that is the confusable-options pattern the feature exists for, and tests/live.rs relies on it. The golden rows deliberately reuse a string across cases, which no single declaration may now do, so the spec and test fixtures split across two enums. One gains a case whose clauses are not in sorted order: declaration order is contract, and nothing pinned it before. The passthrough property test moves to guideme, over `Rubric`, and the golden test pins the derive's output equal to it. That is the whole point of the duplicated renderer: one law, property-tested once, and the macro tied to it.
The choice case was already covered. The noul case is where examples swing hardest, so it is the one worth spending a live request on: against jev-1.13.0 the ticket reads 0.74 urgent with plain criteria and 0.22 with criteria that name a broken job with a manual workaround. The assert is the direction, never the number.
Three of the five changes are contract: all three question kinds take examples, rendering follows declaration order, and `Not this option` is the measured label rather than a taste call. The duplicated renderer and the deliberately unwidened choose_among/score_levels signatures are the two things the next person will want to "fix", so both say why in docs/design.md.
An empty rubric with no examples has nothing to do with this feature, and 0.1.0
compiled it. Rejecting it unconditionally made 0.1.1 break builds that never
asked for anything new, and the Python SDK, reading the same rule, drew the line
the other way — a declaration legal in one SDK and not the other is a contract
defect whichever rule is better.
The error now fires only on a variant carrying an example or a counterexample:
you described nothing. That matches Python's `option("", examples=[…])` and
restores the property this design rests on, that 0.1.1 changes nothing for
anyone not using the feature. tests/derive.rs pins a blank rubric still
compiling.
The wire test asserts the derive's bytes against the same hand-written constants for all eight rows, and proves they survive to the request, so the golden-row test restated it. What it alone still carried was the runtime-against-derive pin for shapes the noul row does not reach, so that loop moves across rather than going away. The spec drift check cannot take this job: its vector is generated from the derive, so it re-states whatever the renderer currently does. Only a table written by hand can say the renderer is wrong.
The old wording left the bare rubric already picking return_status, so the test could only assert a nudge. With rubrics measured to leave the question genuinely ambiguous, the bare form picks return_policy — wrong — and the guided form picks return_status: p(return_status) 0.22 -> 0.88 against jev-1.13.0. The assert is the pick plus the direction, still never a number.
Every new compile error needs an example or a counterexample to fire, and nothing in 0.1.0 could have written one. That is the guarantee worth saying out loud in a patch release, and it is only true now that the empty-rubric rule is narrowed. docs/contract.md says where the line falls, so the next SDK draws it in the same place.
There are two copies of the algorithm; pinning one against the other on eight fixed rows leaves the macro free to diverge on anything outside them — a string carrying a newline or a "; " of its own, say. The pin catches drift on the rows it lists; only the property catches it everywhere else, so both copies get it.
Rubric::new("").example(..) rendered a stray "Examples:" line onto nothing,
while Python refuses the same thing when it is constructed. A declaration
accepted by one SDK and refused by the other is a contract defect however it
renders.
A builder has no Result to return, so criteria widens from impl Into<String> to
impl Into<Rubric> to get an error channel, and Noul::wire returns Error::Config
for the blank-with-examples case only — the same line the derive draws, so a
blank description on its own still goes through as it did in 0.1.0.
Widening is source-compatible, and that is checked rather than asserted: Rubric
converts from every type String converts from, and tests/rubric.rs compiles one
call of each shape. &String, Cow<str>, Box<str> and char all broke with only the
two conversions the amendment named.
Thirty what was ambiguous, and the two readings differ by the three #[ignore]d live tests that never run in the gate. The ceiling exists to keep the gate fast, so it bounds what the gate runs; saying so, with today's figure, stops the question recurring. The number is unchanged.
criteria took impl Into<String> in 0.1.0. Narrowing it to impl Into<Rubric> broke &String, Cow<str>, Box<str>, char and — unfixably by any set of From impls — a caller's own helper generic over Into<String>. A sealed IntoRubric with a blanket case for Into<String> and one more for Rubric covers all of them, so this is a widening rather than a swap. The price is that Rubric must not be Into<String>, or the two cases overlap, so it renders through Display. Rubric also enforced none of the validation docs/contract.md calls shared: an empty example, a duplicate within a clause, and a string that was both an example and a counterexample all rendered silently, and every one of those is visible inside a single rubric. They move into into_wire alongside the blank description check, in the derive's wording. Only the checks that compare two options need a whole declaration, and those stay in the derive. The renderer pin moves out of the wire test: it is the only thing stopping two copies of the algorithm drifting, and it was running after a mock server had to start. The compatibility claim becomes a const _ block of one call per shape, so deleting a conversion is a build failure rather than a silent narrowing.
AGENTS.md says every misuse gets a trybuild case; empty and duplicate counterexample had none. The property regex excluded control characters, so a rubric containing a newline — exactly what "used verbatim, never trimmed" is there to promise — was never generated.
Every case came from a derive, so the vector said nothing about the kind with the largest measured win and nothing about the runtime renderer. Two noul cases come straight from a Rubric, which covers both. The drift guard checked starts_with plus contains, which would pass a renderer that reordered the clauses or changed the separator while the error claimed it had verified the rendering. It now re-renders each case through Rubric and refuses to publish a vector the two renderers disagree on.
"Empty or whitespace-only" and "duplicate" both read as obvious and are not: Rust trims on Unicode White_Space, Python's str.strip() also takes the C0 separators, and duplicate detection is exact while the emptiness check trims, so "a" and " a" legitimately coexist. A third SDK would have guessed. The vector's kind field was undocumented too. Also corrects the reasoning for criteria: under IntoRubric the accepted set grows rather than shrinks, so "widened" is true again, and the round's general lesson goes in design.md — compatibility claims get compiled, not reasoned about.
Display rendered without checking anything, and it was the documented route into the &str constructors — so the path the docs recommend for untrusted rubric text skipped every rule the type claims to enforce. render() is fallible and is now the only way to get the string, so there is no unchecked path to the wire. Rubric is unreleased, so nothing depends on the old conversion. Noul::wire held both rubrics in one tuple and rendered them independently, so a yes and a no could share an example — an assertion that one input belongs to both sides, which Python already rejected. It is the one runtime path with two rubrics in view, so it is the one place the derive's cross-option rule can apply here. choose_among still has no such moment. Also records that Rubric can never gain From<Rubric> for String: the blanket IntoRubric impl covers every T: Into<String>, so adding it would make the two impls overlap. That is not a 0.2.0 tradeoff, it is closed.
with_capacity was still counting three grids after the noul cases landed, so the vector reallocated on every run.
…omise The kind field was made binding on both SDKs and never actually landed here; I reported it closed after a patch script exited before writing the file. Items are also inserted verbatim, with no escaping and no promise that the rendering can be read back, which a third implementer would otherwise have to guess at. Narrows the claim that cross-option rules are compile-time only in Rust: true for choose_among, false for a noul, which holds both rubrics at once. Moves the notes back under Unreleased, since nothing is published and the date had already gone stale.
Items are joined onto one line, so a line break in one renders as a clause boundary the declaration never wrote. What that buys is rendering integrity, not input trust: a string rendered from declared parts carries exactly the clauses those parts declared. A description may still contain anything — it renders verbatim and always did, and constraining it would refuse legitimate prose while protecting nobody. U+000D is in for hygiene rather than forgery: a lone carriage return is pasted text that breaks the joined line. U+2028, U+2029 and U+0085 are deliberately out, being whitespace already and harmless embedded. The check is `contains` over those two code points and never a line-splitting primitive, because `str::lines` and `str.splitlines` cover different sets and would leave the two SDKs disagreeing while both looked correct. The derive checks after the blank-rubric rule rather than in `push_item`, so a variant breaking both reports the blank one as `Rubric::render` does. `push_item` runs while attributes are still being read and cannot see a rubric set by a later one, so precedence is not expressible there.
The shared-example assertion this file was meant to gain last round never landed: rustfmt had rewrapped the anchor before the patch ran and the patch did not check that it matched, so it silently did nothing while the source change it covered went in. render_pair was live and untested. Adds it back alongside the line-break case and a precedence case proving blank wins, and folds the four rejections through one helper so each reads as a line.
One sentence replaces the per-case notes: it explains why "a; b" as one item renders like two and still passes the duplicate check, why " a" is not "a", and why "A" is not "a" — and it stops a third SDK inventing normalisation. States the refused-versus-documented line with its reason, so it does not read as arbitrary: "; " and a literal "Not this option: x" change what a reader sees inside a clause, a line break changes which clause they are in, and only the second forges a clause that was never declared.
…t have Both SDKs reject a counterexample on a level — Rust as a compile error on the Levels derive — but this document only said a level never renders one. A third SDK implementing from it could build silent dropping instead of rejection, and the difference would not show until someone compared outputs. The refusal joins the rejected-loudly list; the rendering sentence stays, since it explains why. That sentence also claimed more than is true: score_levels takes a pre-rendered string, so a rubric carrying a counterexample can reach a level that way and render the clause. The guarantee is over declared parts, and now says so. Python refuses an explicitly empty examples=[], which the attribute surface cannot express at all — a variant either carries the attribute or does not. A reader diffing the two lists found a rule in one and silence in the other, with nothing to separate "not applicable" from "forgotten".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the locked cross-SDK design for rubric examples in the Rust SDK, and releases it as
0.1.1. Python lands second, re-vendors
spec/and updatesspec/SOURCEto this repository'smainafter the squash-merge.What this adds
Examples reach all three question kinds. Two repeatable
#[guide(..)]keys,example = "…"andcounterexample = "…", composing withthe existing
rubric,keyandfallback:Billingreaches the wire as one string:The proc macro renders at expansion time and emits a literal, so
Options::RUBRICandLevels::LEVELSkeep their types andquestion.rs,api/and the wire are untouched. TheAPI's structured
criteriaobjects are deliberately not used: a score answer echoes criteriaback in
legend, which isBTreeMap<u8, String>here, so objects would mean a breaking changeto a public type in every SDK for a field none of them reads. Flattening measured as good and
~11% cheaper in billed input tokens.
docs/design.mdrecords the reasoning.A noul has no enum to hang attributes off, so it takes
Rubric:Question<Noul>::criteriaalready tookimpl Into<String>, so this is a new public type andno signature change: a plain pair of strings keeps working. This is where examples earn the
most — see the live numbers below.
Rubricmeans the renderer now lives twice, at runtime inguidemeand at expansion time inthe proc macro, because
Options::RUBRICis aconstand cannot call a function. ~10 linesduplicated, on purpose: sharing them costs a third published crate forever, and making
RUBRICa function breaks a public trait.
every_golden_row_renders_byte_for_byte_from_both_renderersasserts a real derived enum's
RUBRICequalsRubric's output on every row, so the two cannotdrift silently.
The load-bearing invariant
A rubric with no examples and no counterexamples renders to itself, byte for byte. Every 0.1.0
declaration puts the same bytes on the wire. It is asserted three ways: a
proptestover therenderer in
guideme-derive, the unchangedtests/derive.rsexpectations, and a check on thepublished
spec/vectors/rubric.json.spec/schema/andspec/vectors/policy.jsonregeneratebyte-identically, which is the evidence the wire contract did not move.
Contract
spec/vectors/rubric.jsonis new: the inputs and the rendering for each case, generated fromreal derived enums in
spec.rsrather than a second copy of the algorithm.docs/contract.mdgains a part 3 stating the algorithm, the invariant and the one deliberate asymmetry — Rust
renders in the proc macro, Python renders where a rubric becomes wire text — with equivalent
inputs producing identical wire bytes either way.
Validation
Each is a compile error naming the rule, with a
trybuildcase: an empty or whitespace-onlyexample or counterexample; a duplicate inside one variant's examples; an example on a
variant with no rubric to attach it to;
counterexampleon aLevelsderive; the same stringas an example of two different variants; and the same string as both an example and a
counterexample of one variant. An example asserts that an input belongs here, so the last two
assert something that cannot be true.
This release breaks no existing build. Every new error needs an
exampleorcounterexampleto fire, and nothing in 0.1.0 could have written one. An empty orwhitespace-only rubric is rejected only where examples were attached to it, so
#[guide(rubric = "")]and a bare///still compile exactly as they did — the same linePython draws.
The same string as an example of one option and a counterexample of another stays legal and
is required — it is the confusable-options pattern the feature exists for, and
tests/live.rsuses it, which is what proves the carve-out holds.
Declaration order and the
Not this optionlabel are contract, not presentation. The vectorcarries a case whose clauses are deliberately not in sorted order, so a renderer that sorted
would fail it.
A note on the test ceiling
AGENTS.mdsaid "the ceiling is 30 nextest entries", which turned out to bound nothingprecisely:
cargo nextest listcounts the#[ignore]d live tests andcargo nextest rundoesnot, so the same tree reported 28 or 31 depending on which you meant. This PR needed the room,
and deciding what the rule means inside the change that benefits from it is exactly the wrong
order.
So it was not decided here. The line is redefined to bound the entries the gate runs —
what
cargo nextest run --workspacereports, 29 today — with the#[ignore]d live testsexplicitly outside it, because they never execute in the gate and so are not what the ceiling
protects. The number 30 is unchanged, and the redefinition was ratified separately by the
repository owner rather than as part of this change. The reviewer's concern here was precedent,
not the merits: a project that quietly reinterprets its own limits when they bind has no
limits. The merits were never in question — nothing about a test that does not run protects the
gate's runtime.
Verification
mise run checkgreen through the pre-push hook: fmt-check, clippy-D warnings, 27 nextestentries (30 counting the
#[ignore]d live tests, at the ceilingAGENTS.mdsets), doctests,rustdoc, cargo-deny.
jev-1.13.0,#[ignore]d like the other live tests, asserting direction andnever a number. On the design note's ambiguous input
About those shoes - what is the situation with the money side of things?, probability ona bare rubric picks
return_policy— the wrong answer — at 0.78, and the same rubricscarrying examples pick
return_statusat 0.88. The assert is the pick plus the direction.On the noul case,
Our nightly export job has been failing since Tuesday. We pull the numbers by hand for now.asked as "Is this ticket urgent?" reads 0.74 with plain criteria and 0.22 with criteria that
name a broken job with a manual workaround — and 0.22 is the right answer.
examples/otlp/Cargo.lockrefreshed for the version bump;cargo build --lockedin thatdirectory passes as CI runs it.