Skip to content

chc.zones

Stability tier: experimental.

A city's zones over time: idle supply and open requests per zone, an incentive and a price per zone as levers, recruits drawn from neighbouring zones, and supply that answers an incentive late.

The marketplace plant the loop runs on. chc.marketplace's softmax equilibrium is not control-affine, so it stays a validation target; this plant is control-affine by construction, so identification and safety read the same object. Per zone i, K zones on a ring::

d(supply)/dt = gamma (sigma - supply) + (I - P)(recruit incentive + carry stock) - m - eta xi
d(queue)/dt  = demand (1 + xi - elasticity price) - m - alpha queue
d(stock)/dt  = incentive - theta stock

m is the zone's rate of trips (Matching) and xi its demand shock. The levers are moves from the do-nothing point, both on [0, 1]: the incentive in units of the budget, the price a rise over the list price as a share of it. An incentive recruits recruit drivers a period at once and carry a period per unit of its stock, the incentive exposure decaying at theta, so part of the response arrives late, 1/theta periods on average. A share spill of the recruits come from the neighbouring zones, P[j, i] = spill / 2 for each neighbour j of i, so the city gains 1 - spill of what the zone does. A full price rise turns away elasticity of the zone's demand.

The do-nothing point is the parameterisation: supply and queue are each zone's idle supply and open requests with no lever moved and no shock, and sigma and demand are derived so that the point is the steady state::

sigma = supply + m0 / gamma,   demand = m0 + alpha queue,
m0 = mu supply queue / (supply + queue)

HONEST SCOPE, and it bounds what a number read off this plant means:

  • The logged operator chased the shock. Both levers rose with the zone's shock, which raises demand and takes supply off the road. A fit that ignores it reads a price rise as raising demand and an incentive as recruiting a fraction of what it does. The shock is logged (shock_<i>), so it can be adjusted for, and ZoneMarketSystem.graph derives that adjustment. Shocks are independent from period to period, so the stock's drift is not confounded; a persistent shock would confound it too.
  • The log's actions share its noise. Under the logged operator an action and the plant's noise both move with the period's shock, and an off-policy evaluation that weights actions by u | x alone assumes they do not. ZoneMarketSystem.linear_gaussian is the one-period law under a policy that does not see the shock.
  • A switchback on one zone reads the zone's recruits, not the city's: the zone gains recruit and its neighbours lose spill * recruit between them.
  • Under harmonic matching the drift is nonlinear, and a fit whose drift is a polynomial in the state is a local surrogate of it, as in chc.mmm: audit a plan on this plant, not on the fit's forecast. Under linear matching every closed form here is exact.
  • theta is taken as known, as chc.mmm takes its adstock rates: ZoneMarketSystem.stock_dynamics hands the stock rows to a fit as known=.
  • Nothing here is anyone's data. The parameters are generic, chosen to put the confounding in the levers' effects, which is the mechanism under study.

Matching = Literal['harmonic', 'linear'] module-attribute

How a zone's idle supply and open requests turn into trips:

  • "harmonic": mu supply queue / (supply + queue), never more than the smaller side;
  • "linear": its tangent at the do-nothing point, which passes through zero because the harmonic law is homogeneous of degree one, so the two laws share the do-nothing steady state.

ZoneMarketPlant

Bases: Module

The true continuous plant: x = [supply, queue, stock] and u = [incentive, price], each block one entry per zone.

Control-affine with a constant channel: an incentive recruits (I - P) diag(recruit) at once and a price rise turns away diag(demand * elasticity); everything else is drift. shock is the demand shock each zone is sitting at, a field rather than an input so that the plant stays a plain Dynamics.

trips(x)

Each zone's rate of trips at state x.

ZoneMarketSystem dataclass

A synthetic city log, one row per day and period, whose operator chased each zone's shock.

The per-zone tuples set the number of zones and must agree in length. Zone 0 is the centre, with the most supply and demand and the strongest recruitment.

Raises:

Type Description
ValueError

on per-zone tuples of different lengths, a do-nothing point or a rate that is not positive, a negative channel, shock or noise, an elasticity outside [0, 1), a spill outside [0, 1] or with one zone to spill into, or an unknown matching.

state_columns property

The plant's state order: every zone's supply, then every queue, then every stock.

lever_columns property

The plant's action order: every zone's incentive, then every price.

do_nothing property

The steady state with no lever moved and no shock, in state_columns order.

incentive_channel property

Drivers a period an incentive held at one recruits once settled, per zone: recruit + carry / theta. The channel zone_decision's b stands for.

plant(shock=None)

The true vector field at a demand shock per zone, none by default.

sample(*, n_days=40, n_periods=48, dt=1.0, seed=0)

Simulate the log period by period, each zone's levers moved with its own shock.

Every day starts at the do-nothing point, with supply and queue jittered by noise. A row holds the state at the start of its period and the levers and shock over it. Integrated with chc.integrate.rk4_step, which the planner rolls out with, so the log carries no discretisation gap from the model class fitted to it.

graph()

Each zone's shock moves its levers and both its states; an incentive moves its stock and the supply of every zone it recruits in or from, and so does the stock; a price moves its queue; supply moves its queue.

Supply and queue drain each other through the trips within a period, and only supply's edge is drawn: a cycle has no adjustment set, and the reverse edge opens no back-door path from a lever, whose every confounder is a zone's shock either way.

levers(*, incentive_cost=0.0, price_cost=0.0)

Every lever on [0, 1], in lever_columns order.

stock_dynamics()

The mechanical rows, exactly: the stock decays at theta and the incentive adds to it.

In state_columns and lever_columns order and zero on every other row; handed to a fit as known=, it leaves only supply and the queue to learn.

zone_decision(*, target, state_weight, action_weight)

The incentives to hold, as a one-shot ZoneDecision, prices at the list price.

Its state is each zone's trips a period once the incentives have settled, its lever each zone's incentive on [0, 1], its channel incentive_channel::

trips = m0 + diag(alpha mu_s / D)(I - P) diag(channel) incentive,
D = gamma mu_q + gamma alpha + mu_s alpha,

mu_s and mu_q the linear law's weights on supply and the queue (derived in validation/zone_market.mac). Exact under linear matching; under harmonic matching, the settled trips' first-order response at the do-nothing point, where the two laws touch.

linear_gaussian(dt=1.0)

One period of sample's log as x' = a x + b u + offset + w, under a policy that does not see the shock.

The Jacobians of one RK4 period at the do-nothing point, so exact under linear matching. w carries the period's shock through the step and the supply and queue noise, with covariance shock_sd^2 e e' plus noise^2 on those rows, e the step's response to a shock. The logged operator did see the shock, so its actions and w are correlated.