Vegetarian recipes + chores — a two-slot ad network on constrained inventory
Plans out what a tiny closed-taxonomy retail-media network looks like when the inventory is the taxonomy
Table of Contents
1. 1. What this is
A planning doc for a small ad network that constrains inventory to two closed taxonomies:
- vegetarian recipes — every unit points at one recipe, tagged with cuisine + primary-protein + prep-time + season
- chores — every unit points at one household task, tagged with frequency + surface + tools-required + skill-tier
Both taxonomies are small (target 40-200 items each), fully human-
curated, and closed — no /spec.org-style admission of arbitrary
new items. That closure is the point: sponsored-research on wal.sh
serves out of ~855 heterogeneous research notes and produces
matched chip clutter because the vocabulary is folksonomic
(retrospective §3.5). A two-slot network on 40+40 curated inventory
is the opposite endpoint of that spectrum.
Nothing here is implemented. This is the scoping document — what
the surface would look like if we built it. Sits alongside the
existing browser-adtech-infra/ specs.
2. 2. Why two, and why these two
Two independent taxonomies inside one network is the minimum needed to test whether the auction generalises across inventory kinds. A single-taxonomy network (just recipes) can be dismissed as a degenerate case; two disjoint kinds force the auction to answer "which kind serves given this query context?" — the archetype router question from the multi-contract L7 audit (docs/l7-audit-multi-contract-2026-08-17.org §5, Phase 5 open question).
Vegetarian recipes is chosen because:
- Well-bounded (ovo-lacto-vegan spectrum; no ambiguity)
- Rich attribute set (cuisine × protein × prep-time × season × diet)
- Serving context is trivial to reason about (query
dinner,quick,Indian,lentilsall naturally overlap) - Zero fictional-referent risk (unlike the
Obbligatoincident; every recipe is either really from a cookbook or really from a test kitchen)
Chores is chosen because:
- Also well-bounded, but with orthogonal attribute axes
- Tests non-affinity matching (a query for
dinnershould NOT serveclean the gutters; the auction must refuse the cross-taxonomy match without invention) - Human tasks vs consumables: different render templates, different attribution surfaces
- Operator dogfood — chores I actually do become the ledger of what gets recommended, which surfaces the observer-in-the-experiment problem honestly
The pair is chosen to be complementary in schema but disjoint in audience. A user browsing recipes typically wants more recipes, not chores. Cross-taxonomy interleaving is a deliberate signal: "we ran out of matching recipes and are showing you chores instead" = inventory-thin, exactly the pathology the retrospective's §3.1 identifies as monotone remnant degradation.
3. 3. Inventory shape
Two closed types. Single constructor each. Total validator each. Same contracts-first pattern as sponsored-research (sponsored-research/spec.org).
3.1. 3.1 recipe/v1
Required keys: :id :title :cuisine :protein :prep-min :season :diet
:source-kind :ingredients :steps-count
Enumerated ranges:
:cuisine— Italian · Indian · Mexican · Thai · French · Ethiopian · Middle-Eastern · American · Japanese · Chinese · Korean · Vietnamese · other-recorded:protein— legumes · tofu · tempeh · seitan · dairy · egg · nuts-seeds · no-protein-focus:prep-min— integer 5..240:season— spring · summer · autumn · winter · year-round:diet— ovo-lacto · vegan · ovo · lacto · strict-vegan:source-kind— cookbook · test-kitchen · adapted · original:ingredients— vector of strings, 3..30 items:steps-count— integer 1..25
Rejected: recipes with <3 or >30 ingredients (structural noise), prep >4 hours (not a weeknight recipe), or protein source outside the enum. Rejection is silent at admission (§4 gate check).
3.2. 3.2 chore/v1
Required keys: :id :title :frequency :surface :tools :skill-tier
:duration-min :hazards
Enumerated:
:frequency— daily · weekly · monthly · quarterly · annual:surface— kitchen · bathroom · floor · window · roof · garden · appliance · car · storage:tools— vector from a closed 47-item tool list (screwdriver, scraper, brush, …):skill-tier— trivial · basic · intermediate · advanced:duration-min— integer 2..480:hazards— vector subset of #{height chemical electrical sharp heavy climbing water}
Rejected: chores requiring tools not in the 47-item list (violates
closure), hazards outside the enum (silently coerced to other,
logged for admission review).
4. 4. Auction contract
Same skeleton as the sponsored-research v-final contract (sponsored-research/spec.org), adapted for two-taxonomy routing.
R1. Every unit MUST carry :taxonomy ∈ #{"recipe" "chore"}.
R2. Every unit's :matched array MUST resolve against the correct
taxonomy's attribute space. A recipe unit MUST NOT include
attributes from the chore vocabulary in its matched list, and
vice versa.
R3. Cross-taxonomy interleaving requires explicit operator
opt-in: default is same-taxonomy only. When opted in, an interleaved
result MUST carry :cross-taxonomy true in the envelope so the
consumer (auditor) can flag it as a fallback signal, not organic
match.
R4. Auction routing:
- Query has a taxonomy hint (
cuisine,prep,season, etc.): serve fromrecipeonly - Query has a chore hint (
cleaning,weekly,tool, etc.): serve fromchoreonly - Ambiguous or empty: skip the sponsored slot (do NOT invent a
cross-serving unit). Empty is honest; wrong is
Obbligato.
R5. Non-displacement (I8 in sponsored-research spec): a served ad MUST NOT permute the organic ranking. Same GSP soundness argument carries here — see [BROKEN LINK: No match for fuzzy expression: *3.3].
R6. Slot budget: two slots per page (one recipe-only, one chore-only). Recipe slot fills first; if empty, chore slot fills; if both empty, no ad rendered. Never both slots filled by the same taxonomy.
R7. Frequency capping: any (uid, unit) pair capped at 3
impressions per 24h, 1 impression per hour. Prevents the rage-click
pattern surfaced in [BROKEN LINK: No match for fuzzy expression: *5.3].
5. 5. Render
Two card templates, both greyscale-banner + selectable-DOM-text per the wal.sh style guide (diagram style guide).
5.1. 5.1 Recipe card
Layout: [greyscale banner 200x150 · title · matched: cuisine,
protein, prep · "Read (<n> steps)"]
Banner sourced from a closed 12-plate palette (per-cuisine base plate, per-protein overlay). Same flux2-klein pipeline the sponsored-display banners use — see plates/spec.org.
5.2. 5.2 Chore card
Layout: [pictogram 100x100 · title · matched: surface, frequency,
tool · "Duration <n> min · Skill: <tier>"]
No banner — chores get a black-line pictogram from a closed 40-icon set. This is a deliberate visual contrast: the reader can tell recipe vs chore before reading. Every recipe unit is warm-greyscale; every chore unit is line-art-black-on-white. The dichotomy answers "is this cooking or cleaning?" without a category label.
6. 6. Makefile — the pipeline
The Makefile lives inside the note dir (tangled per §12) and
governs the whole inventory lifecycle: seed → validate → build
plates → serve → measure. Follows the wal.sh convention of
per-project Makefiles (Makefile.gates, per-note recipe files).
# vegetarian-recipes-network Makefile
# Lives in site/research/browser-adtech-infra/vegetarian-recipes-network/
# All operator chores for the two-slot network. Run with `gmake -C .` from
# the parent, or `make` from this dir.
INVENTORY_DIR := inventory
PLATE_DIR := plates
BUILD_DIR := build
BB := bb
.PHONY: help seed validate gate plates render corpus-json audit deploy clean
help: ## Show all targets
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | \
awk 'BEGIN{FS=":.*## "}{printf " %-14s %s\n", $$1, $$2}'
seed: ## Add a new inventory item (INTERACTIVE=1 for prompts, else use ITEM=path)
@if [ -n "$$ITEM" ]; then $(BB) scripts/seed.clj --from "$$ITEM"; \
else $(BB) scripts/seed.clj --interactive; fi
validate: ## Run closed-type validators against every item in $(INVENTORY_DIR)/
@$(BB) scripts/validate.clj $(INVENTORY_DIR)/
gate: validate ## Full gate: closed-types + negative fixtures + cross-taxonomy leakage
@$(BB) scripts/gate.clj
plates: ## Generate greyscale banner PNGs (recipe) + pictograms (chore) via flux2-klein
@$(BB) scripts/gen-plates.clj \
--inventory $(INVENTORY_DIR) --out $(PLATE_DIR)
render: gate plates ## Compose per-unit cards for offline preview
@$(BB) scripts/render.clj \
--inventory $(INVENTORY_DIR) --plates $(PLATE_DIR) --out $(BUILD_DIR)
corpus-json: gate ## Emit corpus.json — the payload the browser bundle fetches
@$(BB) scripts/emit-corpus.clj $(INVENTORY_DIR)/ > $(BUILD_DIR)/corpus.json
@printf "✓ corpus.json: %s items, %s bytes\n" \
"$$(jq '.units | length' $(BUILD_DIR)/corpus.json)" \
"$$(wc -c < $(BUILD_DIR)/corpus.json)"
audit: ## Consume today's access log slice, produce per-slot fill rate + CTR
@$(BB) ../../../scripts/tracker-intent.clj \
--page /research/browser-adtech-infra/vegetarian-recipes-network \
--format counts
deploy: gate corpus-json ## Push corpus.json + plates to production (idempotent)
@rsync -avz $(BUILD_DIR)/corpus.json \
www_wal_sh@vps47738.dreamhostps.com:~/wal.sh/static/vrn/corpus.json
@rsync -avz $(PLATE_DIR)/ \
www_wal_sh@vps47738.dreamhostps.com:~/wal.sh/static/vrn/plates/
@echo "✓ VRN corpus deployed"
clean: ## Remove $(BUILD_DIR) and $(PLATE_DIR); inventory + scripts unchanged
@rm -rf $(BUILD_DIR) $(PLATE_DIR)
@echo "✓ clean"
7. 7. Metrics
Reuses the site tracker's event vocabulary — no new event names.
element.impressionon avrn-slotdata-track-kind → filledsponsored.clickwhereelKind=vrn-recipeORvrn-chore→ CTRsponsored.timeoutper slot → provider health- Absence of both slot-events (no impression, no timeout) → the slot was gated closed at auction (no matching inventory), which is the operational "we said no" signal to track separately
Daily aggregation via scripts/event-flow-snapshot.clj (already
committed) — the timeout_slots field naturally partitions across
the two slots.
8. 8. Refutation condition
The claim: constraining ad inventory to a closed 40-80 item two-taxonomy corpus produces a legible auction that behaves better than sponsored-research's 855-doc open corpus, on three axes:
- Zero
matchedchip clutter. Every unit has ≤4 matched terms from the taxonomy's enumerated attribute space. No folksonomic long-tail keywords. - Zero cross-taxonomy hallucination. Never a chore served on a
recipe query without an explicit
:cross-taxonomyflag. - Auditable serve/no-serve. Every page load either fills the
slot (with a unit whose taxonomy match is machine-verifiable
against the query) or emits an explicit "no eligible" gate
log — never a limp partial like
Obbligato.
It fails if any of:
- After a month of operation, >5% of served units have a
:matchedarray with ≥5 terms (chip clutter returned) - Any served
choreunit lands on a recipe-context page (or vice versa) without:cross-taxonomy true - The "no eligible" gate rate exceeds 40% of page loads (inventory too thin to matter → fold the network)
- The CTR gap between recipe slot and chore slot exceeds 10× on matching queries (one taxonomy is dominating, kill the other)
9. 9. What's NOT in scope
- Real advertisers, real money, real auction competition. Same closed-ecosystem posture as sponsored-research ([BROKEN LINK: No match for fuzzy expression: *3.8]).
- Payment attribution (there IS no payment; the parody continues).
- Cross-site retargeting. Uid is opaque per R4 of the observer spec; no cross-origin sync.
- Recipe instructions (steps beyond the count). Content stays on the destination page; the ad card is a pointer, not a reader.
- Chore guidance (how to do it). Same rationale.
10. 10. Open questions
- Seasonality bias. If the recipe corpus is heavier on winter soups than summer salads, does the auction end up systematically serving the wrong season? Bake seasonality into the eligibility filter (R4) so a summer query only sees summer + year-round.
- Skill floor for chores. Should
advancedchores (chimney sweep) be filtered out by default? Default-yes; opt-in explicit. - Duplicate detection. Two recipes titled "lentil soup" from two different cookbooks — do we serve both, prefer one, or link? Prefer the more recently seeded one; log the duplicate for human review.
- When does the inventory need to grow? Target the 40-200 band. Below 40 the auction runs out of matches on common queries; above 200 the human curation load exceeds the value. The ceiling is the point.
11. 11. Sequence — how this would build
Not a promise, a proposal. Order matters.
- Author
recipe/v1+chore/v1contracts ascontract.cljc, with negative fixtures (per contracts-first process). - Seed 12 recipes (three per season) + 12 chores (three per frequency tier). Just enough to test the auction plumbing.
- Wire the two-slot render behind a feature flag
(
window.__vrn_enabled__ = true). Live on a hidden URL only. - Extend the tracker's
tracker.core/known-eventsif a new event category is needed (probably not — reusesponsored.impression,sponsored.click,sponsored.timeout). - Bake the plate pipeline against a closed-palette (12 recipe
base plates, 40 chore pictograms) using the existing
flux2-klein infra + a new
gen-plates.cljscript. - Ship to the hidden URL; run the tracker; grade against §8's refutation criteria after 7 days.
- If green: promote inventory to 40+40, expose on real pages, revisit refutation at 30 days.
- If red: kill and write the post-mortem.
12. Cross-references
- sponsored-research/spec.org — the open-corpus counterpart; contract skeleton reused
- retrospective/index.org — §3.5 (n constructors zero contracts), §3.8 (closed ecosystem)
- vocabulary-binding/index.org — why the taxonomy IS the targeting layer
- event-flow-viz/index.org — the measurement surface, unchanged
- plates/spec.org — the flux2-klein pipeline this reuses
- docs/adtech-contracts-first-process.md — the 8-step pipeline