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 foradtech:sponsored-display(industry term for the taboola/outbrain box format; usechumboxin prose but tag asadtech:sponsored-display)ab-engine,ab-testing,bucketing,fnv1a→project:ab-engine(belongs toproject:*notadtech:*— it's a library that powers the adtech surface but is independently valuable)search-mount→adtech:pocket-es(search-mount is the DOM adapter that hosts interleave; naming it separately splits chip mass without gaining discrimination)sponsored-onboarding→adtech:creative-ops(onboarding is a lifecycle phase, not a distinct facet)web-history→domain: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:
- The compound keyword
local-firstMATCHES query tokenlocal(k.includes(t)— the keyword contains the token). - Query
servingMATCHES keywordsponsored-serving(k.includes(t)). - Query
sponsoredMATCHES keywordsponsored-research,sponsored-display,sponsored-onboarding— one query fans out to every sponsored keyword variant. - Query
researchMATCHES keywordsponsored-research(t.includes(k)is not needed here;k.includes(t)handles theresearchsubstring insidesponsored-research). - Query
botwould MATCH keywordrobot(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-contractdef con 18co-located eventhypersensitivitycontext-repositoryprofessional developmentopen-source-ai-advocacyhigh performance computingcodex-desktop-linuxtrust 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:#+KEYWORDScarries free-text signal,#+FILETAGScarries the controlled vocabulary. Add alongside; never overwrite. - Do NOT strip
botorrobot— both are load-bearing and the bidirectional match is intended (see worked example above). - Do NOT rename
pocket-estoadtech:pocket-es— thepocket-eskeyword is the search-chip identifier for 12 docs. Addadtech:pocket-esas an additional#+FILETAGSentry 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:
- The
adtechfacet, 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 toproject:*entries and retire the facet.) - Any of the compound-keyword rules produces a
matched-termsresult 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.) - 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.)
- Any single query token, after the
_rootadjacency fanout enabled by substring match, exceedsplausibly should serve 3 sponsored cards but returns 30. The compound-keyword policy is supposed to keepmatched-termsslicing to ≤3 without gaming thetake 3call. If thetake 3is doing all the work and the pre-slice set is uniformly huge, the vocabulary is not doing its job. - The 15
browser-adtech-infra/*/spec.orgfiles remain absent fromsearch-index.jsonafter the re-index step in Phase 5. If the indexer refuses spec files by design, then theadtech:*vocabulary is registering terms that never reachmatched-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 theadtech:*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
- Controlled Vocabulary v1 — the v1 that this file extends
- 2026-keyword-vocabulary-convergence — the convergence method these vocabularies target
- v1.1 consolidation run — 128 canonical terms from gemini-2.5-flash, not yet applied
- sponsored_research/core.cljc — the
matched-termsreference impl - adtech-contracts-first-process — the eight-step recipe every adtech feature crosses
- sponsored-research/spec.org — the V-final contract that requires non-empty
matched-terms - nomenclature-audit.org — L7 audit that flagged the adtech naming drift this file responds to