We can filter tightly now, but in spite of the rich metadata attached to each produce candidate, we can't see anything about a candidate except its name. If you're deciding between peach and apricot, relevant information like price, color, season, etc… is already on the candidate's text properties, but Vertico is only showing us the string itself. Marginalia is the package that promotes candidates into informed choices by displaying their metadata as right-aligned columns next to each candidate name.
The trick that makes Marginalia (and every subsequent VOMPECCC package) possible is the convention already established by the list of produce items: the candidate is its name as a string, with its full record's fields stamped on as text properties. completing-read is satisfied because the candidate is a plain string; the rest of the substrate is satisfied because the properties are right there.
A note on scope before we start: plain completing-read can only carry one completion category at a time, and Marginalia dispatches its annotator off that prompt-level category. The corpus has two categories (fruit and vegetable); to keep this phase honest, we narrow the picker to just fruits for now. Phase 5 will lift this restriction with Consult's multi-source mechanism.
That narrowing is one line of Lisp, but worth pausing on. Every later phase (the Consult sources in Phase 5, the async variant in Phase 8) will partition the corpus by category in the same way, so we factor it out once and read the routing key off the candidate itself rather than off of some separate lookup table. This is the candidate-as-currency convention in miniature: a routing question is answered by reading a text property:
(defun my-produce-of-category (cat)
"Return candidates from `my-produce' whose `category' is CAT."
(cl-remove-if-not
(lambda (cand) (eq (get-text-property 0 'category cand) cat))
my-produce))
We still need to tell the prompt itself what completion category this is, because Marginalia dispatches its annotator off the prompt's metadata, not off per-candidate text properties. The candidate stamping we did in the corpus is for our own code (the actions, the transformer, the exporter) to read; the framework looks at prompt metadata. The lightest way to set the metadata is completion-extra-properties, a property list that overrides the completion metadata for the duration of one completing-read call:
(defun my-pick-fruit ()
"Pick a fruit by name."
(interactive)
(let ((completion-extra-properties '(:category fruit)))
(message "You picked: %s"
(completing-read "Fruit: " (my-produce-of-category 'fruit)))))
The foundational pattern — a completion function that responds to (action 'metadata) with (metadata (category . fruit)) — is still available and is what libraries like Consult build on. However, for a one-off picker without Consult, completion-extra-properties is equivalent and cleaner. Why is this even needed? Because plain completing-read over a list of strings has no way to communicate a category to Marginalia: the framework reads the prompt's completion metadata, and our list of strings doesn't ship any. As soon as we move to Consult in Phase 5, the source-level :category key takes over and the extra-properties step disappears entirely, which is exactly why packages like spot never need this dance. Every spot prompt is a Consult prompt. Our Phase 4 picker is the awkward case precisely because it is deliberately the barest possible call, and a demonstration of a Consult-less completion setup for our produce picker.
The annotator itself is the heart of this phase. It takes a candidate string, reads its text properties directly, and hands a list of columns to marginalia--fields, which does the alignment, per-field truncation, and face application:
(defun my-annotate-produce (cand)
"Annotate a produce candidate CAND with type, color, season, and price."
(marginalia--fields
((symbol-name (get-text-property 0 'type cand))
:truncate 13 :face 'marginalia-type)
((get-text-property 0 'color cand) :truncate 10 :face 'marginalia-string)
((get-text-property 0 'season cand) :truncate 12 :face 'marginalia-date)
((format "$%.2f" (get-text-property 0 'price cand))
:truncate 8 :face 'marginalia-number)))
Note that I am not writing any layout code at all. marginalia--fields handles padding, alignment, and face application; my job is only to declare which fields go in which columns. Annotating the candidates in this way enables Orderless's @ dispatcher to filter by our produce's metadata, so @berry, @citrus, @root become legitimate filter prefixes.
Registration for the fruit category is one add-to-list against Marginalia's annotator registry, plus enabling the marginalia-mode:
(add-to-list 'marginalia-annotators
'(fruit my-annotate-produce none))
(marginalia-mode 1)
The registry entry is (CATEGORY ANNOTATOR1 ANNOTATOR2 ... none), where each tail symbol is an annotator marginalia-cycle (M-A) can rotate to in-session. We list two states for our category: our custom annotator, then none (annotations off). This matches spot's convention and gives a clean toggle on M-A. Marginalia's own built-in entries also include a builtin symbol in the chain (which is a fallback that defers to whatever annotation-function the prompt's metadata declares natively) but for a category nobody else knows about (like fruit), there is no built-in to defer to, so leaving it out keeps the cycle to two visibly-different states instead of three.
Run the picker:
When (my-pick-fruit) runs, the prompt opens as it did before, but every one of the fruit candidates is now followed by a horizontally aligned set of columns: a type word (pome, berry, citrus, …), a color word (red, yellow, blue, green, …), a season (spring, summer, winter, year-round), and a dollar-formatted price ($0.59, $3.99, $6.99).
Stylistically, each column is padded to a fixed width and rendered in its own face, so the four fields read as distinct groups rather than running together with a delimiter. Where Phase 3 showed strawberry, this phase shows:
strawberry berry red spring $3.99
The list is legible at a glance, and what's more, you can usefully compare peach against apricot on price and season without typing anything. Scanning for cheap in-season fruit, for example, is made easy in this way.
Typing against annotation text is where Marginalia crosses from cosmetic to compositional. Typing yellow at the prompt matches nothing, because yellow is in the annotation column, not the candidate name, and Orderless is still matching against names only. But prefix that same component with @, as in @yellow, and the annotation dispatcher we wired up in Phase 3 tells Orderless to match this particular component against the annotation text instead of the candidate string. The list snaps to exactly the three yellow-colored fruits in the corpus.
To drive home the point that the VOMPECCC packages work independently of one another, Orderless knew nothing about Marginalia, and vice-versa. The @ dispatcher simply matches against whatever is in the "annotation slot" of the current candidate, and that slot happens to contain the words Marginalia stamped there.
The compound variant from Phase 3 cashes in here too: @~sm triggers the flex branch of annotation-if-at, flex-matching the characters s and m against annotation text. Only summer contains them in order, so the list collapses to the summer fruits.
Annotation components compose the same way regular components do. Typing @summer,@red on the empty prompt narrows first to summer fruits, then to the subset of those that are also red. The list collapses to raspberry and cherry, the two red summer fruits in the corpus. You can reach for a fruit by its properties without ever remembering its name. Post 2 called this "an unusually large leverage gain for what feels like a cosmetic layer".
The architectural observation here is that the @ dispatcher is not a Marginalia feature. It is an Orderless feature (a dispatcher we wrote) that happens to work because Marginalia exposes annotations through the same completion-metadata slot Orderless reads from. Swap Marginalia for any other annotator (say, a leaner one you write yourself that only shows price) and the @ filter still works. With an alternative annotation provider, Orderless would just filter against whatever that other annotator produces.