feat(storyboard): add field_greater_than / field_at_most / field_at_least matchers (#1839)#1841
Conversation
Cap- and floor-style scenarios (frequency ≤ 3, delivered_reach ≥ promised) have no clean primitive today. The strongest available is `field_less_than` (strict <), which forces a literal-plus-epsilon workaround (`field_less_than: 3.01`) that's semantically wrong and brittle when sellers report the cap value exactly. `field_at_most` (≤) and `field_at_least` (≥) are non-strict comparators that share `field_less_than`'s comparand-resolution semantics: either a literal `value` or a runtime-captured `context_key`, with absent context keys passing the check with a `context_key_absent` observation. Refactors the previous one-off `validateFieldLessThan` into a shared `validateNumericComparison(op)` helper parameterised by check name, operator symbol, and comparator function — all three checks dispatch through it. Forward-compat default in the runner already grades unknown check kinds as `not_applicable`, so older runners won't brick. Tests: 12 new covering both checks (literal + context_key paths, the critical equality-at-boundary cases that distinguish them from `field_less_than`, type-error paths, and missing-path). Concrete consumer: `media_buy_seller/frequency_cap_enforcement` (adcontextprotocol/adcp#4727) ships with the epsilon workaround; a small follow-up will swap `field_less_than: 3.01` → `field_at_most: 3`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adrant Per protocol review on this PR: shipping only `field_at_most`/`field_at_least` without `field_greater_than` leaves 3 of 4 numeric comparators implemented. Storyboard authors reaching for strict-`>` would hit the runner's forward-compat `not_applicable` default and silently grade as passing — semantically wrong for assertions that should fail at the boundary. `field_greater_than` slots into the same `NUMERIC_OPS` table; the `validateNumericComparison` helper handles it without code duplication. Four-quadrant vocabulary now complete: `<`, `>`, `<=`, `>=`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review pass — doneParallel reviews from code-reviewer + ad-tech-protocol-expert. code-reviewer: ship. Refactor preserves all original behavior (error messages, expected/actual fields, observation pass-through, type-error paths) exactly; boundary semantics correct; ad-tech-protocol-expert: sound-with-one-caveat. Naming matches the prose-style vocabulary ( Addressed in Title updated to reflect all three matchers. |
Summary
Closes #1839.
Adds two new storyboard validation check kinds:
field_at_most— assert numeric field ≤ comparand (non-strict)field_at_least— assert numeric field ≥ comparand (non-strict)They share the same comparand-resolution model as the existing strict
field_less_than: either a literalvalueor a runtime-capturedcontext_key, with absent context keys passing the check with acontext_key_absentobservation (the prior step may have been legitimately skipped on a branch-set path).Why
Storyboard scenarios that assert "value X is at most / at least N" — cap and floor enforcement — have no clean primitive today. The strongest available is
field_less_than(strict<), which forces a literal-plus-epsilon workaround:That's semantically wrong (assumes a domain rounding convention) and brittle when sellers report the cap value exactly (
3.00 < 3.01passes;3.00 < 3.00fails).field_at_most: 3is the right primitive.Implementation note
Refactored
validateFieldLessThaninto a sharedvalidateNumericComparison(op)helper parameterised by check name, operator symbol, and comparator function. All three numeric checks dispatch through it — no copy-paste branches. Forward-compat default in the runner already grades unknown check kinds asnot_applicable, so older runners won't brick.Concrete consumer
media_buy_seller/frequency_cap_enforcement(adcontextprotocol/adcp#4727) ships with the epsilon workaround. Once this lands, a small follow-up will swapfield_less_than: 3.01→field_at_most: 3in that scenario yaml.Test plan
test/storyboard-cross-step-validators.test.js):≤/≥from</>)context_keycomparandcontext_key_absentobservationfield_less_than/field_equals_contexttests still pass (refactor preserves behaviour).npm run format:checkclean.tsc --noEmitclean.Python parity
Per the issue: once TS ships, mirror in
adcp-client-python. Not addressed here.🤖 Generated with Claude Code