Skip to main content

chop: When Every Command Returns the Same Kind of Thing

Section 2.2 of Structure and Interpretation of Computer Programs introduces the closure property: the result of combining things should be the same kind of thing you started with. cons two values and you get a pair — which you can cons again. This is what makes recursive data structures possible. Without it, you get flat records. With it, you get trees, lists, nested structure of arbitrary depth.

Abelson and Sussman are careful to distinguish this from lexical closures (functions that capture their environment). Algebraic closure is about the type signature of combination: if the output type matches the input type, composition is unlimited.

Most discussions of closure treat it as a property to verify — you check whether your algebra is closed and move on. But closure is more powerful than that. It’s a design method: choose a type, force every operation to consume and produce that type, and see what emerges. The constraint does the creative work.

chop is an image-manipulation CLI built on exactly this principle. 27 commands. One rule: read JSON from stdin, write JSON to stdout.

The Problem with Image Pipelines

Traditional image CLIs violate closure. ImageMagick consumes a file and produces a file. Each invocation is terminal — the output is pixels, not something you can pipe into further processing without going back to disk. Composition happens through flag accumulation inside a single command, not through the shell’s native composition mechanism.

You can’t tee a midpoint. You can’t save a half-finished pipeline as a recipe and apply it later. You can’t branch.

The SICP parallel is direct: if cons produced an atom instead of a pair, you could build flat structures but not recursive ones. If an image command produces pixels instead of a composable description, you can build single transformations but not pipelines.

One Constraint: JSON In, JSON Out

Every chop command reads a PipelineState JSON object from stdin, appends one operation, and writes the updated JSON to stdout. The wire format carries no image data — only a recipe:

{
  "version": 3,
  "ops": [
    ["load", ["photo.jpg"], {}],
    ["resize", ["50%"], {}],
    ["pad", [10], {"color": "white"}]
  ],
  "metadata": {}
}

Each operation is a [name, args, kwargs] triple. The pipeline accumulates operations as data. Here’s what this looks like in practice:

chop load photo.jpg | chop resize 50% | chop save out.png

And here’s what flows between the pipes — the actual stdout of chop resize 50%:

{"version":3,"ops":[["load",["photo.jpg"],{}],["resize",["50%"],{}]],"metadata":{}}

The type signature is PipelineState -> PipelineState. Every command, without exception, consumes and produces the same kind of thing. Closure holds.

What Emerges

The interesting part isn’t the constraint itself — it’s what the constraint produces. Four capabilities fall out of the design that were never planned individually.

Non-Terminal Save

In most CLIs, save is a terminus. In chop, save materializes the pipeline (touches pixels for the first time), writes the file as a side effect to stderr, and then outputs its JSON state like every other command:

chop load photo.jpg | chop save full.png | chop resize 50% | chop save thumb.png

Two output files from a single pipeline. This works because save isn’t special — it follows the same contract as resize or blur. The uniform type signature means there’s no distinction between “intermediate” and “terminal” commands. Every command is both.

Recipes as Data

What happens if you build a pipeline without load? You get an unbound program — a sequence of operations with no input image:

chop resize 50% | chop pad 10 | chop grayscale > recipe.json

The resulting JSON is a first-class value:

{"version":3,"ops":[["resize",["50%"],{}],["pad",[10],{}],["grayscale",[],{}]],"metadata":{}}

Apply it later with apply:

chop load photo.jpg | chop apply recipe.json | chop save out.png

This is SICP Chapter 4’s programs-as-data principle. A pipeline without load is a function waiting for an argument. The apply command is function application. And because recipes are JSON, they’re inspectable, version-controllable, and shareable — you can commit a thumbnail-recipe.json alongside your build scripts.

Multi-Image Composition

The JSON format naturally extends to labeled images. Load with --as to name images; target specific images with --on:

chop load --as bg photo.jpg | chop load --as fg logo.png \
    | chop resize 50% --on fg \
    | chop overlay bg fg \
    | chop save watermarked.png

Under the hood, materialize() maintains a labeled context — a dictionary mapping names to images with a cursor tracking the “current” image. Composition operations like overlay, hstack, vstack, and grid take label arguments and combine multiple images into one. The result goes back into the context, and the JSON contract never changes.

This wasn’t designed as a separate feature. It’s the same PipelineState -> PipelineState contract accommodating richer operation types. The closure property scales.

Inspectable Intermediates

Because stdout is always valid JSON, standard Unix tools work at any point in the pipeline:

chop load photo.jpg | chop resize 50% | tee step.json | chop pad 10 | chop save out.png

tee captures the intermediate state. jq can query it. diff can compare two pipeline states. wc -c tells you the recipe size. None of this required any design effort — it’s a consequence of the output being structured text rather than binary pixels.

Lazy Evaluation

Nothing in the pipe is an image. The JSON carries [name, args, kwargs] triples — a description of computation, not the computation itself. No pixels are loaded, no transforms applied, until a command explicitly materializes the pipeline.

Only two commands trigger materialization: save (to write a file) and info (to report dimensions and metadata). Everything else is pure recording.

This is SICP Chapter 3.5’s insight about streams: separate the description of a computation from its execution. A pipeline of ten operations doesn’t create ten intermediate images. It creates one JSON object with ten entries, then executes them all in a single pass at the end.

The materialize() method in pipeline.py walks the ops list, building a labeled image context as it goes — loading files, applying transforms, executing compositions — and returns the image at the cursor. It’s the only eager path in the system.

The Architecture

The codebase is small and sharply factored:

  • output.py (19 lines): One function. Writes pipeline state as JSON to stdout. No conditions, no flags. This is where the closure constraint lives — and it’s 19 lines because enforcing uniformity is trivial.

  • operations.py (696 lines): Pure Image -> Image functions. Sixteen single-image transforms (resize, crop, rotate, brightness, blur, etc.) and four multi-image compositions (hstack, vstack, overlay, grid). No I/O, no JSON, no state — just pixel manipulation.

  • pipeline.py (311 lines): The PipelineState dataclass. JSON serialization, deserialization, and materialize() — the single method that turns a recipe into pixels.

  • cli.py (645 lines): 27 commands, each roughly 10 lines. Read state, append one op, return state. The uniformity of the contract means every command handler has nearly identical structure.

The total is under 1,700 lines. The interesting thing is the ratio: 696 lines of image operations, 19 lines enforcing the design constraint. The constraint is cheap to implement and expensive to violate — which is exactly what you want from an architectural decision.


One design decision — every command reads JSON, writes JSON — and four capabilities emerge: non-terminal saves, reusable recipes, multi-image composition, and inspectable intermediates. Plus lazy evaluation for free, because the uniform representation turns out to be a recipe rather than a result.

SICP presents the closure property as something to notice about well-designed systems. But the deeper lesson is that closure is something to impose. Pick a type. Force everything through it. The constraint doesn’t limit what you can build — it generates capabilities you wouldn’t have thought to design.

The best design decisions are the ones you only have to make once.


This post is part of a series on SICP, exploring how the ideas from Structure and Interpretation of Computer Programs appear in modern programming practice.

Discussion