Pricing engine: spec
Table of Contents
1. Purpose
The pricing engine composes four pure adjustments on a base price:
surge on demand, yield across availability classes, competitor
reprice, and a clamp to [floor, ceil]. Namespace:
wal-sh.adtech.pricing-engine.core (cross-host .cljc).
ORACLE: src/wal_sh/adtech/pricing_engine/core.cljc (pure),
browser.cljs, server.clj.
2. Requirements
Input contract. Bad inputs throw ex-info.
| Field | Type | Bound |
|---|---|---|
base |
non-neg finite double | ≤ max-base (1e6) |
demand |
non-neg finite number | ≤ max-demand (1e6) |
classes |
non-empty vec of {:class str :multiplier finite≤100 :available int≥0} |
n/a |
competitors |
vec of {:name :price}; rows with NaN/Inf :price dropped |
n/a |
floor |
non-neg finite double | ≤ ceil |
ceil |
non-neg finite double | ≤ max-price (1e9) |
epsilon |
non-neg finite double | default 0.01 |
Composition order (fixed):
final-price= clamp ∘ competitor-reprice ∘ yield ∘ surge.- Changing this order changes monotonicity guarantees and should be considered a breaking change.
3. Contract signature
Pure (core.cljc):
| Fn | Signature | Returns |
|---|---|---|
surge |
(demand base) / (demand base opts) |
double; surge-adjusted price |
yield |
(base classes) |
{:price :class :remaining :promoted?} |
competitor-reprice |
(current competitors) / (... opts) |
{:price :basis :competitor} |
final-price |
(inputs-map) |
{:final :base :surge :yield :competitor :clamped?} |
format-usd |
(x) |
"$X.XX" |
default-surge-multiplier |
(demand) |
double ∈ [1.0, 4.0] |
4. Output guarantees (invariants)
(final-price ...) :final∈[floor, ceil](CLOSED interval).(surge d b)≥bfor all validd, b.(competitor-reprice ...) :price≥ floor; ties on competitor price broken by[price-asc, name-asc](deterministic cross-host).- All outputs are finite IEEE-754 float64; bit-identical on JVM and CLJS for inputs within documented bounds.
surge,yield,competitor-reprice,final-priceare pure functions of their arguments. No reads from*-vars, atoms, or the wall clock.
5. Negative-state catalog (property tests)
All entries have rejection or fallback property tests in
test/wal_sh/adtech/pricing_engine/core_prop_test.cljc unless noted.
| # | Input shape | Specified behaviour | Test |
|---|---|---|---|
| 1 | demand = 0 |
surge returns base exactly (no multiplication) |
surge-zero-demand-returns-base (example) |
| 2 | demand < 0 |
surge throws ex-info "demand must be non-negative finite" |
surge-rejects-bad-demand (200 cases) |
| 3 | base < 0 |
surge throws ex-info "base must be non-negative finite" |
surge-rejects-bad-base (50 cases) |
| 4 | demand = ##NaN or ##Inf |
surge throws; not finite |
surge-rejects-bad-demand |
| 5 | floor > ceil |
final-price throws "impossible clamp" |
final-price-rejects-floor-gt-ceil (50 cases) |
| 6 | competitors = [] |
competitor-reprice returns {:basis :no-competitors :price current :competitor nil} (no reprice) |
competitor-empty-list-noop |
| 7 | competitor with ##NaN=/=##Inf price |
that row dropped; others survive; if NONE survive → :no-competitors |
competitor-nan-price-dropped |
| 8 | yield class with :available 0 |
skipped; next class opens | yield-promotes-when-class-exhausted |
| 9 | ALL yield classes :available 0 |
top-multiplier class returned with :promoted? true :remaining 0 (do NOT reject; the spec said "decide", we chose "promote") |
yield-all-exhausted-promotes-top |
| 10 | classes = [] |
yield throws "classes must be non-empty" |
yield-rejects-empty-classes |
| 11 | demand = ##Inf |
final-price throws (finite check); surge bound never reached, validation fires first |
final-price-infinity-demand-rejected |
| 12 | :final exactly at floor or ceil |
:final ∈ [floor, ceil] (closed; both endpoints included); clamp activates, :clamped? true |
final-price-in-clamp-band (100 cases) |
| 13 | competitor undercut would breach floor | clamped at floor; basis = :floor-binding |
competitor-floor-binding |
| 14 | competitor ties on price | tie-break by [price-asc, name-asc]; deterministic cross-host |
competitor-tie-break-by-name-asc + cross-host-determinism-vector |
| 15 | 1000 identical final-price calls |
bit-identical output (no hidden accumulator/state) | no-drift-over-1000-iterations |
6. Cross-references
- search-ads/spec.org: the auction module whose clearing price is the other place a reserve/floor binds.
- sponsored-formats/spec.org: the unit catalog whose slots the priced inventory fills.
- ORACLE:
src/wal_sh/adtech/pricing_engine/core.cljc(pure)src/wal_sh/adtech/pricing_engine/browser.cljssrc/wal_sh/adtech/pricing_engine/server.clj(JVM)test/wal_sh/adtech/pricing_engine/core_prop_test.cljc(properties)
7. Open questions
- Case 9 (all yield classes exhausted) was specced as "max-multiplier
OR rejected; decide". We chose promote to keep the composition
pipeline total; rejection would force every caller to wrap
final-pricein a try/catch for a state that is normal in oversold scenarios. Documented in thecore.cljcdocstring.