Controlled Vocabulary v2 for wal.sh: Adtech Facet and Bidirectional Matching

Table of Contents

1. Purpose

v1 was written on 2026-05-31 to convert 492 free-text-keyword docs into a 6-facet, 47-term controlled vocabulary (Controlled Vocabulary v1). Two things have changed since then that make a v2 necessary rather than optional.

First, keyword hygiene is now the sole reason-to-serve gate for the sponsored slot. wal-sh.adtech.sponsored-research.core/matched-terms returns up to three keywords from a candidate doc's :keywords list that share a bidirectional substring with a query token; V-final V5 requires the returned vector to be non-empty. If matched-terms returns [], no sponsored card serves for that doc under that query. Keyword hygiene is no longer just a search-ranking concern — it directly gates ad inventory. The matched-terms implementation lives in src/wal_sh/adtech/sponsored_research/core.cljc (lines 85-110) and the build+contract discipline it sits inside is documented in adtech-contracts-first-process.

Second, the corpus has grown from 623 docs (v1) to 855 docs (this file's Corpus snapshot), and a new content cluster — site/research/browser-adtech-infra/ with 15 spec.org files plus a creative-ops/ subtree with 9 creative pages — has entered the tree carrying keywords that don't belong to any existing facet. v1's six facets (lang, domain, format, era, project, status) do not cover browser-adtech, tracker, pocket-es, plates, beacon, chumbox, creative-ops, sponsored-research. These are neither languages nor domains nor formats nor eras — they are components of an experimental adtech surface the site operates as its own subject matter. v2 adds one facet, adtech, with 12 terms, and formalises a compound-keyword policy whose necessity was invisible before bidirectional matching.

2. Delta from v1

2.1. What v1 committed to

v1 defined 6 facets and 47 controlled terms:

Facet Terms Purpose
lang 12 Programming language when the language is the subject
domain 16 Conceptual territory of the doc
format 8 Document format (conference / research / spec / brief …)
era 4 Time horizon of content (historical / foundational / current / emerging)
project 7 wal.sh internal research threads
status 4 Publication state (draft / stub / deprecated / evergreen)

Storage rule: controlled terms live in #+FILETAGS; free-text descriptive terms stay in #+KEYWORDS for BM25 search.

2.2. What v2 changes

Change Detail
KEEP all 47 v1 terms v1 stays authoritative for lang, domain, format, era, project, status. No terms removed, no terms renamed.
KEEP the #+FILETAGS / #+KEYWORDS split v1's storage rule is unchanged. v2 additions land in #+KEYWORDS (for matched-terms reach) and, where facet-appropriate, in #+FILETAGS.
ADD one facet: adtech 12 terms covering the browser-adtech-infra cluster + the sponsored-research reason-to-serve gate. See 3.
ADD one project: project:sponsored-research New wal.sh research thread; adds to v1's project facet without altering existing entries.
ADD one project: project:creative-ops Same rationale as above.
RE-CLASSIFY pocket-es v1 filed it under domain:search and project:pocket-es. v2 additionally exposes it as an adtech:pocket-es alias because it is the interleave surface for sponsored content, not only a search engine. Both classifications are valid; the adtech: alias is additive.
PROPOSE retirement (soft) project:underwriter (the underwriter subsystem was moved to _drafts/ 2026-07 — 14 files); keep the term registered so historical drawer entries still validate, but mark it RETIRED in the term registry. No removal from v1.
ADD a compound-keyword policy See 4. This is the load-bearing v2 addition.

3. Facet addition: adtech

The adtech facet exists because the browser-adtech-infra cluster has 15 spec files with keywords that don't belong to domain:* (they are not what the doc is about in a topical sense — they are the system components the doc specifies). Treating them as domain terms would put browser-adtech in a bucket alongside domain:security and domain:distributed, which is a category error: the browser adtech surface is a thing wal.sh operates, not a territory wal.sh researches. The project=/=domain boundary from v1 is the guide — but the adtech subsystem is large enough (15 specs + 9 creatives + core + gate + browser adapter) that folding it into project:* would eat half the project facet.

Twelve terms. Same earned-term criterion as v1: appears in ≥3 docs AND is irreducible.

Term Covers Current occurrences (source of truth = org files, not index — see 5)
adtech:browser-adtech Top-level cluster tag; every browser-adtech-infra/*/spec.org carries it 15+ specs, 1 index doc
adtech:sponsored-research The sponsored slot itself (the CLJS oracle, the V-final contract, matched-terms) 10 creatives + spec + contract doc
adtech:sponsored-display Display-format specs (banner, plate, IAB sizing) 1 spec, extends via creatives
adtech:attribution Attribution models, incrementality, holdout design 2 specs (attribution-audit, attribution-engine)
adtech:intent-signals Engaged-time / dwell / scroll / chartbeat-style intent capture 1 spec, +2 specs referencing
adtech:exit-intent Before-you-leave overlay signal 1 spec
adtech:stuffing-detectors Detection of URL-parameter stuffing, go redirects, distributional recall 1 spec
adtech:tracker Passive pixel + intersection-observer capture 1 spec
adtech:beacon sendBeacon envelope + pixel fallback 1 spec
adtech:plates Plate generation pipeline (flux2-klein, ollama, greyscale) 1 spec, +references from creatives
adtech:creative-ops The lifecycle state machine + review workflow for creatives 1 lifecycle spec + 9 creative pages
adtech:pocket-es Alias into the adtech surface: search-mount interleave, sponsored-card injection 1 spec (pocket-es-integration)

Six terms above are essential (appear in ≥3 docs already or are load- bearing for matched-terms): browser-adtech, sponsored-research, creative-ops, pocket-es, tracker, plates. The remaining six are included on the irreducibility criterion (they name distinct spec files that other specs cross-reference) even though several are hapax today — they will grow with the cluster.

3.1. Recommended aliases (not new facets)

  • chumbox → alias for adtech:sponsored-display (industry term for the taboola/outbrain box format; use chumbox in prose but tag as adtech:sponsored-display)
  • ab-engine, ab-testing, bucketing, fnv1aproject:ab-engine (belongs to project:* not adtech:* — it's a library that powers the adtech surface but is independently valuable)
  • search-mountadtech:pocket-es (search-mount is the DOM adapter that hosts interleave; naming it separately splits chip mass without gaining discrimination)
  • sponsored-onboardingadtech:creative-ops (onboarding is a lifecycle phase, not a distinct facet)
  • web-historydomain:web + adtech:browser-adtech (topical + system-component)

4. Compound-keyword policy under bidirectional matching

This is the v2 addition that has no analogue in v1. v1 assumed keywords were matched by exact string equality (search chips resolve to docs that list the exact term). matched-terms does bidirectional substring matching:

;; Reference impl (matches v-final JS oracle)
(filter (fn [k]
          (some (fn [t]
                  (or (str/includes? k t)
                      (str/includes? t k)))
                query-tokens))
        keywords)

Consequences the vocabulary must plan for:

  1. The compound keyword local-first MATCHES query token local (k.includes(t) — the keyword contains the token).
  2. Query serving MATCHES keyword sponsored-serving (k.includes(t)).
  3. Query sponsored MATCHES keyword sponsored-research, sponsored-display, sponsored-onboarding — one query fans out to every sponsored keyword variant.
  4. Query research MATCHES keyword sponsored-research (t.includes(k) is not needed here; k.includes(t) handles the research substring inside sponsored-research).
  5. Query bot would MATCH keyword robot (k.includes(t), because "robot" contains "bot"). Silent semantic slippage.

4.1. The rule

Prefer a compound keyword when the compound is a stable industry term AND the components (a) always co-occur in the doc's real subject matter and (b) at least one component is a common query token that would collide with an unrelated meaning.

Split into components when the components (a) are separately meaningful for search and (b) do not create semantic collisions under substring match.

4.2. Worked examples

Choice Rationale matched-terms behaviour
sponsored-research (compound) sponsored alone would match sponsored-display and sponsored-onboarding; keeping the compound lets query research disambiguate query sponsored → matches all three; query research → matches only sponsored-research. Compound gives disambiguation upgrades.
local-first (compound) first alone is meaningless as a chip; local alone would match locale, localhost, localized query local → matches local-first via substring (fires; recall boost); query first alone rarely occurs. Compound is safe and preserves the discriminating chip.
attribution-audit (compound) audit is a common query token that would collide with security-audit, makefile-audit, nomenclature-audit. Compound scopes the term to the adtech feature. query audit → matches all four audit types (fine, that's the point of a broad query); query attribution → matches attribution-audit only.
bot vs robot (SPLIT — retain both) Substring makes bot match robot via k.includes(t). Kept both because both are the site's canonical labels for the webring bot-trap corpus, and losing either breaks 62+ historical drawer entries. query bot → matches both bot and robot keywords (over-recall is acceptable here; the docs really are about both).
ai agent vs ai-agent (MERGE to hyphenated) Same referent, two variants; hyphenated is the v1 convention. Split serves no query. Post-merge, query agent matches; query ai matches; no downside to the merge.
machine learning (SPLIT into ml alias?) ml alone is too broad; machine alone means nothing; learning alone matches reinforcement learning, curriculum learning, lifelong learning. Keep the compound. query ml → NO MATCH under substring against keyword machine learning (neither contains the other). This is a bidirectional-matching miss v1 didn't foresee. FIX: add ml as an additional keyword alongside machine learning on the ~25 docs.
pocket-es vs pocket=+=es (KEEP compound) es is Spanish and Elasticsearch; pocket is unrelated. Compound is the only correct form. query pocket-es → exact match on the 12 tagged docs; query pocket → matches via substring; query es → NO MATCH (neither string contains the other). Alias elasticsearch on the same docs if es reach matters.

4.3. The corollary rule

If a compound keyword's canonical short form is a common query, ADD the short form as a second keyword. machine learning docs get both machine learning AND ml. sponsored-research docs get both sponsored-research AND sponsored. This is additive — no v1 keywords are removed — and it fixes the bidirectional-matching miss where neither string contains the other.

5. Corpus snapshot 2026-08-17

Analysis of site/static/search-index.json as of the run at 2026-08-17T00:34Z. Reproducible via bb (see src/wal_sh/site/ for the readers; the counts below are from a one-off bb script). Caveat: the index does NOT include the 15 browser-adtech-infra/*/spec.org files or the 9 creative-ops/creatives/*.org files yet — the indexer either filters spec.org or has not been re-run since those files landed. The adtech-facet counts under 3 come from the org files on disk, not from this index. See 7.

Metric Value
Total docs in index 855
Docs with 0 keywords 2
Docs with 1-2 keywords 1
Docs with 3-5 keywords 134
Docs with 6+ keywords 718
Total keyword mentions 7539
Unique lowercased keywords 4525
Hapax (freq=1) 3605 (79.7% of unique)
Rank Count Keyword
1 83 clojure
2 45 index
3 44 clojurescript
4 41 conference
5 40 javascript
6 36 bot
7 34 python
8 33 llm
9 32 security
10 32 robot
11 32 mcp
12 31 clojure-conj
13 31 canary
14 30 disallowing
15 30 functional programming
16 27 org-mode
17 26 emacs
18 25 programming language
19 25 machine learning
20 25 scheme
21 23 lisp
22 22 agent
23 20 weekly-summary
24 19 freebsd
25 19 2026
26 19 spa
27 18 verification
28 18 typescript
29 18 tool
30 17 ai agent

Ten sampled hapax (arbitrage-unit gold under matched-terms — the queries that hit exactly one doc):

  • boeing-speea-contract
  • def con 18
  • co-located event
  • hypersensitivity
  • context-repository
  • professional development
  • open-source-ai-advocacy
  • high performance computing
  • codex-desktop-linux
  • trust building

5.1. What's surprising

The bot / robot / canary / disallowing / spa cluster (disallowing at 30, canary at 31, spa at 19, robot at 32) is now in the top 30. These entered from the walsh-research bot-trap corpus and did not exist in v1's top-frequency analysis. The vocabulary now has to decide whether bot and robot are one term or two (see the compound-keyword worked example above) — and the substring-match rule turns the bot=/=robot choice into a matched-terms behaviour question, not just a stylistic one.

Also surprising: the singleton ratio is unchanged from v1 (79.7% vs 80%). The convergence rounds documented in the parent 2026-keyword-vocabulary-convergence page reduced per-doc singleton mass but the total unique-term count grew proportionally with corpus growth. Steady state, not convergence.

6. Migration

6.1. Cost estimate

Additive migration is deliberately cheap. Estimated cost:

Phase Work Time
1 Add bb script under scripts/ that reads the org tree, computes hapax + top-freq, and prints candidate #+KEYWORDS additions for the ~250 docs that would benefit from the ml / sponsored short-form aliases 1 hour
2 Manual review of the script's proposals (accept / reject per doc) 2 hours
3 Batch-apply accepted changes via the bb script (edits in place; v1's rule stands — never bulk-replace existing keywords) 15 min
4 Re-index (gmake index) and re-verify with the parent convergence workflow 30 min
5 Trigger a re-index that includes site/research/browser-adtech-infra/*/spec.org (may require adjusting pocket-es.indexer filter — currently spec files appear absent from the live index) 30 min
6 Register adtech:* terms in the machine-readable vocabulary source (v1 proposed scripts/vocabulary_v1.py; v2 should land as a .cljc module under src/wal_sh/site/vocabulary/ so it's queryable from the running nREPL) 1 hour

Total: ~5 hours of tooling + review. No changes to matched-terms. No changes to any existing keyword. No file renames.

6.2. What NOT to do

  • Do NOT bulk-replace free-text keywords with adtech:* tags. v1's rule applies: #+KEYWORDS carries free-text signal, #+FILETAGS carries the controlled vocabulary. Add alongside; never overwrite.
  • Do NOT strip bot or robot — both are load-bearing and the bidirectional match is intended (see worked example above).
  • Do NOT rename pocket-es to adtech:pocket-es — the pocket-es keyword is the search-chip identifier for 12 docs. Add adtech:pocket-es as an additional #+FILETAGS entry on the doc subset that discusses pocket-es-as-adtech-surface (roughly: the pocket-es-integration spec, the interleave-ratio research notes, the search-mount spec).
  • Do NOT modify matched-terms. The vocabulary adapts to the reference JS oracle, not the other way around. This is a fixed point of the v2 design.

7. Refutation condition

The v2 proposal is falsified if any of the following holds after a full migration pass:

  1. The adtech facet, after Phase 2 review, ends up with fewer than 6 terms actually applied to ≥3 docs. This would prove the facet is speculation rather than a real emergent category. (Fallback: demote the terms to project:* entries and retire the facet.)
  2. Any of the compound-keyword rules produces a matched-terms result set that violates the sponsored-research V-final contract — most notably, a query that matches ZERO keywords on a doc that should plausibly serve a sponsored card. This would prove the vocabulary is under-connected relative to the reference JS oracle's substring behaviour. (Fallback: add short-form aliases per the corollary rule more aggressively, and re-measure the empty-matched-terms rate.)
  3. Post-migration, the singleton ratio has not dropped below 70% (from 79.7%). The additive-alias approach is designed to lower the hapax share by giving lonely compounds a broader alias. If it doesn't, the vocabulary work has produced no measurable convergence and this file should be superseded rather than iterated. (Fallback: return to the pre-2026 folksonomy view and accept that keyword hygiene is not controllable at this corpus scale.)
  4. Any single query token, after the _root adjacency fanout enabled by substring match, exceeds plausibly should serve 3 sponsored cards but returns 30. The compound-keyword policy is supposed to keep matched-terms slicing to ≤3 without gaming the take 3 call. If the take 3 is doing all the work and the pre-slice set is uniformly huge, the vocabulary is not doing its job.
  5. The 15 browser-adtech-infra/*/spec.org files remain absent from search-index.json after the re-index step in Phase 5. If the indexer refuses spec files by design, then the adtech:* vocabulary is registering terms that never reach matched-terms — the facet documents system components the search engine can't see. In that case, either the indexer needs to include spec files (preferred) or the adtech:* facet needs to be limited to the 1 index doc + 9 creatives + the tracker/beacon/plates specs that DO reach the index.

8. See also