From cf1e7367ed67f726b9572eee35370add7ecfb2c4 Mon Sep 17 00:00:00 2001 From: jacob-web10-nyc Date: Fri, 28 Aug 2026 08:24:54 -0400 Subject: [PATCH] =?UTF-8?q?feature(sdk)=20+=20docs(kb):=20curateAds=20?= =?UTF-8?q?=E2=80=94=20the=20D51=20ad-dissemination=20helper=20(3.26.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-creator choice of how their ads get mixed into a viewer's feed is a shared, deterministic SDK helper, not SQL. sdk/src/curate.ts exports curateAds(creatorAds, setting, state) → the ordered active subset to show, with four modes: round_robin (rotate past lastShown), pinned (the creator's pick), frequency_capped (drop ads shown cap× this session), and greedy (order by performance). Filters on body.status (showable unless paused). Canonical doc_id ordering makes the output independent of input order (pinned by a determinism test). The v0 greedy gap (operator-surfaced): greedy weights by offer performance, but the v0 ad object carries no stats (D55). So with no performance supplied, greedy degrades to round_robin (equal exposure — no ad starved) and becomes true greedy the instant the v4 metrics layer feeds performance in. KB ads.md Dissemination section updated (signature + the degradation note). 110 SDK tests green (22 new), typecheck + build clean. --- knowledge/changelogs/CHANGELOG.md | 3 + .../knowledge-base/web10-v3/social/ads.md | 4 +- knowledge/strategy/parallel-execution.md | 2 +- knowledge/strategy/plan.md | 2 +- sdk/dist/curate.d.ts | 67 +++++++ sdk/dist/curate.d.ts.map | 1 + sdk/dist/index.d.ts | 1 + sdk/dist/index.d.ts.map | 2 +- sdk/dist/index.js | 51 ++++- sdk/dist/v3.d.ts.map | 2 +- sdk/src/curate.test.ts | 175 ++++++++++++++++++ sdk/src/curate.ts | 125 +++++++++++++ sdk/src/index.ts | 9 + 13 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 sdk/dist/curate.d.ts create mode 100644 sdk/dist/curate.d.ts.map create mode 100644 sdk/src/curate.test.ts create mode 100644 sdk/src/curate.ts diff --git a/knowledge/changelogs/CHANGELOG.md b/knowledge/changelogs/CHANGELOG.md index 4bc76874..f1f26e72 100644 --- a/knowledge/changelogs/CHANGELOG.md +++ b/knowledge/changelogs/CHANGELOG.md @@ -1,3 +1,6 @@ +3.26.0 || 27.08.2026 +feature(sdk) + docs(kb): `curateAds` — the D51 ad-dissemination helper (the ads lane's second foundation). The per-creator choice of how their ads get mixed into a viewer's feed is a shared, **deterministic** SDK helper, not SQL: the stateful algorithms (round-robin needs "which ad showed last," greedy needs performance numbers) don't belong in a query, and being pure + shared means every app curates a given creator's ads identically from the same inputs. `sdk/src/curate.ts` exports `curateAds(creatorAds, setting, state)` → the ordered **active** subset to show (the caller takes the first for a single post, or the whole list for a curated-subset surface). Four modes: `round_robin` (rotate past `lastShownDocId`, wrap-around), `pinned` (just the creator's pick), `frequency_capped` (drop ads already shown `cap`× this session), and `greedy` (order by performance, highest first). Filters on `body.status` (an ad is showable unless `paused` — status defaults to `active`). The active set is canonically ordered by `doc_id` first, so the output is independent of the order the ads arrive in — pinned by a determinism test. **The v0 `greedy` gap (operator-surfaced):** `greedy` weights by offer performance, but the v0 ad object carries no `stats` (D55 — a counter is a write on a read path; impression/revenue verification is the v4 layer). So with no `performance` supplied, `greedy` **degrades to `round_robin`** (equal exposure — no ad starved) and becomes true greedy the instant the v4 metrics layer feeds per-ad scores into `state.performance`. `round_robin` / `pinned` / `frequency_capped` need no metrics (app-local state + the creator's pick). KB: `ads.md` Dissemination section updated (the `curateAds` signature gains `state`; the v0 `greedy` degradation is documented so it's not a silent lie). 110 SDK tests green (22 new), typecheck + build clean. + 3.25.0 || 27.08.2026 test(api): the tagged-post ad conformance floor (D55) — the ads lane's foundation. An ad is a `posts` doc tagged `ad` (a leaf-typed `offer` + a `status`), not a service — so the API has zero ad-specific branches, and the conformance is that the ad post is indistinguishable from a post except its tag + body fields. Pinned by `api/tests/test_ads.py` (5 tests): (1) the ad post is created through the existing `/v3/create` on `posts` (no `ads` service, no new endpoint) with `tags=["ad"]` + the leaf-typed `offer` + `status` in the body, attached to the creator's followers group; (2) the feed read (`read_documents_in_groups` over the followers group) returns it interleaved with normal posts — same shape, same keys, the ad fields are the only delta; (3) I3 — a non-follower is an access failure (D42 403) and the document query never runs, so the ad is never returned; (4) `status` is a plain body field the read does NOT filter — a paused ad comes back exactly like an active one (curation + the renderer filter client-side, D51). Plus a SQL-level pin: the feed read query selects the `tags` column but filters on neither tags nor status (`status` is a body field, not a column — it cannot be filtered in SQL at all). No production code changed — the API already treats ad posts as posts (verified: no `offer` / `ad`-tag branches in `api/app/v3/`). 795 API tests green, ruff clean. Gates the catalog + composer (both read this). diff --git a/knowledge/knowledge-base/web10-v3/social/ads.md b/knowledge/knowledge-base/web10-v3/social/ads.md index 7cdbdc5c..3841eb59 100644 --- a/knowledge/knowledge-base/web10-v3/social/ads.md +++ b/knowledge/knowledge-base/web10-v3/social/ads.md @@ -103,7 +103,7 @@ How a creator's ads get shown is a **per-creator choice**, not a platform decisi The curation is a **shared, deterministic SDK helper**, not SQL (the stateful algorithms — round-robin needs "which ad showed last," greedy needs the performance numbers — do not belong in a query): ``` -curateAds(creatorAds, creatorSetting) → the ordered subset to show +curateAds(creatorAds, creatorSetting, state) → the ordered active subset to show ``` - **round_robin** — rotate the creator's active ads so each gets equal exposure (state in the app: memory/localStorage) @@ -113,6 +113,8 @@ curateAds(creatorAds, creatorSetting) → the ordered subset to show The helper filters on `status === 'active'`. Because it is shared and deterministic, every app curates a given creator's ads identically. It is used where a creator's ads are *selected* — the composer's "Rotate my ads" (which ad a post carries, `ads-catalog.md`) and any app surfacing a curated subset. The feed's ad *posts* need no curation: they are posts, they render, 100% delivery by architecture. +**v0 note — `greedy` has no data yet.** `greedy` weights by offer performance (clicks/conversions), but the v0 ad object carries no `stats` (above — a counter is a write on a read path; revenue settlement + impression verification are the v4 layer). So in v0, `curateAds` degrades `greedy` to `round_robin` (equal exposure) whenever no `performance` is supplied: no ad is starved, and the mode becomes true greedy the instant the v4 metrics layer feeds per-ad scores into the helper's `state`. The other three modes need no metrics at all — `round_robin` and `frequency_capped` run on app-local state (last-shown / session counts), and `pinned` is just the creator's pick. + ## The Partner Links UI (the ingest) The Studio's monetization screen has one card for this: **Partner Links** (it was "Amazon Associates" + "Direct Deals" — collapsed, because they are the same primitive: a link that pays the creator). The card is the ingest: diff --git a/knowledge/strategy/parallel-execution.md b/knowledge/strategy/parallel-execution.md index 1600caa4..d1764628 100644 --- a/knowledge/strategy/parallel-execution.md +++ b/knowledge/strategy/parallel-execution.md @@ -270,7 +270,7 @@ object + feed read + dissemination) and `social/ads-catalog.md` (the catalog - [✓ 3.16.1] KB: the standard ad object + the per-user query + the Dissemination section (per-creator setting + feed+ads join + `curateAds` SDK helper) + the two-layer note (`social/ads.md`) + D50 + D51 - [✓ 3.22.0] KB: the Ad Catalog + the composer integration (`social/ads-catalog.md`) + D54 — catalog = the canonical per-viewer read run by the owner (no special-case); post carries ad by `ref_value` (no copy); `body.status` = `active` | `paused`; round-robin = D51 setting at render time; no new tables/endpoints/SDK surface - [✓ 3.23.0] KB: D55 — an ad is a `posts` doc tagged `ad`, not a service (`social/ads.md` rewritten + `ads-catalog.md` updated) — the feed join + the `ads: [readAll]` contract + the provisioning all disappear; the object is locked (post fields + leaf-typed `offer` + `status`, no `stats`, no `creative.format`); the creative is data + the HTML is the app's renderer (no `html` leaf type); `html_template` is v4; carrying is post → post -- [ ] Dissemination (SDK): the `curateAds(creatorAds, creatorSetting)` helper — `round_robin` / `greedy` / `pinned` / `frequency_capped`, deterministic + per-creator so every app curates identically; the per-creator setting is a field on the `settings` doc; filters on `body.status === 'active'` (D54) +- [✓ 3.26.0] Dissemination (SDK): the `curateAds(creatorAds, creatorSetting, state)` helper — `round_robin` / `greedy` / `pinned` / `frequency_capped`, deterministic + per-creator so every app curates identically; the per-creator setting is a field on the `settings` doc; filters on `body.status === 'active'` (D54). v0: `greedy` degrades to `round_robin` (no `stats` in the ad object — D55) until the v4 metrics layer feeds `performance` in - [ ] Partner Links card (UI): collapse "Amazon Associates" (`AmazonTagCard.tsx`) + "Direct Deals" (`DirectDealsCard.tsx`) into one "Partner Links" card in the Studio monetization screen — `offer.kind` = `affiliate` | `direct` | `own_store` + the dissemination picker; update `studio-data.ts` + `studio.test.tsx` - [✓ 3.25.0] The tagged-post ad (API conformance): the ad object (a `posts` doc tagged `ad` with the `offer` + `status`) through the existing posts CRUD + the feed read returns it + I3 (a non-follower can't read the ad post) — no service to provision, no new endpoint; verify + pin with `api/tests/test_ads.py`. Gates the catalog + composer (both read this) - [ ] Ad Catalog (authenticator): the Studio's inventory screen — the owner's posts filtered to `tags ∋ 'ad'` (creative / offer / status / attached posts via reverse `ref_value`), new-ad ingest flow (offer + content → one `posts` doc tagged `ad` on the followers group), edit / pause / retire, all states designed (empty → CTA, skeleton, error, active/paused rows). `ui/src/components/Studio/` diff --git a/knowledge/strategy/plan.md b/knowledge/strategy/plan.md index 98c03fdc..a7e90939 100644 --- a/knowledge/strategy/plan.md +++ b/knowledge/strategy/plan.md @@ -216,7 +216,7 @@ the ad object + feed read + dissemination are in `social/ads.md` (D50 + D51 - [✓ 3.23.0] **Decision: D55** (`knowledge/strategy/decisions.md`) — an ad is a `posts` doc tagged `ad`, not a service (supersedes D50's service framing): the feed join + the `ads: [readAll]` contract + the provisioning all disappear; the object is locked (post fields + leaf-typed `offer` + `status`, no `stats`, no `creative.format`); the creative is data + the HTML is the app's renderer (no `html` leaf type); `html_template` is v4; carrying is post → post. - [✓ 3.23.0] **KB** (`knowledge-base/web10-v3/social/ads.md` rewritten + `ads-catalog.md` updated) — the tagged-post model: why a post not a service, the locked ad object, the creative-is-data/HTML-is-the-app split + the v4 `html_template` escape hatch, the data-model map, the feed read as the ad read, D51 dissemination re-scoped (curation selects; the feed's ad posts just render), the two-layer note. - [✓ 3.25.0] **Foundation: the tagged-post ad conformance** — the ad object (a `posts` doc tagged `ad` with the `offer` + `status`) through the existing posts CRUD + the feed read returns it + I3 (a non-follower can't read the ad post), pinned by `api/tests/test_ads.py`. No service to provision. The catalog and the composer both read this, so it gates both surfaces. -- [ ] **Foundation: `curateAds` (SDK)** — the D51 helper (`round_robin` / `greedy` / `pinned` / `frequency_capped`), deterministic + per-creator, filters on `body.status === 'active'`. The composer's rotation + any curated-subset surface calls it. +- [✓ 3.26.0] **Foundation: `curateAds` (SDK)** — the D51 helper (`round_robin` / `greedy` / `pinned` / `frequency_capped`), deterministic + per-creator, filters on `body.status === 'active'`. The composer's rotation + any curated-subset surface calls it. - [ ] **Ad Catalog (authenticator)** — the Studio's inventory screen: the owner's posts filtered to `tags ∋ 'ad'` (creative / offer / status / attached posts via reverse `ref_value`), new-ad ingest flow (offer + content → one `posts` doc tagged `ad` on the followers group), edit / pause / retire, all states designed (empty → CTA to ingest, skeleton, error, active/paused rows). `ui/src/components/Studio/`. - [ ] **Composer ad control (web10-social)** — the "Attach ad" control in `PostComposer`: the catalog picker sheet (active first, empty + loading states), attach writes the post's `ref` → `ref_value` (post → post), the ad block renders under the post (creative + offer + disclosure, disclosure never hidden), "Rotate my ads" per-post opt-in to the D51 setting. `marketing/web10-social/src/components/Feed/`. - [ ] **E2E** — the torture gauntlet: create ad in the catalog → attach to a post → follower sees the post with the ad block + disclosure → pause the ad → it stops rendering → non-follower never sees the ad (I3). `e2e/tests/ads.spec.ts`. diff --git a/sdk/dist/curate.d.ts b/sdk/dist/curate.d.ts new file mode 100644 index 00000000..317f181e --- /dev/null +++ b/sdk/dist/curate.d.ts @@ -0,0 +1,67 @@ +/** + * curateAds — the shared, deterministic D51 curation helper. + * + * How a creator's ads get mixed into a viewer's feed is a per-creator choice + * (D51), not a platform decision. The curation is a pure function, not SQL: + * the stateful algorithms (round-robin needs "which ad showed last," greedy + * needs performance numbers) do not belong in a query. Because the helper is + * shared and deterministic, every app curates a given creator's ads + * identically from the same inputs. + * + * KB: knowledge/knowledge-base/web10-v3/social/ads.md (the Dissemination + * section) + ads-catalog.md. + */ +/** The D51 dissemination modes — a per-creator choice on the `settings` doc. */ +export type DisseminationMode = 'round_robin' | 'greedy' | 'pinned' | 'frequency_capped'; +/** The creator's ad-dissemination setting (normalized from the `settings` doc). */ +export interface AdDisseminationSetting { + dissemination: DisseminationMode; + /** `frequency_capped`: max times the same ad shows per session. */ + cap?: number; + /** `pinned`: the doc_id of the ad that is live. */ + pinnedDocId?: string; +} +/** + * App-local curation state (memory / localStorage) — the stateful + * algorithms' memory. Passed in so the helper stays a pure function. + */ +export interface CurationState { + /** `round_robin` (and the `greedy` fallback): the doc_id shown last. */ + lastShownDocId?: string; + /** `frequency_capped`: how many times each ad has shown this session. */ + shownCounts?: Record; + /** + * `greedy`: per-ad performance score (doc_id → number). v0 supplies none — + * the ad object carries no `stats` (D55) — so `greedy` degrades to + * `round_robin` until the v4 metrics layer feeds this in. + */ + performance?: Record; +} +/** An ad post as the feed read returns it. Only `doc_id` + `body.status` are needed. */ +export interface CuratableAd { + doc_id: string; + body?: Record; +} +/** + * Given a creator's ad posts + their dissemination setting + app-local state, + * return the ordered subset of ACTIVE ads to show. The caller takes what it + * needs — the first entry for a single post (the composer's "Rotate my ads"), + * or the whole list for a curated-subset surface. + * + * Filters on `body.status`: an ad is showable unless it is `paused` (status + * defaults to `active`, per the locked ad object). + * + * Modes: + * - `round_robin` — rotate the active ads (start after `lastShownDocId`) so + * each gets equal exposure. + * - `greedy` — order by `performance` (highest first). v0 has no performance + * metrics, so with no `performance` supplied it degrades to `round_robin`. + * - `pinned` — return just the pinned ad (if active). + * - `frequency_capped` — drop ads already shown `cap`× this session. + * + * Deterministic: the result depends only on the inputs. The active set is + * canonically ordered by `doc_id` first, so the output is independent of the + * order the ads happened to arrive in. + */ +export declare function curateAds(creatorAds: T[], setting: AdDisseminationSetting, state?: CurationState): T[]; +//# sourceMappingURL=curate.d.ts.map \ No newline at end of file diff --git a/sdk/dist/curate.d.ts.map b/sdk/dist/curate.d.ts.map new file mode 100644 index 00000000..efd9ead2 --- /dev/null +++ b/sdk/dist/curate.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"curate.d.ts","sourceRoot":"","sources":["../src/curate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,gFAAgF;AAChF,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,QAAQ,GAAG,QAAQ,GAAG,kBAAkB,CAAA;AAExF,mFAAmF;AACnF,MAAM,WAAW,sBAAsB;IACrC,aAAa,EAAE,iBAAiB,CAAA;IAChC,mEAAmE;IACnE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACrC;AAED,wFAAwF;AACxF,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,WAAW,EAC7C,UAAU,EAAE,CAAC,EAAE,EACf,OAAO,EAAE,sBAAsB,EAC/B,KAAK,GAAE,aAAkB,GACxB,CAAC,EAAE,CAsCL"} \ No newline at end of file diff --git a/sdk/dist/index.d.ts b/sdk/dist/index.d.ts index 210218d5..939f5613 100644 --- a/sdk/dist/index.d.ts +++ b/sdk/dist/index.d.ts @@ -9,6 +9,7 @@ export { createV3Client, type V3Client } from './v3'; export type { V3ClientOptions, V3Document, V3Group, V3GroupMember, V3InviteResponse, V3JoinRequest, V3ServiceContract, V3CR, V3AppCR, V3GroupCR, V3GroupRole, V3GroupMemberCR, V3User, V3LoginResponse, } from './v3'; export { cookieDict, readTokenCookie, setTokenCookie, scrubTokenCookie, decodeJwt, isTokenExpired, } from './token'; +export { curateAds, type AdDisseminationSetting, type CurationState, type CuratableAd, type DisseminationMode, } from './curate'; export { Web10Error } from './http'; export type { TokenPayload } from './types'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/sdk/dist/index.d.ts.map b/sdk/dist/index.d.ts.map index c3cceffb..a95ba460 100644 --- a/sdk/dist/index.d.ts.map +++ b/sdk/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,MAAM,CAAA;AACpD,YAAY,EACV,eAAe,EACf,UAAU,EACV,OAAO,EACP,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,IAAI,EACJ,OAAO,EACP,SAAS,EACT,WAAW,EACX,eAAe,EACf,MAAM,EACN,eAAe,GAChB,MAAM,MAAM,CAAA;AAGb,OAAO,EACL,UAAU,EACV,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,SAAS,EACT,cAAc,GACf,MAAM,SAAS,CAAA;AAGhB,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AAGnC,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,MAAM,CAAA;AACpD,YAAY,EACV,eAAe,EACf,UAAU,EACV,OAAO,EACP,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,IAAI,EACJ,OAAO,EACP,SAAS,EACT,WAAW,EACX,eAAe,EACf,MAAM,EACN,eAAe,GAChB,MAAM,MAAM,CAAA;AAGb,OAAO,EACL,UAAU,EACV,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,SAAS,EACT,cAAc,GACf,MAAM,SAAS,CAAA;AAGhB,OAAO,EACL,SAAS,EACT,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,iBAAiB,GACvB,MAAM,UAAU,CAAA;AAGjB,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AAGnC,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA"} \ No newline at end of file diff --git a/sdk/dist/index.js b/sdk/dist/index.js index 152086db..94c971d5 100644 --- a/sdk/dist/index.js +++ b/sdk/dist/index.js @@ -106,7 +106,9 @@ function createV3Client(options = {}) { return; try { const token = state.token ?? readTokenCookie(); - const body = { url: window.location.href.split(/[?#]/)[0] }; + const rawUrl = window.location.href.split(/[?#]/)[0]; + const url = rawUrl.replace(/\/index\.html$/, "/"); + const body = { url }; if (token) body.token = token; fetch(`${apiOrigin}/v3/apps/register`, { @@ -420,12 +422,59 @@ function createV3Client(options = {}) { pingAppRegister(); return client; } +// src/curate.ts +function curateAds(creatorAds, setting, state = {}) { + const active = creatorAds.filter((ad) => (ad.body?.status ?? "active") !== "paused"); + if (active.length === 0) + return []; + const canonical = [...active].sort((a, b) => compareDocIds(a.doc_id, b.doc_id)); + switch (setting.dissemination) { + case "pinned": { + const pinned = canonical.find((ad) => ad.doc_id === setting.pinnedDocId); + return pinned ? [pinned] : []; + } + case "frequency_capped": { + const cap = setting.cap ?? Number.POSITIVE_INFINITY; + const counts = state.shownCounts ?? {}; + return canonical.filter((ad) => (counts[ad.doc_id] ?? 0) < cap); + } + case "greedy": { + const perf = state.performance; + const hasPerf = perf !== undefined && Object.keys(perf).length > 0; + if (!hasPerf) { + return rotate(canonical, state.lastShownDocId); + } + return [...canonical].sort((a, b) => { + const pa = perf[a.doc_id] ?? 0; + const pb = perf[b.doc_id] ?? 0; + if (pb !== pa) + return pb - pa; + return compareDocIds(a.doc_id, b.doc_id); + }); + } + case "round_robin": + default: + return rotate(canonical, state.lastShownDocId); + } +} +function rotate(list, lastShownDocId) { + if (!lastShownDocId || list.length === 0) + return list; + const idx = list.findIndex((ad) => ad.doc_id === lastShownDocId); + if (idx === -1) + return list; + return list.slice(idx + 1).concat(list.slice(0, idx + 1)); +} +function compareDocIds(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} export { setTokenCookie, scrubTokenCookie, readTokenCookie, isTokenExpired, decodeJwt, + curateAds, createV3Client, cookieDict, Web10Error diff --git a/sdk/dist/v3.d.ts.map b/sdk/dist/v3.d.ts.map index 2eca9e95..053535a9 100644 --- a/sdk/dist/v3.d.ts.map +++ b/sdk/dist/v3.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"v3.d.ts","sourceRoot":"","sources":["../src/v3.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"v3.d.ts","sourceRoot":"","sources":["../src/v3.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/sdk/src/curate.test.ts b/sdk/src/curate.test.ts new file mode 100644 index 00000000..0de4be13 --- /dev/null +++ b/sdk/src/curate.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest' +import { curateAds, type CuratableAd } from './curate' + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function ad(doc_id: string, status?: string): CuratableAd { + return status === undefined ? { doc_id } : { doc_id, body: { status } } +} + +// ── Active filter ────────────────────────────────────────────────────────── + +describe('curateAds — active filter', () => { + it('drops paused ads', () => { + const out = curateAds([ad('a', 'active'), ad('b', 'paused'), ad('c', 'active')], { + dissemination: 'round_robin', + }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'c']) + }) + + it('treats a missing status as active (default)', () => { + const out = curateAds([ad('a'), ad('b', 'paused')], { dissemination: 'round_robin' }) + expect(out.map((a) => a.doc_id)).toEqual(['a']) + }) + + it('returns empty when every ad is paused', () => { + const out = curateAds([ad('a', 'paused'), ad('b', 'paused')], { dissemination: 'round_robin' }) + expect(out).toEqual([]) + }) + + it('returns empty for no ads', () => { + expect(curateAds([], { dissemination: 'round_robin' })).toEqual([]) + }) +}) + +// ── round_robin ──────────────────────────────────────────────────────────── + +describe('curateAds — round_robin', () => { + it('returns all active ads in canonical order with no prior state', () => { + // input order is scrambled; canonical (doc_id) order wins + const out = curateAds([ad('c'), ad('a'), ad('b')], { dissemination: 'round_robin' }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b', 'c']) + }) + + it('rotates so the ad after lastShown comes first', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'round_robin' }, { + lastShownDocId: 'a', + }) + expect(out.map((a) => a.doc_id)).toEqual(['b', 'c', 'a']) + }) + + it('wraps around when the last ad was shown', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'round_robin' }, { + lastShownDocId: 'c', + }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b', 'c']) + }) + + it('ignores a lastShownDocId that is not in the active set', () => { + const out = curateAds([ad('a'), ad('b')], { dissemination: 'round_robin' }, { + lastShownDocId: 'zzz', + }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b']) + }) +}) + +// ── greedy ───────────────────────────────────────────────────────────────── + +describe('curateAds — greedy', () => { + it('orders by performance (highest first) when performance is supplied', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'greedy' }, { + performance: { a: 1, b: 9, c: 5 }, + }) + expect(out.map((a) => a.doc_id)).toEqual(['b', 'c', 'a']) + }) + + it('tie-breaks deterministically by doc_id', () => { + const out = curateAds([ad('c'), ad('a'), ad('b')], { dissemination: 'greedy' }, { + performance: { a: 5, b: 5, c: 5 }, + }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b', 'c']) + }) + + it('treats a missing performance score as 0', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'greedy' }, { + performance: { b: 3 }, + }) + // b (3) first; a and c are 0 → doc_id order + expect(out.map((a) => a.doc_id)).toEqual(['b', 'a', 'c']) + }) + + it('degrades to round_robin when no performance is supplied (v0 — no stats)', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'greedy' }, { + lastShownDocId: 'a', + }) + // same as round_robin with lastShown 'a' + expect(out.map((a) => a.doc_id)).toEqual(['b', 'c', 'a']) + }) + + it('degrades to canonical order when no performance and no lastShown', () => { + const out = curateAds([ad('c'), ad('a'), ad('b')], { dissemination: 'greedy' }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b', 'c']) + }) +}) + +// ── pinned ───────────────────────────────────────────────────────────────── + +describe('curateAds — pinned', () => { + it('returns only the pinned ad', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { + dissemination: 'pinned', + pinnedDocId: 'b', + }) + expect(out.map((a) => a.doc_id)).toEqual(['b']) + }) + + it('returns empty when the pinned ad is paused', () => { + const out = curateAds([ad('a'), ad('b', 'paused')], { dissemination: 'pinned', pinnedDocId: 'b' }) + expect(out).toEqual([]) + }) + + it('returns empty when no pinnedDocId is set', () => { + const out = curateAds([ad('a'), ad('b')], { dissemination: 'pinned' }) + expect(out).toEqual([]) + }) + + it('returns empty when the pinned ad is not in the set', () => { + const out = curateAds([ad('a'), ad('b')], { dissemination: 'pinned', pinnedDocId: 'zzz' }) + expect(out).toEqual([]) + }) +}) + +// ── frequency_capped ─────────────────────────────────────────────────────── + +describe('curateAds — frequency_capped', () => { + it('drops ads already shown cap times this session', () => { + const out = curateAds([ad('a'), ad('b'), ad('c')], { dissemination: 'frequency_capped', cap: 2 }, { + shownCounts: { a: 2, b: 1 }, + }) + // a hit the cap (2); b (1) and c (0) remain + expect(out.map((a) => a.doc_id)).toEqual(['b', 'c']) + }) + + it('keeps every active ad when no cap is set', () => { + const out = curateAds([ad('a'), ad('b')], { dissemination: 'frequency_capped' }, { + shownCounts: { a: 99 }, + }) + expect(out.map((a) => a.doc_id)).toEqual(['a', 'b']) + }) + + it('still drops paused ads', () => { + const out = curateAds([ad('a'), ad('b', 'paused')], { dissemination: 'frequency_capped', cap: 5 }) + expect(out.map((a) => a.doc_id)).toEqual(['a']) + }) +}) + +// ── Determinism ──────────────────────────────────────────────────────────── + +describe('curateAds — determinism', () => { + it('returns the same output for the same inputs', () => { + const ads = [ad('a'), ad('b'), ad('c')] + const setting = { dissemination: 'round_robin' as const } + const state = { lastShownDocId: 'b' } + expect(curateAds(ads, setting, state).map((a) => a.doc_id)).toEqual( + curateAds(ads, setting, state).map((a) => a.doc_id), + ) + }) + + it('is independent of the order the ads arrive in', () => { + const setting = { dissemination: 'round_robin' as const } + const state = { lastShownDocId: 'a' } + const forward = curateAds([ad('a'), ad('b'), ad('c')], setting, state).map((a) => a.doc_id) + const backward = curateAds([ad('c'), ad('b'), ad('a')], setting, state).map((a) => a.doc_id) + expect(forward).toEqual(backward) + }) +}) diff --git a/sdk/src/curate.ts b/sdk/src/curate.ts new file mode 100644 index 00000000..52450d90 --- /dev/null +++ b/sdk/src/curate.ts @@ -0,0 +1,125 @@ +/** + * curateAds — the shared, deterministic D51 curation helper. + * + * How a creator's ads get mixed into a viewer's feed is a per-creator choice + * (D51), not a platform decision. The curation is a pure function, not SQL: + * the stateful algorithms (round-robin needs "which ad showed last," greedy + * needs performance numbers) do not belong in a query. Because the helper is + * shared and deterministic, every app curates a given creator's ads + * identically from the same inputs. + * + * KB: knowledge/knowledge-base/web10-v3/social/ads.md (the Dissemination + * section) + ads-catalog.md. + */ + +/** The D51 dissemination modes — a per-creator choice on the `settings` doc. */ +export type DisseminationMode = 'round_robin' | 'greedy' | 'pinned' | 'frequency_capped' + +/** The creator's ad-dissemination setting (normalized from the `settings` doc). */ +export interface AdDisseminationSetting { + dissemination: DisseminationMode + /** `frequency_capped`: max times the same ad shows per session. */ + cap?: number + /** `pinned`: the doc_id of the ad that is live. */ + pinnedDocId?: string +} + +/** + * App-local curation state (memory / localStorage) — the stateful + * algorithms' memory. Passed in so the helper stays a pure function. + */ +export interface CurationState { + /** `round_robin` (and the `greedy` fallback): the doc_id shown last. */ + lastShownDocId?: string + /** `frequency_capped`: how many times each ad has shown this session. */ + shownCounts?: Record + /** + * `greedy`: per-ad performance score (doc_id → number). v0 supplies none — + * the ad object carries no `stats` (D55) — so `greedy` degrades to + * `round_robin` until the v4 metrics layer feeds this in. + */ + performance?: Record +} + +/** An ad post as the feed read returns it. Only `doc_id` + `body.status` are needed. */ +export interface CuratableAd { + doc_id: string + body?: Record +} + +/** + * Given a creator's ad posts + their dissemination setting + app-local state, + * return the ordered subset of ACTIVE ads to show. The caller takes what it + * needs — the first entry for a single post (the composer's "Rotate my ads"), + * or the whole list for a curated-subset surface. + * + * Filters on `body.status`: an ad is showable unless it is `paused` (status + * defaults to `active`, per the locked ad object). + * + * Modes: + * - `round_robin` — rotate the active ads (start after `lastShownDocId`) so + * each gets equal exposure. + * - `greedy` — order by `performance` (highest first). v0 has no performance + * metrics, so with no `performance` supplied it degrades to `round_robin`. + * - `pinned` — return just the pinned ad (if active). + * - `frequency_capped` — drop ads already shown `cap`× this session. + * + * Deterministic: the result depends only on the inputs. The active set is + * canonically ordered by `doc_id` first, so the output is independent of the + * order the ads happened to arrive in. + */ +export function curateAds( + creatorAds: T[], + setting: AdDisseminationSetting, + state: CurationState = {}, +): T[] { + // 1. Active only — status defaults to active; only an explicit 'paused' hides. + const active = creatorAds.filter((ad) => (ad.body?.status ?? 'active') !== 'paused') + if (active.length === 0) return [] + + // Canonical order (doc_id) so the result is independent of input order. + const canonical = [...active].sort((a, b) => compareDocIds(a.doc_id, b.doc_id)) + + switch (setting.dissemination) { + case 'pinned': { + const pinned = canonical.find((ad) => ad.doc_id === setting.pinnedDocId) + return pinned ? [pinned] : [] + } + case 'frequency_capped': { + const cap = setting.cap ?? Number.POSITIVE_INFINITY + const counts = state.shownCounts ?? {} + return canonical.filter((ad) => (counts[ad.doc_id] ?? 0) < cap) + } + case 'greedy': { + const perf = state.performance + const hasPerf = perf !== undefined && Object.keys(perf).length > 0 + if (!hasPerf) { + // v0: no performance metrics (D55 — no stats in the ad object) → + // degrade to round_robin so no ad is starved. True greedy the moment + // the v4 metrics layer feeds `performance` in. + return rotate(canonical, state.lastShownDocId) + } + return [...canonical].sort((a, b) => { + const pa = perf[a.doc_id] ?? 0 + const pb = perf[b.doc_id] ?? 0 + if (pb !== pa) return pb - pa // higher performance first + return compareDocIds(a.doc_id, b.doc_id) // deterministic tie-break + }) + } + case 'round_robin': + default: + return rotate(canonical, state.lastShownDocId) + } +} + +/** Rotate `list` so the entry after `lastShownDocId` comes first (wrap-around). */ +function rotate(list: T[], lastShownDocId?: string): T[] { + if (!lastShownDocId || list.length === 0) return list + const idx = list.findIndex((ad) => ad.doc_id === lastShownDocId) + if (idx === -1) return list + return list.slice(idx + 1).concat(list.slice(0, idx + 1)) +} + +function compareDocIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 5ac8aaea..440dcf0f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -36,6 +36,15 @@ export { isTokenExpired, } from './token' +// Ad curation (D51) — the shared, deterministic dissemination helper +export { + curateAds, + type AdDisseminationSetting, + type CurationState, + type CuratableAd, + type DisseminationMode, +} from './curate' + // HTTP export { Web10Error } from './http'