Layer 1: The Consumer

The agent that wants, and chooses based on those wants

Author

D. Eric Belleville Jr.

Published

July 23, 2026

Layer 1: The Consumer that Wants, and Chooses Based on These Wants

# The agent is a function

Our decision-maker is nothing more than a function. Hand him an option, he returns a number: how much he wants it. A *choice rule* is a second function, `(agent, menu) -> chosen option`, that picks whichever option scores highest. That separation is the whole architecture. Preferences live inside the agent; the choice rule neither knows nor cares what utility function it was handed. Every behavioral wrinkle we add in later layers will slot into one of these two places.

0.1 Preferences over two goods

\[U(x,y) = [\alpha x^\rho + (1-\alpha)y^\rho]^\frac{1}{\rho}\] Where \(\alpha \in [0,1\)is the weight they place on good \(x\), \(\rho\) is the substitution parameter, and \(\sigma=\frac{1}{1-\rho}\) is the elasticity of substitution - that is, how readily they swap one good for the other when relative prices move.

Code
CES <- function(alpha, rho) {
  stopifnot(alpha >= 0, alpha <= 1)
  function(bundle) {(alpha * bundle[["x"]]^rho + (1 - alpha) * bundle[["y"]]^rho)^(1 / rho)
  }
}
# Given a menu and choice funciton, return the menu with the highest utility
choose_argmax <- function(utility, menu) {
  scores <- vapply(menu, utility, numeric(1))
  menu[[which.max(scores)]]
}

rank_menu <- function(utility, menu) {
  scores <- vapply(menu, utility, numeric(1))
  labels <- vapply(menu, function(o) sprintf("(x=%g, y=%g)", o$x, o$y), "")
  data.frame(option = labels, utility = round(scores, 3))[order(-scores), ]
}
Note‘CES()’ returns a function, not a number

0.2 Two agents, one menu

Alice and Bob differ only in \(\alpha\); we hold \(\rho\) fixed so that taste is the only thing varying

Code
menu <- list(
  list(x = 1, y = 9),
  list(x = 3, y = 3),
  list(x = 7, y = 1),
  list(x = 4, y = 5)
)

alice <- CES(alpha = 0.5, rho = 1)  # values x and y equally
bob   <-CES(alpha = 0.8, rho = 0.5)  # mostly wants x

rank_menu(alice,menu)
option utility
1 (x=1, y=9) 5.0
4 (x=4, y=5) 4.5
3 (x=7, y=1) 4.0
2 (x=3, y=3) 3.0
Code
rank_menu(bob, menu)
option utility
3 (x=7, y=1) 5.367
4 (x=4, y=5) 4.191
2 (x=3, y=3) 3.000
1 (x=1, y=9) 1.960

Same menu, same choice rule, different closure.

1 Indifference curves

Hold \(U\) fixed and collect every bundle that delivers it. The result is an indifference curve. Rather than inverting the utility function by hand, we evaluate it on a grid and contour the surface — a method that will still work later when the value functions get kinked.

First, the general machinery. A tree of nests, each an ordinary CES, lets us handle any number of goods; for now, every tree has a single node.

Code
good <- function(name, weight = 1) {
  list(kind = "leaf", good = name, weight = weight)
}
nest <- function(rho, ..., weight = 1) {
  list(kind = "nest", rho = rho, weight = weight, children = list(...))
}

ces_tree <- function(tree, tol = 1e-8) {
  eval_node <- function(node, bundle) {
    if (node$kind == "leaf") return(bundle[[node$good]])
    z <- vapply(node$children, eval_node, numeric(1), bundle = bundle)
    w <- vapply(node$children, function(ch) ch$weight, numeric(1))
    if (abs(node$rho) < tol) prod(z ^ w)              # Cobb-Douglas limit
    else                     sum(w * z ^ node$rho) ^ (1 / node$rho)  # CES
  }
  function(bundle) eval_node(tree, bundle)
}

plot_ces <- function(rho, main, levels = seq(1, 9, by = 1)) {
  U <- ces_tree(nest(rho = rho, good("x", 0.5), good("y", 0.5)))
  Z <- outer(gx, gy, Vectorize(function(x, y) U(c(x = x, y = y))))
  contour(gx, gy, Z, levels = levels, asp = 1,
          xlab = "x", ylab = "y", main = main, col = "#7F77DD", lwd = 1.3)
}

One plotting helper, used for all three cases below. Fixing lev at the same sequence every time is deliberate: it keeps the panels comparable, sot he only thing that changes across cases is the shape of the curves.

Code
gx <- seq(0.05, 10, length.out = 200)
gy <- seq(0.05, 10, length.out = 200)


plot_ces <- function(rho, main, levels = seq(1, 9, by = 1)) {
  U <- ces_tree(nest(rho = rho, good("x", 0.5), good("y", 0.5)))
  Z <- outer(gx, gy, Vectorize(function(x, y) U(c(x = x, y = y))))
  contour(gx, gy, Z, levels = levels, asp = 1,
          xlab = "x", ylab = "y", main = main, col = "#7F77DD", lwd = 1.3)
}

1.1 Case 1: Perfect Substitutes, \(\rho = 1, \sigma = \infty, \\ U=\alpha x+(1-\alpha)y\)

Code
plot_ces(rho = 1, main = expression(paste("perfect substitutes  (", rho, " = 1)")))
Figure 1: Perfect substitutes. Straight indifference curves: the two goods trade at a fixed rate regardless of how much of each he already holds.

Case 2: Perfect Complements, \(\rho = \infty, \sigma = 0, \\ U=\min\{\alpha x, (1-\alpha)y\}\)

Code
plot_ces(rho = -50, main = expression(paste("perfect complements  (", rho, " = -50)")))
Figure 2: Perfect complements. L-shaped curves: extra x is worthless without matching y.

::: {.callout-warning}

## Why `-50` and not `-Inf`

`-Inf` is not computable, so we approximate the limit with a large negative $\rho$. Push it much past `-100` and `x^rho` starts to overflow at small `x`, filling the grid with `Inf` and `NaN`. The elbows are already sharp at `-50`.

:::

Case 3: Cobb Douglas, \(\rho = 0, \sigma = 1, \\ U=x^\alpha y^(1-\alpha)\)

Code
plot_ces(rho = 0, main = expression(paste("Cobb-Douglas  (", rho, " = 0)")))
Figure 3: Cobb-Douglas. Curves bow toward the origin but never touch the axes, so he always buys some of both.

Compare Figure 1, Figure 2, and Figure 3: the contour heights are identical in all three, so the difference you see is entirely the geometry of substitution.

2 What if we want \(n\) goods?

The restrictions bite once we leave two goods. Flat CES forces the same elasticity between every pair — coffee substitutes for rent exactly the same as it does for tea, which is nonsense. And the machinery needs to stop assuming a bundle has exactly two elements.

The second is easy: a bundle becomes a named vector, and the choice rule never looks at its length.

Code
label_bundle <- function(b) {
  paste0("(", paste(names(b), b, sep = "=", collapse = ", "), ")")
}

rank_menu <- function(utility, menu) {
  scores <- vapply(menu, utility, numeric(1))
  labels <- vapply(menu, label_bundle, character(1))
  data.frame(bundle = labels, utility = round(scores, 3))[order(-scores), ]
}

The first is what the tree was for. Goods come in families: put close substitutes in a shared nest with a high- $\sigma$ parameter, and let the top level govern how families trade against each other.

Code

#| label: nested-consumer
U <- ces_tree(
  nest(rho = -1.0,                                                # across nests
       nest(rho = 0.8, good("coffee", 0.5), good("tea",       0.5), weight = 0.5),
       nest(rho = 0.8, good("rent",   0.5), good("utilities", 0.5), weight = 0.5)
  )
)

menu <- list(
  balanced       = c(coffee = 4,   tea = 4,   rent = 4,   utilities = 4),
  coffee_heavy   = c(coffee = 7,   tea = 1,   rent = 4,   utilities = 4),
  beverages_only = c(coffee = 7,   tea = 7,   rent = 0.5, utilities = 0.5),
  housing_only   = c(coffee = 0.5, tea = 0.5, rent = 7,   utilities = 7)
)

rank_menu(U, menu)
bundle utility
balanced (coffee=4, tea=4, rent=4, utilities=4) 4.000
coffee_heavy (coffee=7, tea=1, rent=4, utilities=4) 3.865
beverages_only (coffee=7, tea=7, rent=0.5, utilities=0.5) 0.933
housing_only (coffee=0.5, tea=0.5, rent=7, utilities=7) 0.933

Swaping tea for coffe costs them almost nothing — they share a nest. Dropping housing is close to catastrophic, because across nests the goods are complements. A flat CES cannot express both facts at once.

2.1 Three views of a four-good agent

Two of these are slices: freeze the other goods and plot the pair that remains. The third is not a slice because the tree genuinely aggregates, the composite plane $(Q_A, Q_B)$ carries the entire four-good problem with nothing discarded.

Code
# top level alone, treating the composites as two goods
U_top <- ces_tree(nest(rho = -1.0, good("QA", 0.5), good("QB", 0.5)))

u_within <- function(x, y) U(c(coffee = x, tea = y, rent = 4, utilities = 4))
u_cross  <- function(x, y) U(c(coffee = x, tea = 4, rent = y, utilities = 4))
u_comp   <- function(x, y) U_top(c(QA = x, QB = y))

grd <- seq(0.05, 12, length.out = 220)
lev <- seq(2, 5, by = 0.5)

panel <- function(f, xlab, ylab, main) {
  Z <- outer(grd, grd, Vectorize(f))
  contour(grd, grd, Z, levels = lev, asp = 1,
          xlab = xlab, ylab = ylab, main = main,
          col = "#7F77DD", lwd = 1.3, labcex = 0.7)
  contour(grd, grd, Z, levels = 4, add = TRUE,        # the U = 4 curve
          col = "#2a78d6", lwd = 2.4, drawlabels = FALSE)
  points(4, 4, pch = 19, col = "#2a78d6", cex = 1.2)  # the balanced bundle
}
Code

#| label: fig-contours
#| fig-width: 12
#| fig-height: 4.2
#| fig-cap: "The same agent photographed from three angles. Blue marks the balanced bundle (4,4,4,4) and its U = 4 curve."
par(mfrow = c(1, 3), mar = c(4.2, 4.2, 3, 1.2))
panel(u_within, "coffee", "tea",                "within nest  (sigma = 5)")
panel(u_cross,  "coffee", "rent",               "across nests  (sigma = 0.5)")
panel(u_comp,   "beverages Q_A", "housing Q_B", "composite plane  (exact)")

::: {.callout-tip}

## Nesting is a dimension-reduction device

The right panel of @fig-contours is exact, not approximate. Two-stage budgeting falls straight out: he first splits spending across families, then allocates within each one. That is why a four-good problem can be drawn honestly on a plane.

:::

3 Where he stands

He wants things, he ranks them, and he takes the best one on offer. He is also still omniscient, effortless, and unconstrained: no budget, no risk, no clock, no other agents. Every layer from here removes one of those superpowers.