From ee4d7469d99eae64028ce5a7f168ee03e552ec69 Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:49:35 -0700 Subject: [PATCH 1/6] [PROJ-1431] Build isolated shadow governance demo backend --- README.md | 4 +- docs/RECSYS_VALIDATION_EVIDENCE.md | 2 + docs/dev-journal.md | 100 ++ docs/lab/birders-feed-readiness.md | 139 +++ docs/lab/demo-shadow-governance-contract.md | 85 ++ docs/lab/open-science-demo-readiness.md | 50 + package.json | 1 + scripts/birders-feed-scout.ts | 69 ++ src/demo/appview.ts | 122 +++ src/demo/corpus.ts | 501 +++++++++ src/demo/public-view.ts | 168 ++++ src/demo/routes.ts | 258 +++++ src/demo/service.ts | 949 ++++++++++++++++++ src/demo/store.ts | 464 +++++++++ src/demo/synthetic-voters.ts | 250 +++++ src/demo/topic-intent.ts | 69 ++ src/demo/types.ts | 308 ++++++ src/demo/weights.ts | 198 ++++ src/feed/birders-scout-command.ts | 122 +++ src/feed/community-materializer.ts | 509 ++++++++++ src/feed/community-registry.ts | 167 +++ src/feed/rate-limit-config.ts | 120 +++ src/feed/routes/describe-generator.ts | 15 +- src/feed/routes/feed-skeleton.ts | 58 +- src/feed/server.ts | 116 +-- src/feed/snapshot-cache.ts | 240 +++-- src/governance/aggregation-math.ts | 47 + src/governance/aggregation.ts | 37 +- src/scoring/components/relevance.ts | 9 +- tests/birders-feed-materializer.test.ts | 331 ++++++ tests/birders-scout-command.test.ts | 79 ++ tests/demo-shadow-isolation.test.ts | 60 ++ tests/demo-shadow-public-view.test.ts | 242 +++++ tests/demo-shadow-routes.test.ts | 767 ++++++++++++++ tests/demo-shadow-store.test.ts | 112 +++ tests/demo-shadow-weights.test.ts | 232 +++++ tests/feed-community-registry.test.ts | 75 ++ tests/feed-skeleton-validation.test.ts | 72 ++ tests/rate-limit-config.test.ts | 43 +- tests/web-next-shadow-demo-contract.test.ts | 101 ++ web-next/app/demo/shadow-demo-contract.ts | 253 +++++ .../demo/shadow-demo-contract.type-test.ts | 46 + 42 files changed, 7361 insertions(+), 229 deletions(-) create mode 100644 docs/lab/birders-feed-readiness.md create mode 100644 docs/lab/demo-shadow-governance-contract.md create mode 100644 docs/lab/open-science-demo-readiness.md create mode 100644 scripts/birders-feed-scout.ts create mode 100644 src/demo/appview.ts create mode 100644 src/demo/corpus.ts create mode 100644 src/demo/public-view.ts create mode 100644 src/demo/routes.ts create mode 100644 src/demo/service.ts create mode 100644 src/demo/store.ts create mode 100644 src/demo/synthetic-voters.ts create mode 100644 src/demo/topic-intent.ts create mode 100644 src/demo/types.ts create mode 100644 src/demo/weights.ts create mode 100644 src/feed/birders-scout-command.ts create mode 100644 src/feed/community-materializer.ts create mode 100644 src/feed/community-registry.ts create mode 100644 src/feed/rate-limit-config.ts create mode 100644 src/governance/aggregation-math.ts create mode 100644 tests/birders-feed-materializer.test.ts create mode 100644 tests/birders-scout-command.test.ts create mode 100644 tests/demo-shadow-isolation.test.ts create mode 100644 tests/demo-shadow-public-view.test.ts create mode 100644 tests/demo-shadow-routes.test.ts create mode 100644 tests/demo-shadow-store.test.ts create mode 100644 tests/demo-shadow-weights.test.ts create mode 100644 tests/feed-community-registry.test.ts create mode 100644 tests/web-next-shadow-demo-contract.test.ts create mode 100644 web-next/app/demo/shadow-demo-contract.ts create mode 100644 web-next/app/demo/shadow-demo-contract.type-test.ts diff --git a/README.md b/README.md index ce961b82..0bcbc86b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ A production Bluesky custom feed where subscribers democratically vote on rankin - CLI tool (`feed-cli`): full admin operations from any terminal, no VPS access required - MCP server: Streamable HTTP admin tools for natural-language feed management - Public API reference at [docs.corgi.network](https://docs.corgi.network), auto-deployed from `docs/docs-site/` -- Admin Swagger UI at `/docs` (production-gated) +- Admin Swagger UI at `/api/docs` (production-gated); `/docs` is the public product documentation - Research data export: votes, scores, engagement, epochs, audit log (CSV/JSON) **Engineering** @@ -219,7 +219,6 @@ All 15 numeric values (5× raw, weight, weighted) are persisted per post per epo - `POST /xrpc/app.bsky.feed.sendInteractions` — See More/See Less signals - `GET /api/transparency/*` — Score explanations, stats, counterfactuals, audit log - `GET /health`, `/health/ready`, `/health/live` — Health checks -- `GET /docs` — OpenAPI / Swagger UI **Governance (session auth)** - `POST /api/governance/auth/login` — Bluesky handle + app password @@ -227,6 +226,7 @@ All 15 numeric values (5× raw, weight, weighted) are persisted per post per epo - `GET /api/governance/weights`, `/epochs`, `/content-rules` — Current governance state **Admin (session auth + DID allowlist)** +- `GET /api/docs` — OpenAPI / Swagger UI (admin-gated in production) - `/api/admin/status`, `/epochs`, `/governance/*` — Governance controls - `/api/admin/feed/*` — Feed health, rescore, Jetstream reconnect - `/api/admin/interactions/*` — Scroll depth, engagement, keyword analytics diff --git a/docs/RECSYS_VALIDATION_EVIDENCE.md b/docs/RECSYS_VALIDATION_EVIDENCE.md index 275e99d5..af2f4069 100644 --- a/docs/RECSYS_VALIDATION_EVIDENCE.md +++ b/docs/RECSYS_VALIDATION_EVIDENCE.md @@ -10,6 +10,8 @@ and blocked claims live in the lab journal above. ## Current Evidence +- Shadow reviewer demo contract: implemented PROJ-1431 backend routes and web contract for production-sourced, session-frozen shadow sessions; final deploy-parity `npm run verify` passed with 118 Vitest files / 1,065 tests plus root, SDK, CLI, legacy web, and Next static builds. The integrated HTTP client and test adapter pass 6 demo files / 26 frontend tests and completed a same-origin two-epoch browser run at desktop, tablet, and mobile widths with no console errors or horizontal overflow. The v2 electorate uses one reviewer plus 24 deterministic, history-aware synthetic voters and exercises 25-voter trimmed-mean receipt math. Redis records are validated through nested corpus/vote/epoch shapes, corpus-build leases renew during slow hydration, and malformed public AppView data fails closed. A 2026-07-10 read-only production query found 84,831 active-epoch scored Open Science Builders candidates from 46,652 authors over 72 hours, including 2,274 cross-topic posts. This supports the live-scored shadow corpus; it does not claim a published native Bluesky feed or real-human electorate validity. +- Birders feed readiness substrate: added disabled/private `birders_who_code` registry/materializer support, namespaced Redis feed keys, read-only scout command, and feed-rkey dispatch while preserving public `community-gov` behavior. Focused local verification passed for 5 Birders/feed files / 50 tests, and full `npm run verify` passed with 117 default Vitest files / 1045 tests plus root/web builds. A 2026-07-10 production read-only scout over the 72-hour active-epoch scored window found broad OR supply of 22,449 candidate posts (7,483/day), 12,935 authors (4,311.67/day), 1,649 strong bridge/high-relevance posts (549.67/day), and 3.44% top-author concentration; the stricter `bird_and_bridge` split found 162 posts (54/day), 136 authors (45.33/day), and 4.67 strict strong posts/day. This supports continued disabled-materializer work, not immediate public feed publication. - Full local verification gate: `npm run verify` passed on current `origin/main` base after refreshing `web` and `web-next` lockfile installs; this included root TypeScript build, 97 files / 840 Vitest tests, CLI build, MCP-local skip check, SDK build, SDK fixture, Vite lint/build, and Next static build. - Default regression suite: 97 files / 840 tests passed on current `origin/main` base with non-production dummy config and local IPC/loopback access. - Post-review closeout slice: 7 files / 88 tests passed after fixing the valid CodeRabbit major/minor findings for manifest claim semantics, background tracking DB-pool headroom, vote-load DB-pool sizing, `FEED_MAX_POSTS` bounds, subscriber digest config, by-ID snapshot cache eviction, soft-deleted engagement subjects, and stress snapshot cleanup. diff --git a/docs/dev-journal.md b/docs/dev-journal.md index 97149b69..6dab3e43 100644 --- a/docs/dev-journal.md +++ b/docs/dev-journal.md @@ -1401,3 +1401,103 @@ PROJ-1433 requires every metric used in the paper or site to have a receipt and - Removed hard Sybil-resistance wording from refreshed paper/site claims. Current safe wording is simulation/mechanism evidence only, and the metrics packet preserves the PROJ-1551 warning that synthetic voter populations do not prove real electorate behavior, Sybil resistance, personhood, or abuse resistance. - Kept the `web-next` type fixes scoped to validation blockers discovered during PROJ-1433; no visual behavior was intentionally changed in those two helper components. - Redacted the example production post's raw handle, post text, DID, and source URI from the public metrics packet and UI fixtures; public site and paper copy now use anonymized receipt labels while preserving the numeric proof. + +## 2026-07-09 #02 — PROJ-1431 shadow reviewer demo backend contract + +**Branch:** `dev/PROJ-1431-recsys-reviewer-demo-path-tells-the-corgi-loop-end-to-end` +**Commits:** pending Andrew approval. +**Files changed:** `src/demo/**`, `src/feed/server.ts`, `src/governance/aggregation.ts`, `src/governance/aggregation-math.ts`, `tests/demo-shadow-*.test.ts`, `tests/web-next-shadow-demo-contract.test.ts`, `tests/rate-limit-config.test.ts`, `web-next/app/demo/shadow-demo-contract.ts`, `docs/lab/demo-shadow-governance-contract.md`, `docs/RECSYS_VALIDATION_EVIDENCE.md`. + +### What changed + +Added the reviewer-safe shadow demo backend contract for PROJ-1431. Public `/api/demo/*` routes create anonymous demo sessions, freeze a production-sourced Open Science Builders corpus per session, accept a reviewer-only vote, run 24 deterministic synthetic community voters, advance shadow epochs, return reranked feed rows, and generate inspectable receipt math from stored production score decompositions. + +The default demo electorate is now one reviewer plus 24 deterministic synthetic voters grouped into Research Practitioners, Data Stewards, Current-Awareness Readers, Community Discussants, and Interdisciplinary Connectors. With 25 total votes, the same trimmed-mean helper used by production actually trims extremes instead of quietly behaving like a small-electorate mean. Each voter combines a stable bloc preference, prior-policy inertia, bounded response to the reviewer, and deterministic epoch variation. LLM agents are explicitly out of scope for v2 so every simulated vote is seeded, inspectable, replayable, and cost-bounded. + +Corrected the epoch transition semantics before UI integration: the open epoch now retains its baseline policy, ballots produce a pending aggregate, and the pending aggregate becomes the next epoch's policy. This makes displayed rank movement a causal comparison between two policies instead of recomputing both sides from one aggregate. Topic-intent ballots use the existing production topic slugs and the extracted production relevance formula, so topic controls alter ranking and receipt math rather than serving as decorative UI. + +### Isolation + +The demo remains Redis-only under `demo:*` namespaces and does not mutate `governance_votes`, `governance_epochs`, `governance_audit_log`, production feed cache keys, production snapshots, or research exports. It does not call production epoch transition, vote-writing, scoring-pipeline, feed-cache writer, audit writer, or export writer paths. Reviewer clicks reweight stored raw components; they do not run the production scorer. + +The corpus provenance line for UI use is: "Live-scored snapshot, frozen for this demo run so rank movement is attributable to policy changes." A refresh-live-snapshot action creates a new session boundary and never changes an existing session's frozen comparison set. + +### Measurements + +- Focused shadow-demo slices passed with dummy non-production env, including the final store/route/public-view hardening slice at 3 files / 26 tests. +- Root TypeScript build passed with dummy non-production env: `npm run build`. +- Full deploy-parity `npm run verify` passed with a temporary `.env.example` copy and local loopback/IPC permission: root build, default suite 118 files / 1,065 Vitest tests, CLI build, MCP-local skip, SDK build, SDK fixture, legacy `web` lint/build, and `web-next` static build. +- Claude's integrated `web-next` demo worktree passed lint, TypeScript, 6 demo test files / 26 tests, and the production static build. A same-origin browser run served that static export from the HTTP-only compiled backend and completed session creation, reviewer vote, 24-voter aggregation, epoch 2 reranking/receipt, a different epoch 3 proposal, and a second reranking/receipt. The pending policy changed from approximately 23/17/18/19/23 to 18/28/15/14/25 across recency/engagement/bridging/source-diversity/relevance, confirming history-aware multi-epoch behavior. Viewports 1440x1100, 756x762, and 390x844 had no horizontal overflow and the final run reported no browser console errors. +- A post-hardening HTTP-only smoke against local Redis completed two full epochs under contract `2026-07-10.shadow-demo.v2`: 25 ballots and trim count 2 in each round, epoch-2 receipt score 0.689742, epoch-3 receipt score 0.675619, and matching visible/receipt rank 1. With the intentionally non-production `.env.example` database, corpus status was correctly `degraded`; session creation took 13.93 ms, vote/voter/advance writes stayed below 2.60 ms, and feed/receipt reads stayed below 1.00 ms in this local run. +- The assembled run exposed and fixed a Fastify route collision between the public Next `/docs/` page and admin Swagger UI. Product docs remain `/docs`; the production-gated API explorer moved to `/api/docs`. +- Multiple CodeRabbit local review passes completed. All verified HIGH/MEDIUM findings were fixed; the final scoped backend pass reported only two intentionally skipped trivial performance/generalization suggestions, and the final frontend finding was fixed with a two-epoch mock-adapter regression test. No commit has been made pending Andrew approval. + +### Decisions & alternatives + +- Kept the compatibility route path `POST /api/demo/sessions/:sessionId/agents/run`, but documented and typed it as deterministic synthetic community voters rather than LLM agents. +- Made `open_science_builders` the only live shadow community in v2 after a read-only 72-hour production query found 84,831 active-epoch scored candidates from 46,652 authors, including 2,274 posts spanning at least two target topics. Birders remains a preview/future secondary feed. +- Chose a session-frozen corpus over continuous churn so rank movement in the demo is attributable to policy changes, not shifting live post supply. +- Did not claim mature live Birders feed supply; the contract records scout thresholds that still need production evidence before that claim is safe. + +## 2026-07-09 #03 — PROJ-1431 Birders feed readiness scout + +**Branch:** `dev/PROJ-1431-recsys-reviewer-demo-path-tells-the-corgi-loop-end-to-end` +**Commits:** pending Andrew approval. +**Files changed:** `src/feed/community-registry.ts`, `src/feed/community-materializer.ts`, `src/feed/birders-scout-command.ts`, `scripts/birders-feed-scout.ts`, `src/feed/routes/describe-generator.ts`, `src/feed/routes/feed-skeleton.ts`, `src/feed/snapshot-cache.ts`, `tests/feed-community-registry.test.ts`, `tests/birders-feed-materializer.test.ts`, `tests/birders-scout-command.test.ts`, `tests/feed-skeleton-validation.test.ts`, `package.json`, `docs/lab/birders-feed-readiness.md`, `docs/RECSYS_VALIDATION_EVIDENCE.md`, `docs/dev-journal.md`. + +### What changed + +Added a disabled/private Birders Who Code feed-readiness substrate without publishing a new Bluesky feed. The code-first registry now contains `community-gov` as the enabled public production feed and `birders_who_code` as a disabled private future feed with its own rkey, terms, seed weights, and namespaced Redis keys. + +Added a Birders scout/materializer that reads recent non-deleted production posts, joins active-epoch score decompositions, filters with Birders plus code/data bridge terms, reweights stored raw components with the Birders seed policy, and can write only `feed:community:birders_who_code:*` keys when explicitly run in materialize mode. The default `npm run feed:birders-scout` path is read-only. + +The feed skeleton now dispatches by registered feed rkey while preserving current `community-gov` behavior. Disabled Birders requests are rejected clearly, and `describeFeedGenerator` advertises only enabled public feeds. + +### Why + +This lets us prove supply and infrastructure readiness for a real Birders feed before creating a public Bluesky feed record or adding true multi-community governance. It also lays the lower-risk substrate for a future no-code feed builder: registry entry, terms, seed policy, namespaced feed keys, materialization, and health metadata. + +### Production read-only measurements + +Production host: `corgi-vps`. +Checkout path: `/opt/bluesky-feed`. +Query mode: read-only transaction with `ROLLBACK`. +Window: 72 hours. +Active production epoch: 2. + +Broad Birders OR bridge scout, sampled 2026-07-10T00:33:28.29665+00:00: + +- 22,449 candidate posts, 7,483.00/day. +- 12,935 unique authors, 4,311.67/day. +- 75.18% bridge-post share. +- 3.44% top-author concentration. +- 1,649 strong bridge/high-relevance posts, 549.67/day. +- Status by broad readiness thresholds: ready. + +Stricter split scout, sampled 2026-07-10T00:35:48.833196+00:00: + +- `broad_or`: 22,440 posts, 7,480.00/day, 12,931 authors, 4,310.33 authors/day, 570.00 relevance>=0.65 posts/day, 4.67 strict strong/day. +- `bird_only`: 5,732 posts, 1,910.67/day, 4,153 authors, 1,384.33 authors/day, 25.00 relevance>=0.65 posts/day, 4.67 strict strong/day. +- `bridge_only`: 16,870 posts, 5,623.33/day, 9,377 authors, 3,125.67 authors/day, 549.67 relevance>=0.65 posts/day, 4.67 strict strong/day. +- `bird_and_bridge`: 162 posts, 54.00/day, 136 authors, 45.33 authors/day, 4.67 relevance>=0.65 posts/day, 4.67 strict strong/day. + +### Measurements + +- Focused Birders/feed slice passed with dummy non-production env: `./node_modules/.bin/vitest run tests/feed-community-registry.test.ts tests/birders-feed-materializer.test.ts tests/birders-scout-command.test.ts tests/feed-skeleton-validation.test.ts tests/feed-snapshot-cache.test.ts`, 5 files / 50 tests. +- Root TypeScript build passed with dummy non-production env: `npm run build`. +- `npm run feed:birders-scout -- --help` exited cleanly after keeping the registry pure and lazy-loading DB/Redis modules after argument parsing. +- Full `npm run verify` passed with dummy non-production env and local loopback/IPC permission: root build, default suite 117 files / 1,045 Vitest tests, CLI build, MCP-local skip, SDK build, SDK fixture, legacy `web` lint/build, and `web-next` static build. + +### Decisions & alternatives + +- Kept Birders disabled and private. No Bluesky feed record was published. +- Kept governance out of this pass: no per-community votes, epochs, audit logs, migrations, or feed-builder UI. +- Kept materialization namespaced and rejected `community-gov` in the Birders materializer so the readiness command cannot overwrite `feed:current`. +- Optimized the scout SQL after the first broad production query timed out. The revised shape filters matching recent posts before joining active-epoch scores and preserves an active-epoch metrics row even when candidate supply is zero. +- Treated broad OR supply as useful capacity evidence but not as proof of Birders specificity. The stricter split shows birding supply is healthy, while true bird+code strong supply needs term/scoring tuning and qualitative precision review before publication. + +### Open questions + +- Run the disabled materializer against production Redis only after an explicit operator window, then hydrate a sample through Bluesky AppView for human precision/safety review. +- Decide whether `bird_and_bridge` should be a hard feed filter or a boosting feature; current evidence suggests hard filtering may be too narrow for a lively public feed. +- True governed multi-community feeds still require per-community feed dispatch, Redis namespaces, epochs, weights, votes, receipts, and migration planning beyond this static-policy readiness substrate. diff --git a/docs/lab/birders-feed-readiness.md b/docs/lab/birders-feed-readiness.md new file mode 100644 index 00000000..60692ff0 --- /dev/null +++ b/docs/lab/birders-feed-readiness.md @@ -0,0 +1,139 @@ +# Birders Who Code Feed Readiness Packet + +Status: disabled/private feed-readiness substrate, not a published Bluesky feed. +Issue: PROJ-1431. +Branch: `dev/PROJ-1431-recsys-reviewer-demo-path-tells-the-corgi-loop-end-to-end`. +Last updated: 2026-07-10T00:40Z. + +## Scope + +This packet records the first readiness track for a real Birders Who Code feed. The +implementation proves a safe path for scouting and materializing a second feed +bucket without changing the existing `community-gov` feed. + +The feed remains disabled and private by default. No Bluesky feed record was +published, no production governance rows were written, and the public +`community-gov` skeleton behavior remains unchanged. + +## Implemented Substrate + +- Added a code-first community registry with: + - `community-gov`: enabled, public, production rkey `community-gov`, existing Redis keys. + - `birders_who_code`: disabled, private, planned rkey `birders-who-code`, namespaced Redis keys. +- Added Birders terms for birding plus code/data bridge discovery. +- Added a Birders scout/materializer that: + - reads recent non-deleted `posts`; + - joins active-epoch `post_scores`; + - reweights stored score components with static Birders seed weights; + - reports corpus health before any publication decision; + - writes only `feed:community:birders_who_code:*` keys when explicitly run with `--materialize`. +- Added feed-rkey dispatch: + - `community-gov` still reads from `feed:current`; + - disabled Birders requests are rejected clearly; + - disabled/private feeds are hidden from `describeFeedGenerator`. +- Added `npm run feed:birders-scout`: + - default mode is read-only scout; + - `--json` emits machine-readable metrics; + - `--materialize` writes only the namespaced Birders bucket. + +## Readiness Thresholds + +These are scout thresholds for deciding whether a feed is worth qualitative +review and possible publication. They are not proof of feed quality by +themselves. + +| Metric | Threshold | +| --- | ---: | +| Candidate posts per day | 100 | +| Unique authors per day | 30 | +| Strong bridge/high-relevance posts per day | 10 | + +## Production Read-Only Scout + +Production host: `corgi-vps`. +Checkout path: `/opt/bluesky-feed`. +Database access: read-only transaction with `ROLLBACK`. +Window: 72 hours. +Active production epoch: 2. + +The first broad read-only scout used the same broad term model as the v1 +materializer: Birders terms OR code/data bridge terms. It completed after the +SQL was reshaped to filter matching posts before joining active-epoch scores. + +| Metric | Value | +| --- | ---: | +| Sample timestamp | 2026-07-10T00:33:28.29665+00:00 | +| Candidate posts | 22,449 | +| Candidate posts per day | 7,483.00 | +| Unique authors | 12,935 | +| Unique authors per day | 4,311.67 | +| Bridge-post share | 75.18% | +| Top-author concentration | 3.44% | +| Strong bridge/high-relevance posts | 1,649 | +| Strong bridge/high-relevance posts per day | 549.67 | +| Scout status | ready | + +Interpretation: broad supply is not the bottleneck. This query is deliberately +wide, so it overstates Birders Who Code specificity because generic terms such +as `api`, `script`, and `github` are common on Bluesky. + +## Stricter Split Scout + +To avoid fooling ourselves with broad OR supply, a second read-only split query +bucketed the same 72-hour scored production window. + +Sample timestamp: 2026-07-10T00:35:48.833196+00:00. + +| Bucket | Candidate posts | Candidates/day | Unique authors | Authors/day | Relevance >= 0.65/day | Strict strong/day | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| broad_or | 22,440 | 7,480.00 | 12,931 | 4,310.33 | 570.00 | 4.67 | +| bird_only | 5,732 | 1,910.67 | 4,153 | 1,384.33 | 25.00 | 4.67 | +| bridge_only | 16,870 | 5,623.33 | 9,377 | 3,125.67 | 549.67 | 4.67 | +| bird_and_bridge | 162 | 54.00 | 136 | 45.33 | 4.67 | 4.67 | + +Interpretation: birding-only supply is healthy, and the strict bird+code bridge +bucket has enough volume for review fixtures and demo examples. It does not yet +meet the original 10/day strong bridge/high-relevance threshold. Before +publishing a public Birders feed, tune the terms/scoring threshold and do a +human precision audit against hydrated public posts. + +## Safety Boundaries + +The readiness code must not mutate: + +- `feed:current` +- `feed:epoch` +- `governance_votes` +- `governance_epochs` +- `governance_audit_log` +- production snapshots +- research exports + +The materializer writes only: + +- `feed:community:birders_who_code:current` +- `feed:community:birders_who_code:epoch` +- `feed:community:birders_who_code:health` +- `feed:community:birders_who_code:snapshot_generation` +- `feed:community:birders_who_code:current_snapshot_id` + +## Verification Receipts + +- Focused Birders/feed slice passed after the zero-candidate active-epoch guard: + 5 files / 50 tests. +- `npm run feed:birders-scout -- --help` exited cleanly and documented default + read-only behavior. +- `npm run build` passed. +- Full `npm run verify` passed: root build, default suite 117 files / 1,045 + Vitest tests, CLI build, MCP-local skip, SDK build, SDK fixture, legacy + `web` lint/build, and `web-next` static build. +- Production broad scout and stricter split scout both ran inside read-only + transactions and rolled back. + +## Recommendation + +Do not publish `birders-who-code` yet. The next responsible step is to run the +materializer in a disabled/private mode, hydrate a sample through Bluesky +AppView, and manually inspect precision, diversity, and safety. If that review +looks good, then publishing the feed record becomes a product decision rather +than an infrastructure blocker. diff --git a/docs/lab/demo-shadow-governance-contract.md b/docs/lab/demo-shadow-governance-contract.md new file mode 100644 index 00000000..bbba3adc --- /dev/null +++ b/docs/lab/demo-shadow-governance-contract.md @@ -0,0 +1,85 @@ +# Corgi Shadow Demo Governance Contract + +Version: `2026-07-10.shadow-demo.v2` + +This contract powers the public `/demo` walkthrough without mutating production governance. + +## Scope + +- `open_science_builders` is the only live shadow community in v2. Its candidate pool uses the existing `science-research`, `data-science`, `software-development`, and `open-source` topic vectors. +- `birders_who_code` remains a preview/future secondary community. It is not the primary reviewer corpus and is not published as a Bluesky feed. +- Other demo community IDs may be returned as degraded concepts, not live feeds. +- Demo sessions source one corpus from live production `posts`, active `post_scores`, current epoch weights, and Bluesky AppView hydration, then freeze that comparison set at session creation. Reviewer votes change ranking math, not the compared post set. +- UI copy should use this provenance line: "Live-scored snapshot, frozen for this demo run so rank movement is attributable to policy changes." +- Demo state is Redis-only under `demo:session:*`, `demo:corpus:*`, `demo:corpus:current:*`, `demo:idempotency:*`, and `demo:lock:*`. +- Guided UI should default to 5 guided shadow epochs. Advanced/free-play sessions are capped at 10 shadow epochs, including the baseline epoch. +- Shared current corpus snapshots are short-lived warming caches. Each session still receives a cloned frozen corpus. +- A "refresh live snapshot" action should create a new session, optionally bypassing the short shared-corpus cache. It must never mutate an existing session's frozen corpus. + +## Endpoints + +- `POST /api/demo/sessions` +- `GET /api/demo/sessions/:sessionId` +- `POST /api/demo/sessions/:sessionId/votes` +- `POST /api/demo/sessions/:sessionId/agents/run` (compatibility path; runs deterministic synthetic community voters, not LLM agents) +- `POST /api/demo/sessions/:sessionId/epochs/advance` +- `GET /api/demo/sessions/:sessionId/feed?epochId=&limit=` +- `GET /api/demo/sessions/:sessionId/receipts?epochId=&postUri=` + +`POST /api/demo/sessions` accepts `refreshCorpus: true` to force a new live corpus load for the new session boundary. + +## Governance Math + +The shadow demo uses the same side-effect-free trimmed-mean helper as production aggregation, labeled `trimmed_mean_no_trim_under_10`: no trimming below ten votes, and 10 percent trimming from both ends once ten or more votes exist. + +The default demo electorate is one reviewer plus 24 deterministic synthetic community voters, for 25 total weight votes. That means the v2 demo actually exercises the production trim rule: two low and two high values are trimmed per component before averaging. + +Synthetic voters are deterministic, seeded, inspectable, and replayable. They are grouped into five visible blocs: + +- Research Practitioners: 5 voters +- Data Stewards: 5 voters +- Current-Awareness Readers: 5 voters +- Community Discussants: 4 voters +- Interdisciplinary Connectors: 5 voters + +LLM agents are out of scope for v2 because the demo needs replayability, bounded cost, deterministic receipts, and a clear explanation path for ACM RecSys reviewers. The synthetic voters are not meant to impersonate real people; they are a deterministic community-governance simulator over live-scored production components. + +Each synthetic voter combines a stable bloc preference, prior-policy inertia, a bounded response to the reviewer proposal, and deterministic per-epoch variation. Replaying the same session inputs produces the same ballots. Changing the previous epoch policy changes the next electorate, so a multi-epoch run has real path dependence without opaque model calls or unbounded randomness. + +The open epoch always retains the policy that produced its baseline ranking. Reviewer and synthetic ballots produce a pending aggregate; advancing closes that epoch and creates the next epoch with the pending policy. This preserves a causal before/after comparison instead of applying one aggregate to both sides of the transition. + +Scores are recomputed as: + +```text +effective relevance = production topic-vector relevance(post topics, shadow topic weights) +demo total = sum(effective raw component score * shadow epoch signal weight) +``` + +The demo does not rerun the scorer and does not write `post_scores`. + +## Isolation Rules + +The demo must not import or call production epoch transition, production voting, scoring-pipeline, production feed-cache writer, audit-log writer, or research-export writer paths. It may read active production epochs, posts, and decomposed score components to build the frozen corpus. + +Production tables and keys that must not be written by the demo: + +- `governance_votes` +- `governance_epochs` +- `governance_audit_log` +- `feed:current` +- `feed:current_snapshot_id` +- research export tables + +## Public View Rules + +Bluesky AppView hydration is used for display metadata. Rows with `!no-unauthenticated`, `!hide`, takedown, adult-only labels, deleted/unavailable records, or missing public text are returned as compact hidden rows without text, handle, avatar, or Bluesky URL. + +The UI should display 8-12 ranked posts per shadow epoch when the frozen corpus is large enough. If the live Open Science Builders corpus cannot be loaded or fewer than five public scored posts survive hydration, the API returns a degraded state and falls back to illustrative fixture posts with clear provenance. Live-proof claims must come from public Bluesky posts and Corgi receipt endpoints. + +## Corpus Health Rules + +The live Open Science Builders corpus loader reads recent non-deleted posts from production storage, selects posts with at least one of the four production topic slugs, keeps only posts with decomposed score components for the active production epoch, hydrates display metadata via public Bluesky AppView, and then applies the public-view rules above. The source pool metrics are computed before the 80-post display-corpus limit, so the UI does not mistake the frozen sample size for total 72-hour supply. + +A 2026-07-10 read-only production query found 84,831 active-epoch scored candidates from 46,652 authors over 72 hours. Of those, 2,274 posts matched two or more target topics, or about 758 cross-topic posts/day. The largest single author contributed 1.7 percent of the pool. Detailed counts and query semantics are in `docs/lab/open-science-demo-readiness.md`. + +This proves ample source supply for the shadow corpus. It does not mean Open Science Builders has been published as a native Bluesky feed; no feed record/rkey exists for it in v2. diff --git a/docs/lab/open-science-demo-readiness.md b/docs/lab/open-science-demo-readiness.md new file mode 100644 index 00000000..92bb9bde --- /dev/null +++ b/docs/lab/open-science-demo-readiness.md @@ -0,0 +1,50 @@ +# Open Science Builders Demo Readiness + +Date: 2026-07-10 UTC + +Purpose: quantify whether the primary reviewer demo can use live production-scored posts across science, data science, software development, and open source without relying on a thin novelty corpus. + +## Method + +- Production host: `corgi-vps`. +- Window: the most recent 72 hours. +- Operation: read-only PostgreSQL queries; no feed, governance, post, score, or Redis state was mutated. +- Candidate rule: active-epoch scored, non-deleted posts whose `topic_vector` contains at least one of: + - `science-research` + - `data-science` + - `software-development` + - `open-source` +- Cross-topic rule: a post contains at least two target topic keys. +- Resource guard: the final query disabled parallel gather and used `work_mem=4MB` after an earlier parallel query encountered a PostgreSQL shared-memory allocation error. This is an operational capacity warning, not a demo-supply failure. + +## Results + +| Measure | 72-hour result | Approximate daily rate | +| --- | ---: | ---: | +| All ingested posts | 534,522 | 178,174 | +| Active-epoch scored posts | 534,361 | 178,120 | +| Open Science Builders candidates | 84,831 | 28,277 | +| Candidate authors | 46,652 | 15,551 | +| Posts matching at least two target topics | 2,274 | 758 | +| Top-author concentration | 1.7% | n/a | + +Topic-level supply: + +| Topic | Posts | Unique authors | +| --- | ---: | ---: | +| `science-research` | 45,562 | 27,797 | +| `software-development` | 30,515 | 18,467 | +| `data-science` | 7,754 | 4,830 | +| `open-source` | 3,363 | 2,252 | + +All 534,522 posts in the measured ingestion window had a topic vector. Within the Open Science Builders candidate set, 2.7 percent matched at least two target topics. + +## Decision + +Open Science Builders is the primary live shadow-demo community. It has enough active, scored, author-diverse supply to freeze a high-quality session corpus while still presenting conflicts among recency, engagement, bridging, source diversity, relevance, and topic intent. + +Birders Who Code remains useful as a future secondary feed and product-education example, but its strict bird-plus-code scout produced 54 candidates/day and 4.67 strong bridge posts/day. That narrower supply makes it a less reliable primary reviewer path during the current submission closeout. + +## Claim Boundary + +This evidence supports a production-sourced, session-frozen shadow demo. It does not establish a published Open Science Builders Bluesky feed, real human community preference distributions, Sybil resistance, or external validity for the synthetic electorate. diff --git a/package.json b/package.json index fa2cd608..b49ec6e9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "stress": "tsx tests/stress/run.ts", "generate-feed-did": "tsx scripts/generate-feed-did.ts", "publish-feed": "tsx scripts/publish-feed.ts", + "feed:birders-scout": "tsx scripts/birders-feed-scout.ts", "test": "npm run sdk:build && vitest tests", "generate:component": "tsx scripts/generate-scoring-component.ts", "generate:route": "tsx scripts/generate-route.ts", diff --git a/scripts/birders-feed-scout.ts b/scripts/birders-feed-scout.ts new file mode 100644 index 00000000..c839902b --- /dev/null +++ b/scripts/birders-feed-scout.ts @@ -0,0 +1,69 @@ +import { + BirdersScoutHelpRequested, + birdersScoutUsage, + parseBirdersScoutArgs, + renderBirdersMaterializeResult, + renderBirdersScoutReport, + resolveBirdersCommunity, +} from '../src/feed/birders-scout-command.js'; + +async function main(): Promise { + const options = parseOptionsOrPrintHelp(process.argv.slice(2)); + if (options === null) { + return; + } + + const [{ db }, { redis }, { materializeCommunityFeed, scoutCommunityFeed }] = await Promise.all([ + import('../src/db/client.js'), + import('../src/db/redis.js'), + import('../src/feed/community-materializer.js'), + ]); + + try { + const community = resolveBirdersCommunity(); + const now = new Date(); + + if (options.mode === 'materialize') { + const result = await materializeCommunityFeed({ + community, + dbPool: db, + redisClient: redis, + now, + windowHours: options.windowHours, + limit: options.limit, + }); + console.log(options.json ? JSON.stringify(result, null, 2) : renderBirdersMaterializeResult(result)); + return; + } + + const report = await scoutCommunityFeed({ + community, + dbPool: db, + now, + windowHours: options.windowHours, + limit: options.limit, + }); + console.log(options.json ? JSON.stringify(report, null, 2) : renderBirdersScoutReport(report)); + } finally { + redis.disconnect(); + await db.end(); + } +} + +function parseOptionsOrPrintHelp(argv: readonly string[]): ReturnType | null { + try { + return parseBirdersScoutArgs(argv); + } catch (err) { + if (err instanceof BirdersScoutHelpRequested) { + console.log(birdersScoutUsage()); + return null; + } + throw err; + } +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`Birders scout failed: ${message}`); + process.exitCode = 1; +}); diff --git a/src/demo/appview.ts b/src/demo/appview.ts new file mode 100644 index 00000000..e75780c0 --- /dev/null +++ b/src/demo/appview.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import { logger } from '../lib/logger.js'; +import { AppViewPostSchema, publicPostFromAppView, type AppViewPost } from './public-view.js'; +import type { ShadowDemoCorpusItem } from './types.js'; + +export const APPVIEW_GET_POSTS_MAX_URIS = 25; +const APPVIEW_PUBLIC_ORIGIN = 'https://public.api.bsky.app'; +const APPVIEW_GET_POSTS_PATH = '/xrpc/app.bsky.feed.getPosts'; + +export interface FetchRequestInit { + method: 'GET'; + signal: AbortSignal; +} + +export type DemoFetchFunction = (input: string, init: FetchRequestInit) => Promise<{ + ok: boolean; + status: number; + text: () => Promise; +}>; + +const AppViewGetPostsResponseSchema = z.object({ + posts: z.array(z.unknown()).optional(), +}).passthrough(); + +export function buildAppViewGetPostsUrl(uris: string[]): string { + const url = new URL(APPVIEW_GET_POSTS_PATH, APPVIEW_PUBLIC_ORIGIN); + for (const uri of uris) { + url.searchParams.append('uris', uri); + } + return url.toString(); +} + +export async function hydrateCorpusItemsWithAppView(options: { + items: ShadowDemoCorpusItem[]; + fetchFn: DemoFetchFunction; + timeoutMs: number; +}): Promise { + const byUri = new Map(); + for (let index = 0; index < options.items.length; index += APPVIEW_GET_POSTS_MAX_URIS) { + const batch = options.items.slice(index, index + APPVIEW_GET_POSTS_MAX_URIS); + let response: AppViewPost[]; + try { + response = await fetchAppViewPosts({ + uris: batch.map((item) => item.postUri), + fetchFn: options.fetchFn, + timeoutMs: options.timeoutMs, + }); + } catch (err) { + logger.warn( + { err, batchStart: index, batchSize: batch.length }, + 'Shadow demo AppView batch failed; withholding only that batch' + ); + continue; + } + for (const post of response) { + if (typeof post.uri === 'string') { + byUri.set(post.uri, post); + } + } + } + + return options.items.map((item) => { + const post = byUri.get(item.postUri) ?? null; + if (!post) { + return { + ...item, + displayPost: { + kind: 'hidden_post', + reason: 'Post unavailable from Bluesky public AppView', + }, + }; + } + return { + ...item, + displayPost: publicPostFromAppView(post), + }; + }); +} + +async function fetchAppViewPosts(options: { + uris: string[]; + fetchFn: DemoFetchFunction; + timeoutMs: number; +}): Promise { + if (options.uris.length > APPVIEW_GET_POSTS_MAX_URIS) { + throw new Error(`app.bsky.feed.getPosts accepts at most ${APPVIEW_GET_POSTS_MAX_URIS} URIs`); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); + try { + const response = await options.fetchFn(buildAppViewGetPostsUrl(options.uris), { + method: 'GET', + signal: controller.signal, + }); + const body = await response.text(); + if (!response.ok) { + throw new Error(`Bluesky AppView getPosts failed with HTTP ${response.status}: ${body.slice(0, 500)}`); + } + let json: unknown; + try { + json = JSON.parse(body) as unknown; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Bluesky AppView getPosts returned malformed JSON: ${detail}`); + } + const parsed = AppViewGetPostsResponseSchema.safeParse(json); + if (!parsed.success) { + throw new Error( + `Bluesky AppView getPosts returned an invalid payload: ${parsed.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; ')}` + ); + } + return (parsed.data.posts ?? []).flatMap((candidate) => { + const post = AppViewPostSchema.safeParse(candidate); + return post.success ? [post.data] : []; + }); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/demo/corpus.ts b/src/demo/corpus.ts new file mode 100644 index 00000000..cd0d5e90 --- /dev/null +++ b/src/demo/corpus.ts @@ -0,0 +1,501 @@ +import { randomUUID } from 'node:crypto'; +import type { Pool } from 'pg'; +import { db as defaultDb } from '../db/client.js'; +import { logger } from '../lib/logger.js'; +import { readPostScore, type PostScoreRecord, type ReadPostScoreOptions } from '../scoring/score-reader.js'; +import { hydrateCorpusItemsWithAppView, type DemoFetchFunction } from './appview.js'; +import { hiddenDisplayPost } from './public-view.js'; +import { + SHADOW_DEMO_SESSION_TTL_SECONDS, + type ShadowDemoCommunity, + type ShadowDemoCommunityId, + type ShadowDemoCorpus, + type ShadowDemoCorpusHealth, + type ShadowDemoCorpusItem, + type ShadowDemoRawScores, + type ShadowDemoWarning, + type ShadowDemoTopicIntent, + type ShadowDemoWeights, +} from './types.js'; +import { equalShadowWeights, internalRawScoresToShadow, internalWeightsToShadow } from './weights.js'; +import { emptyShadowTopicIntent } from './topic-intent.js'; + +export const DEMO_COMMUNITIES: Record = { + open_science_builders: { + id: 'open_science_builders', + name: 'Open Science Builders', + status: 'live_shadow', + description: + 'Research, reusable datasets, open-source methods, and software that moves knowledge across disciplines.', + liveFeedReady: true, + }, + birders_who_code: { + id: 'birders_who_code', + name: 'Birders Who Code', + status: 'degraded', + description: + 'Warbler sightings, messy CSVs, camera traps, bug reports, and deploy jokes ranked by community policy.', + liveFeedReady: false, + }, + crit_fumble_pickup: { + id: 'crit_fumble_pickup', + name: 'Crit Fumble Pickup', + status: 'degraded', + description: + 'A tabletop community concept for rules debates, painted minis, session logs, and dramatic dice stories.', + liveFeedReady: false, + }, + osint_garden_club: { + id: 'osint_garden_club', + name: 'OSINT Garden Club', + status: 'degraded', + description: + 'A community concept for satellite sleuthing, botany threads, public records, and surprisingly intense plant IDs.', + liveFeedReady: false, + }, +}; + +const CANDIDATE_LIMIT = 80; +const APPVIEW_TIMEOUT_MS = 8000; +const MIN_PUBLIC_SCORED_POSTS = 5; +const OPEN_SCIENCE_TOPIC_SLUGS = [ + 'science-research', + 'data-science', + 'software-development', + 'open-source', +] as const; + +interface ActiveEpochRow { + id: number; + recency_weight: number; + engagement_weight: number; + bridging_weight: number; + source_diversity_weight: number; + relevance_weight: number; + topic_weights: Record | null; +} + +interface CandidatePostRow { + uri: string; + author_did: string; + created_at: Date | string; + topic_vector: Record | null; + candidate_count_72h: string | number; + unique_authors_72h: string | number; +} + +export interface LoadShadowDemoCorpusOptions { + communityId: ShadowDemoCommunityId; + now: Date; + fetchFn: DemoFetchFunction; + dbPool: Pick; + readScore: (options: ReadPostScoreOptions) => Promise; +} + +export async function loadShadowDemoCorpus(options: LoadShadowDemoCorpusOptions): Promise { + if (options.communityId !== 'open_science_builders') { + return fallbackCorpus({ + communityId: options.communityId, + now: options.now, + reason: `Community ${options.communityId} is not wired to a live shadow corpus in v1`, + }); + } + + try { + const epoch = await readLatestActiveEpoch(options.dbPool); + if (!epoch) { + return fallbackCorpus({ + communityId: options.communityId, + now: options.now, + reason: 'No active production epoch was available for the shadow demo corpus', + }); + } + + const candidates = await readOpenScienceCandidates({ + dbPool: options.dbPool, + epochId: epoch.id, + }); + const scoredItems = await buildScoredCorpusItems({ + rows: candidates, + epochId: epoch.id, + readScore: options.readScore, + }); + const hydratedItems = await hydrateCorpusItemsWithAppView({ + items: scoredItems, + fetchFn: options.fetchFn, + timeoutMs: APPVIEW_TIMEOUT_MS, + }); + const publicCount = hydratedItems.filter((item) => item.displayPost.kind === 'public_post').length; + const health = buildHealth({ + now: options.now, + rows: candidates, + items: hydratedItems, + source: publicCount >= MIN_PUBLIC_SCORED_POSTS ? 'production_scores_appview' : 'fixture_fallback', + }); + + if (publicCount < MIN_PUBLIC_SCORED_POSTS) { + return fallbackCorpus({ + communityId: options.communityId, + now: options.now, + reason: `Only ${publicCount} public scored Open Science Builders candidates passed the scout threshold`, + activeEpochId: epoch.id, + baseWeights: epochWeights(epoch), + baseTopicIntent: epochTopicIntent(epoch), + health, + }); + } + + return { + corpusId: `corpus-${randomUUID()}`, + communityId: options.communityId, + baseProductionEpochId: epoch.id, + baseWeights: epochWeights(epoch), + baseTopicIntent: epochTopicIntent(epoch), + createdAt: options.now.toISOString(), + expiresAt: expiresAt(options.now).toISOString(), + items: hydratedItems, + health, + warnings: [], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn({ err, communityId: options.communityId }, 'Shadow demo live corpus load failed'); + return fallbackCorpus({ + communityId: options.communityId, + now: options.now, + reason: `Live Open Science Builders corpus could not be loaded: ${message}`, + }); + } +} + +export function createDefaultCorpusLoader(): (options: { + communityId: ShadowDemoCommunityId; + now: Date; +}) => Promise { + return async (options) => loadShadowDemoCorpus({ + communityId: options.communityId, + now: options.now, + fetchFn: defaultFetch, + dbPool: defaultDb, + readScore: readPostScore, + }); +} + +async function readLatestActiveEpoch(dbPool: Pick): Promise { + const result = await dbPool.query( + `SELECT id, recency_weight, engagement_weight, bridging_weight, source_diversity_weight, relevance_weight, + topic_weights + FROM governance_epochs + WHERE status = 'active' + ORDER BY id DESC + LIMIT 1` + ); + return result.rows[0] ?? null; +} + +async function readOpenScienceCandidates(options: { + dbPool: Pick; + epochId: number; +}): Promise { + const result = await options.dbPool.query( + `WITH scored_candidates AS ( + SELECT DISTINCT ON (p.uri) + p.uri, p.author_did, p.created_at, p.topic_vector, ps.total_score + FROM posts p + JOIN post_scores ps ON ps.post_uri = p.uri + WHERE ps.epoch_id = $1 + AND p.deleted = FALSE + AND p.created_at >= NOW() - INTERVAL '72 hours' + AND COALESCE(p.topic_vector, '{}'::jsonb) ?| $2::text[] + ORDER BY p.uri, ps.total_score DESC, p.created_at DESC + ), + candidates AS ( + SELECT uri, author_did, created_at, topic_vector + FROM scored_candidates + ORDER BY total_score DESC, created_at DESC + LIMIT ${CANDIDATE_LIMIT} + ), + metrics AS ( + SELECT COUNT(*) AS candidate_count_72h, + COUNT(DISTINCT author_did) AS unique_authors_72h + FROM scored_candidates + ) + SELECT c.uri, c.author_did, c.created_at, c.topic_vector, + m.candidate_count_72h, m.unique_authors_72h + FROM candidates c + CROSS JOIN metrics m`, + [options.epochId, [...OPEN_SCIENCE_TOPIC_SLUGS]] + ); + return result.rows; +} + +async function buildScoredCorpusItems(options: { + rows: CandidatePostRow[]; + epochId: number; + readScore: (options: ReadPostScoreOptions) => Promise; +}): Promise { + const items: ShadowDemoCorpusItem[] = []; + for (const row of options.rows) { + const score = await options.readScore({ + postUri: row.uri, + epochId: options.epochId, + }); + if (!score) { + continue; + } + const rawScores = internalRawScoresToShadow(score.components); + if (!hasAllFiniteRawScores(rawScores)) { + continue; + } + items.push({ + postUri: row.uri, + authorDid: row.author_did, + createdAt: dateToIso(row.created_at), + topicVector: row.topic_vector ?? {}, + rawScores, + productionScore: score.totalScore, + productionEpochId: score.epochId, + scoredAt: score.scoredAt.toISOString(), + componentDetails: score.componentDetails, + displayPost: hiddenDisplayPost('Post has not been hydrated from Bluesky public AppView yet'), + }); + } + return items; +} + +function buildHealth(options: { + now: Date; + rows: CandidatePostRow[]; + items: ShadowDemoCorpusItem[]; + source: ShadowDemoCorpusHealth['source']; +}): ShadowDemoCorpusHealth { + const first = options.rows[0]; + const candidateCount = first ? Number(first.candidate_count_72h) : options.rows.length; + const uniqueAuthors = first ? Number(first.unique_authors_72h) : uniqueAuthorCount(options.items); + return { + status: options.source === 'production_scores_appview' ? 'live' : 'degraded', + source: options.source, + candidatePosts72h: finiteNumber(candidateCount), + publicScoredPosts: options.items.filter((item) => item.displayPost.kind === 'public_post').length, + uniqueAuthors72h: finiteNumber(uniqueAuthors), + bridgePostShare: bridgePostShare(options.items), + topAuthorConcentration: topAuthorConcentration(options.items), + sampledAt: options.now.toISOString(), + }; +} + +function fallbackCorpus(options: { + communityId: ShadowDemoCommunityId; + now: Date; + reason: string; + activeEpochId?: number; + baseWeights?: ShadowDemoWeights; + baseTopicIntent?: ShadowDemoTopicIntent; + health?: ShadowDemoCorpusHealth; +}): ShadowDemoCorpus { + const warning: ShadowDemoWarning = { + code: 'shadow_demo_corpus_degraded', + message: options.reason, + severity: 'degraded', + }; + const items = fixtureCorpusItems(options.now, options.activeEpochId ?? 0); + return { + corpusId: `corpus-${randomUUID()}`, + communityId: options.communityId, + baseProductionEpochId: options.activeEpochId ?? 0, + baseWeights: options.baseWeights ?? equalShadowWeights(), + baseTopicIntent: options.baseTopicIntent ?? emptyShadowTopicIntent(), + createdAt: options.now.toISOString(), + expiresAt: expiresAt(options.now).toISOString(), + items, + health: + options.health ?? { + status: 'degraded', + source: 'fixture_fallback', + candidatePosts72h: 0, + publicScoredPosts: items.length, + uniqueAuthors72h: uniqueAuthorCount(items), + bridgePostShare: bridgePostShare(items), + topAuthorConcentration: topAuthorConcentration(items), + sampledAt: options.now.toISOString(), + }, + warnings: [warning], + }; +} + +function fixtureCorpusItems(now: Date, epochId: number): ShadowDemoCorpusItem[] { + const scoredAt = now.toISOString(); + return [ + fixtureItem({ + index: 1, + epochId, + scoredAt, + authorHandle: 'maya-keene.bsky.social', + authorDisplayName: 'Maya Keene', + text: 'Published a notebook that reproduces our urban heat analysis from raw sensor data.', + rawScores: { recency: 0.74, engagement: 0.32, bridging: 0.81, source_diversity: 0.66, relevance: 0.91 }, + topicVector: { 'science-research': 0.92, 'software-development': 0.76 }, + }), + fixtureItem({ + index: 2, + epochId, + scoredAt, + authorHandle: 'benadler.bsky.social', + authorDisplayName: 'Ben Adler', + text: 'New preprint maps how wildfire smoke changes neighborhood-scale air quality.', + rawScores: { recency: 0.95, engagement: 0.5, bridging: 0.28, source_diversity: 0.42, relevance: 0.87 }, + topicVector: { 'science-research': 0.95 }, + }), + fixtureItem({ + index: 3, + epochId, + scoredAt, + authorHandle: 'eli-overthinking.bsky.social', + authorDisplayName: 'Eli Moreno', + text: 'Researchers will do anything except document the environment that produced the result.', + rawScores: { recency: 0.67, engagement: 0.96, bridging: 0.35, source_diversity: 0.26, relevance: 0.43 }, + topicVector: { 'software-development': 0.78 }, + }), + fixtureItem({ + index: 4, + epochId, + scoredAt, + authorHandle: 'arjunmehta.dev', + authorDisplayName: 'Arjun Mehta', + text: 'Open-source image classifier benchmark and training dataset just dropped.', + rawScores: { recency: 0.82, engagement: 0.58, bridging: 0.88, source_diversity: 0.74, relevance: 0.93 }, + topicVector: { 'science-research': 0.86, 'ai-machine-learning': 0.8, 'open-source': 0.72 }, + }), + fixtureItem({ + index: 5, + epochId, + scoredAt, + authorHandle: 'toastwindow.bsky.social', + authorDisplayName: 'Claire Rowan', + text: 'Field notes from a rainy biodiversity survey, plus the messy CSV and cleaning script.', + rawScores: { recency: 0.7, engagement: 0.44, bridging: 0.77, source_diversity: 0.82, relevance: 0.89 }, + topicVector: { 'science-research': 0.88, 'data-science': 0.71 }, + }), + fixtureItem({ + index: 6, + epochId, + scoredAt, + authorHandle: 'thocknotes.bsky.social', + authorDisplayName: 'Theo Kim', + text: 'Half my lab notebook is error messages, but the replication package finally runs end to end.', + rawScores: { recency: 0.6, engagement: 0.84, bridging: 0.65, source_diversity: 0.48, relevance: 0.64 }, + topicVector: { 'science-research': 0.7, 'software-development': 0.66 }, + }), + ]; +} + +function fixtureItem(options: { + index: number; + epochId: number; + scoredAt: string; + authorHandle: string; + authorDisplayName: string; + text: string; + rawScores: ShadowDemoRawScores; + topicVector: Record; +}): ShadowDemoCorpusItem { + const uri = `at://did:plc:shadowdemo${options.index}/app.bsky.feed.post/bird${options.index}`; + return { + postUri: uri, + authorDid: `did:plc:shadowdemo${options.index}`, + createdAt: options.scoredAt, + topicVector: options.topicVector, + rawScores: options.rawScores, + productionScore: 0, + productionEpochId: options.epochId, + scoredAt: options.scoredAt, + componentDetails: null, + displayPost: { + kind: 'public_post', + uri, + cid: `bafyshadowdemo${options.index}`, + authorDid: `did:plc:shadowdemo${options.index}`, + authorHandle: options.authorHandle, + authorDisplayName: options.authorDisplayName, + authorAvatar: null, + text: options.text, + likeCount: 12 * options.index, + repostCount: 4 * options.index, + replyCount: 3 * options.index, + quoteCount: options.index, + indexedAt: options.scoredAt, + createdAt: options.scoredAt, + bskyUrl: `https://bsky.app/profile/${options.authorHandle}/post/bird${options.index}`, + }, + }; +} + +function epochWeights(row: ActiveEpochRow): ShadowDemoWeights { + return internalWeightsToShadow({ + recency: Number(row.recency_weight), + engagement: Number(row.engagement_weight), + bridging: Number(row.bridging_weight), + sourceDiversity: Number(row.source_diversity_weight), + relevance: Number(row.relevance_weight), + }); +} + +function epochTopicIntent(row: ActiveEpochRow): ShadowDemoTopicIntent { + return { topicWeights: { ...(row.topic_weights ?? {}) } }; +} + +function hasAllFiniteRawScores(rawScores: ShadowDemoRawScores): boolean { + return Object.values(rawScores).every((value) => Number.isFinite(value)); +} + +function expiresAt(now: Date): Date { + return new Date(now.getTime() + SHADOW_DEMO_SESSION_TTL_SECONDS * 1000); +} + +function bridgePostShare(items: ShadowDemoCorpusItem[]): number { + if (items.length === 0) { + return 0; + } + const bridgeCount = items.filter((item) => item.rawScores.bridging >= 0.5).length; + return Number((bridgeCount / items.length).toFixed(3)); +} + +function topAuthorConcentration(items: ShadowDemoCorpusItem[]): number { + if (items.length === 0) { + return 0; + } + const counts = new Map(); + for (const item of items) { + const key = item.authorDid ?? 'unknown'; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const max = Math.max(...counts.values()); + return Number((max / items.length).toFixed(3)); +} + +function uniqueAuthorCount(items: ShadowDemoCorpusItem[]): number { + return new Set(items.map((item) => item.authorDid ?? item.postUri)).size; +} + +function finiteNumber(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function dateToIso(value: Date | string): string { + if (value instanceof Date) { + return value.toISOString(); + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid corpus date value: ${value}`); + } + return parsed.toISOString(); +} + +async function defaultFetch(input: string, init: { method: 'GET'; signal: AbortSignal }): Promise<{ + ok: boolean; + status: number; + text: () => Promise; +}> { + return globalThis.fetch(input, init); +} diff --git a/src/demo/public-view.ts b/src/demo/public-view.ts new file mode 100644 index 00000000..18b90301 --- /dev/null +++ b/src/demo/public-view.ts @@ -0,0 +1,168 @@ +import { z } from 'zod'; +import type { ShadowDemoDisplayPost, ShadowDemoPublicPost } from './types.js'; + +const HIDDEN_LABELS = new Set(['!no-unauthenticated', '!hide', '!takedown']); +const ADULT_ONLY_LABELS = new Set(['porn', 'sexual', 'nudity', 'graphic-media']); +const POST_AT_URI_PATTERN = /^at:\/\/(did:[a-z0-9]+:[A-Za-z0-9._:%-]+)\/app\.bsky\.feed\.post\/([A-Za-z0-9._~:@!$&'()*+,;=-]+)$/; + +const AppViewLabelSchema = z.object({ val: z.string().optional() }).passthrough(); +const AppViewAuthorSchema = z.object({ + did: z.string().optional(), + handle: z.string().optional(), + displayName: z.string().optional(), + avatar: z.string().optional(), + labels: z.array(AppViewLabelSchema).optional(), +}).passthrough(); +const AppViewRecordSchema = z.object({ + text: z.string().optional(), + createdAt: z.string().optional(), +}).passthrough(); +const AppViewEmbedSchema = z.object({ + external: z.object({ + uri: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + thumb: z.string().optional(), + }).passthrough().optional(), +}).passthrough(); + +export const AppViewPostSchema = z.object({ + uri: z.string().optional(), + cid: z.string().optional(), + author: AppViewAuthorSchema.optional(), + record: AppViewRecordSchema.optional(), + indexedAt: z.string().optional(), + likeCount: z.unknown().optional(), + repostCount: z.unknown().optional(), + replyCount: z.unknown().optional(), + quoteCount: z.unknown().optional(), + labels: z.array(AppViewLabelSchema).optional(), + embed: AppViewEmbedSchema.optional(), +}).passthrough(); + +const PublicAppViewPostSchema = AppViewPostSchema.extend({ + uri: z.string().regex(POST_AT_URI_PATTERN), + cid: z.string().trim().min(1), + author: AppViewAuthorSchema.extend({ + did: z.string().trim().min(1), + handle: z.string().trim().min(1), + }), + record: AppViewRecordSchema.extend({ + text: z.string().trim().min(1), + createdAt: z.string().trim().min(1), + }), + indexedAt: z.string().trim().min(1), +}); + +type AppViewLabel = z.infer; +type AppViewAuthor = z.infer; + +export type AppViewPost = z.infer; + +export function hiddenReasonForPublicView(input: unknown): string | null { + if (input === null || input === undefined) { + return 'Post unavailable from Bluesky public AppView'; + } + const parsedPost = AppViewPostSchema.safeParse(input); + if (!parsedPost.success) { + return 'Post metadata unavailable from Bluesky public AppView'; + } + const post = parsedPost.data; + + const labelValues = [ + ...labelValuesFrom(post.labels), + ...labelValuesFrom(post.author?.labels), + ]; + const hiddenLabel = labelValues.find((label) => HIDDEN_LABELS.has(label)); + if (hiddenLabel) { + return `Hidden by Bluesky public-view label ${hiddenLabel}`; + } + + const adultLabel = labelValues.find((label) => ADULT_ONLY_LABELS.has(label)); + if (adultLabel) { + return `Hidden by Bluesky adult-content label ${adultLabel}`; + } + + const record = post.record; + const text = + record && typeof record === 'object' && 'text' in record + ? (record as { text?: unknown }).text + : null; + if (typeof text !== 'string' || text.trim().length === 0) { + return 'Post text unavailable from Bluesky public AppView'; + } + + if (!PublicAppViewPostSchema.safeParse(post).success) { + return 'Post metadata unavailable from Bluesky public AppView'; + } + + return null; +} + +export function publicPostFromAppView(input: unknown): ShadowDemoDisplayPost { + const hiddenReason = hiddenReasonForPublicView(input); + if (hiddenReason) { + return { + kind: 'hidden_post', + reason: hiddenReason, + }; + } + + const parsed = PublicAppViewPostSchema.safeParse(input); + if (!parsed.success) { + return hiddenDisplayPost('Post metadata unavailable from Bluesky public AppView'); + } + const publicPost = parsed.data; + return { + kind: 'public_post', + uri: publicPost.uri, + cid: publicPost.cid, + authorDid: publicPost.author.did, + authorHandle: publicPost.author.handle, + authorDisplayName: displayNameForAuthor(publicPost.author), + authorAvatar: typeof publicPost.author.avatar === 'string' ? publicPost.author.avatar : null, + text: publicPost.record.text, + likeCount: finiteCount(publicPost.likeCount), + repostCount: finiteCount(publicPost.repostCount), + replyCount: finiteCount(publicPost.replyCount), + quoteCount: finiteCount(publicPost.quoteCount), + indexedAt: publicPost.indexedAt, + createdAt: publicPost.record.createdAt, + bskyUrl: bskyPostUrlFromAtUri(publicPost.uri), + } satisfies ShadowDemoPublicPost; +} + +export function hiddenDisplayPost(reason: string): ShadowDemoDisplayPost { + return { + kind: 'hidden_post', + reason, + }; +} + +export function bskyPostUrlFromAtUri(uri: string): string { + const match = POST_AT_URI_PATTERN.exec(uri); + if (!match) { + throw new Error(`Cannot build Bluesky URL for non-post AT-URI: ${uri}`); + } + return `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}`; +} + +function labelValuesFrom(labels: AppViewLabel[] | undefined): string[] { + if (!labels) { + return []; + } + return labels + .map((label) => label.val) + .filter((label): label is string => typeof label === 'string' && label.length > 0); +} + +function displayNameForAuthor(author: AppViewAuthor | undefined): string { + if (typeof author?.displayName === 'string' && author.displayName.trim().length > 0) { + return author.displayName; + } + return author?.handle ?? ''; +} + +function finiteCount(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0; +} diff --git a/src/demo/routes.ts b/src/demo/routes.ts new file mode 100644 index 00000000..9eec9e83 --- /dev/null +++ b/src/demo/routes.ts @@ -0,0 +1,258 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { logger } from '../lib/logger.js'; +import { + SHADOW_DEMO_COMMUNITY_IDS, + SHADOW_DEMO_CONTRACT_VERSION, + type ShadowDemoEnvelope, +} from './types.js'; +import { + DemoConflictError, + DemoNotFoundError, + DemoValidationError, + ShadowDemoService, + createDefaultShadowDemoService, +} from './service.js'; + +const IdempotencyKeySchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9:_-]+$/); + +const WeightSchema = z.object({ + recency: z.number().min(0).finite(), + engagement: z.number().min(0).finite(), + bridging: z.number().min(0).finite(), + source_diversity: z.number().min(0).finite(), + relevance: z.number().min(0).finite(), +}).strict(); + +const TopicIntentSchema = z.object({ + topicWeights: z.record(z.number().min(0).max(1)), +}).strict(); + +const CreateSessionBodySchema = z.object({ + communityId: z.enum(SHADOW_DEMO_COMMUNITY_IDS).optional(), + refreshCorpus: z.boolean().optional(), +}).strict(); + +const SessionParamsSchema = z.object({ + sessionId: z.string().min(1).max(128), +}).strict(); + +const VoteBodySchema = z.object({ + baseEpochId: z.string().min(1).max(64), + weights: WeightSchema, + topicIntent: TopicIntentSchema, + idempotencyKey: IdempotencyKeySchema.optional(), +}).strict(); + +const SyntheticVotersBodySchema = z.object({ + baseEpochId: z.string().min(1).max(64), + idempotencyKey: IdempotencyKeySchema.optional(), +}).strict(); + +const AdvanceBodySchema = z.object({ + fromEpochId: z.string().min(1).max(64), + idempotencyKey: IdempotencyKeySchema.optional(), +}).strict(); + +const FeedQuerySchema = z.object({ + epochId: z.string().min(1).max(64).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}).strict(); + +const ReceiptQuerySchema = z.object({ + epochId: z.string().min(1).max(64).optional(), + postUri: z.string().min(1).max(512), +}).strict(); + +export function registerShadowDemoRoutes( + app: FastifyInstance, + serviceOverride: ShadowDemoService | null +): void { + const service = serviceOverride ?? createDefaultShadowDemoService(); + + app.post('/api/demo/sessions', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const body = parseOrThrow(CreateSessionBodySchema, request.body ?? {}); + return service.createSession({ + communityId: body.communityId ?? 'open_science_builders', + refreshCorpus: body.refreshCorpus ?? false, + }); + }); + }); + + app.get('/api/demo/sessions/:sessionId', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + return service.getSession(params.sessionId); + }); + }); + + app.post('/api/demo/sessions/:sessionId/votes', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + const body = parseOrThrow(VoteBodySchema, request.body ?? {}); + return service.castVote({ + sessionId: params.sessionId, + baseEpochId: body.baseEpochId, + weights: body.weights, + topicIntent: body.topicIntent, + idempotencyKey: idempotencyKeyFrom(request, body.idempotencyKey ?? null), + }); + }); + }); + + app.post('/api/demo/sessions/:sessionId/agents/run', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + const body = parseOrThrow(SyntheticVotersBodySchema, request.body ?? {}); + return service.runSyntheticVoters({ + sessionId: params.sessionId, + baseEpochId: body.baseEpochId, + idempotencyKey: idempotencyKeyFrom(request, body.idempotencyKey ?? null), + }); + }); + }); + + app.post('/api/demo/sessions/:sessionId/epochs/advance', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + const body = parseOrThrow(AdvanceBodySchema, request.body ?? {}); + return service.advanceEpoch({ + sessionId: params.sessionId, + fromEpochId: body.fromEpochId, + idempotencyKey: idempotencyKeyFrom(request, body.idempotencyKey ?? null), + }); + }); + }); + + app.get('/api/demo/sessions/:sessionId/feed', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + const query = parseOrThrow(FeedQuerySchema, request.query); + const limit = query.limit ?? 25; + return service.getFeed({ + sessionId: params.sessionId, + epochId: query.epochId ?? null, + limit, + }); + }); + }); + + app.get('/api/demo/sessions/:sessionId/receipts', { schema: { tags: ['Demo'] } }, async (request, reply) => { + return handleShadowDemoRequest(request, reply, async () => { + const params = parseOrThrow(SessionParamsSchema, request.params); + const query = parseOrThrow(ReceiptQuerySchema, request.query); + return service.getReceipt({ + sessionId: params.sessionId, + epochId: query.epochId ?? null, + postUri: query.postUri, + }); + }); + }); +} + +function parseOrThrow( + schema: TSchema, + value: unknown +): z.infer { + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new DemoValidationError(parsed.error.issues.map((issue) => issue.message).join('; ')); + } + return parsed.data; +} + +function idempotencyKeyFrom(request: FastifyRequest, bodyKey: string | null): string | null { + if (bodyKey) { + return bodyKey; + } + const header = request.headers['idempotency-key']; + if (typeof header !== 'string' || header.length === 0) { + return null; + } + const parsed = IdempotencyKeySchema.safeParse(header); + if (!parsed.success) { + throw new DemoValidationError('idempotency-key header is malformed'); + } + return parsed.data; +} + +function sendEnvelope( + request: FastifyRequest, + reply: FastifyReply, + result: { + sessionId: string | null; + payload: TPayload; + warnings: ShadowDemoEnvelope['warnings']; + } +): FastifyReply { + const envelope: ShadowDemoEnvelope = { + contractVersion: SHADOW_DEMO_CONTRACT_VERSION, + requestId: request.correlationId ?? randomUUID(), + generatedAt: new Date().toISOString(), + sessionId: result.sessionId, + payload: result.payload, + warnings: result.warnings, + }; + return reply.send(envelope); +} + +async function handleShadowDemoRequest( + request: FastifyRequest, + reply: FastifyReply, + operation: () => Promise<{ + sessionId: string | null; + payload: TPayload; + warnings: ShadowDemoEnvelope['warnings']; + }> +): Promise { + try { + const result = await operation(); + return sendEnvelope(request, reply, result); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + const correlationId = request.correlationId ?? randomUUID(); + const status = shadowDemoErrorStatus(error); + if (status >= 500) { + logger.error({ err: error, correlationId }, 'Unexpected shadow demo request failure'); + } + return reply.code(status).send(shadowDemoErrorBody(error, correlationId)); + } +} + +export function shadowDemoErrorStatus(error: Error): number { + if (error instanceof DemoValidationError) { + return 400; + } + if (error instanceof DemoNotFoundError) { + return 404; + } + if (error instanceof DemoConflictError) { + return 409; + } + return 500; +} + +export function shadowDemoErrorBody(error: Error, correlationId: string): { + error: string; + message: string; + correlationId: string; +} { + if ( + error instanceof DemoValidationError || + error instanceof DemoNotFoundError || + error instanceof DemoConflictError + ) { + return { + error: error.name, + message: error.message, + correlationId, + }; + } + return { + error: 'DemoInternalError', + message: 'Shadow demo request failed. Please retry the guided demo.', + correlationId, + }; +} diff --git a/src/demo/service.ts b/src/demo/service.ts new file mode 100644 index 00000000..1990ad77 --- /dev/null +++ b/src/demo/service.ts @@ -0,0 +1,949 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { DEMO_COMMUNITIES, createDefaultCorpusLoader } from './corpus.js'; +import { createSyntheticVoterVotes, getShadowDemoVoterProfiles } from './synthetic-voters.js'; +import { createRedisDemoStore, type DemoStore } from './store.js'; +import { + SHADOW_DEMO_CORPUS_PROVENANCE, + SHADOW_DEMO_GUIDED_EPOCHS, + SHADOW_DEMO_SESSION_TTL_SECONDS, + SHADOW_DEMO_MAX_EPOCHS_PER_SESSION, + SHADOW_DEMO_SHARED_CORPUS_TTL_SECONDS, + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + SHADOW_DEMO_TOTAL_DEMO_VOTERS, + type ShadowDemoCommunityId, + type ShadowDemoCorpus, + type ShadowDemoCorpusItem, + type ShadowDemoCounterfactual, + type ShadowDemoEpoch, + type ShadowDemoFeedPayload, + type ShadowDemoRankedPost, + type ShadowDemoReceiptContribution, + type ShadowDemoReceiptPayload, + type ShadowDemoSessionPayload, + type ShadowDemoSessionState, + type ShadowDemoTopicIntent, + type ShadowDemoVote, + type ShadowDemoWarning, + type ShadowDemoWeights, +} from './types.js'; +import { + aggregateShadowVotes, + engagementOnlyWeights, + scoreFromRawWeights, + validateShadowWeights, +} from './weights.js'; +import { cloneShadowTopicIntent, validateShadowTopicIntent } from './topic-intent.js'; + +const LOCK_TTL_MS = 5000; +const CORPUS_BUILD_LOCK_TTL_MS = 15000; +const CORPUS_BUILD_LOCK_RENEW_INTERVAL_MS = 5000; +const CORPUS_BUILD_WAIT_INTERVAL_MS = 100; +const CORPUS_BUILD_WAIT_MARGIN_MS = 500; + +export class DemoValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'DemoValidationError'; + } +} + +export class DemoConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'DemoConflictError'; + } +} + +export class DemoNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'DemoNotFoundError'; + } +} + +export interface ShadowDemoServiceResult { + sessionId: string | null; + payload: TPayload; + warnings: ShadowDemoWarning[]; +} + +export interface ShadowDemoServiceDependencies { + store: DemoStore; + loadCorpus: (options: { + communityId: ShadowDemoCommunityId; + now: Date; + }) => Promise; + now: () => Date; +} + +export interface CreateSessionRequest { + communityId: ShadowDemoCommunityId; + refreshCorpus: boolean; +} + +export interface CastVoteRequest { + sessionId: string; + baseEpochId: string; + weights: unknown; + topicIntent: unknown; + idempotencyKey: string | null; +} + +export interface RunSyntheticVotersRequest { + sessionId: string; + baseEpochId: string; + idempotencyKey: string | null; +} + +export interface AdvanceEpochRequest { + sessionId: string; + fromEpochId: string; + idempotencyKey: string | null; +} + +export interface GetFeedRequest { + sessionId: string; + epochId: string | null; + limit: number; +} + +export interface GetReceiptRequest { + sessionId: string; + epochId: string | null; + postUri: string; +} + +export class ShadowDemoService { + private readonly store: DemoStore; + private readonly loadCorpus: ShadowDemoServiceDependencies['loadCorpus']; + private readonly now: () => Date; + + constructor(dependencies: ShadowDemoServiceDependencies) { + this.store = dependencies.store; + this.loadCorpus = dependencies.loadCorpus; + this.now = dependencies.now; + } + + async createSession(request: CreateSessionRequest): Promise> { + const now = this.now(); + const corpus = await this.loadCorpusForSession({ + communityId: request.communityId, + now, + refreshCorpus: request.refreshCorpus, + }); + const sessionId = `demo-${randomUUID()}`; + const firstEpoch = createEpoch({ + sequence: 1, + weights: corpus.baseWeights, + topicIntent: corpus.baseTopicIntent, + createdAt: now.toISOString(), + label: 'Baseline policy', + decidedByEpochId: null, + }); + const state: ShadowDemoSessionState = { + sessionId, + communityId: request.communityId, + seed: randomUUID(), + phase: 'created', + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + SHADOW_DEMO_SESSION_TTL_SECONDS * 1000).toISOString(), + corpusId: corpus.corpusId, + currentEpochId: firstEpoch.id, + epochs: [firstEpoch], + votes: [], + corpus, + warnings: corpus.warnings, + }; + + await this.store.writeSession(state, ttlSecondsForState(state, now)); + return { + sessionId, + payload: sessionPayload(state), + warnings: state.warnings, + }; + } + + async getSession(sessionId: string): Promise> { + const state = await this.readRequiredSession(sessionId); + return { + sessionId, + payload: sessionPayload(state), + warnings: state.warnings, + }; + } + + async castVote(request: CastVoteRequest): Promise> { + const operation = async (): Promise> => { + const state = await this.readRequiredSession(request.sessionId); + assertCurrentEpoch(state, request.baseEpochId); + assertPhaseForReviewerVote(state); + const weights = validateWeightsForApi(request.weights); + const topicIntent = validateTopicIntentForApi(request.topicIntent); + const now = this.now().toISOString(); + const reviewerVote: ShadowDemoVote = { + id: `vote-reviewer-${request.baseEpochId}`, + epochId: request.baseEpochId, + actorType: 'reviewer', + actorId: 'reviewer', + label: 'Reviewer', + weights, + topicIntent, + createdAt: now, + }; + + const nextVotes = state.votes + .filter((vote) => !(vote.epochId === request.baseEpochId && vote.actorType === 'reviewer')) + .concat(reviewerVote); + const nextState: ShadowDemoSessionState = { + ...state, + phase: 'reviewer_voted', + votes: nextVotes, + }; + await this.store.writeSession(nextState, ttlSecondsForState(nextState, this.now())); + return { + sessionId: nextState.sessionId, + payload: sessionPayload(nextState), + warnings: nextState.warnings, + }; + }; + + return this.runIdempotent({ + sessionId: request.sessionId, + idempotencyKey: request.idempotencyKey, + requestPayload: request, + operation, + }); + } + + async runSyntheticVoters( + request: RunSyntheticVotersRequest + ): Promise> { + const operation = async (): Promise> => { + const state = await this.readRequiredSession(request.sessionId); + assertCurrentEpoch(state, request.baseEpochId); + if (state.phase !== 'reviewer_voted') { + throw new DemoConflictError('Cast the reviewer vote before running synthetic voters'); + } + const currentEpoch = currentEpochOf(state); + const reviewerVote = latestReviewerVoteForEpoch(state, request.baseEpochId); + if (!reviewerVote) { + throw new DemoConflictError(`Reviewer vote is missing for ${request.baseEpochId}`); + } + const now = this.now().toISOString(); + const syntheticVoterVotes = createSyntheticVoterVotes({ + seed: state.seed, + epochId: request.baseEpochId, + communityId: state.communityId, + reviewerWeights: reviewerVote.weights, + reviewerTopicIntent: reviewerVote.topicIntent, + priorCommunityWeights: currentEpoch.aggregate.weights, + priorTopicIntent: currentEpoch.aggregate.topicIntent, + createdAt: now, + }); + const nextVotes = state.votes + .filter((vote) => !(vote.epochId === request.baseEpochId && vote.actorType === 'synthetic_voter')) + .concat(syntheticVoterVotes); + const nextState: ShadowDemoSessionState = { + ...state, + phase: 'synthetic_voters_ran', + votes: nextVotes, + }; + await this.store.writeSession(nextState, ttlSecondsForState(nextState, this.now())); + return { + sessionId: nextState.sessionId, + payload: sessionPayload(nextState), + warnings: nextState.warnings, + }; + }; + + return this.runIdempotent({ + sessionId: request.sessionId, + idempotencyKey: request.idempotencyKey, + requestPayload: request, + operation, + }); + } + + async advanceEpoch(request: AdvanceEpochRequest): Promise> { + const operation = async (): Promise> => { + const state = await this.readRequiredSession(request.sessionId); + assertCurrentEpoch(state, request.fromEpochId); + const currentEpoch = currentEpochOf(state); + if (currentEpoch.sequence >= SHADOW_DEMO_MAX_EPOCHS_PER_SESSION) { + throw new DemoConflictError( + `Shadow demo session reached the ${SHADOW_DEMO_MAX_EPOCHS_PER_SESSION} epoch limit` + ); + } + if (state.phase !== 'synthetic_voters_ran') { + throw new DemoConflictError('Run the synthetic community voters before advancing the epoch'); + } + const now = this.now().toISOString(); + const decisionVotes = votesForEpoch(state, currentEpoch.id); + assertCompleteDemoElectorate(decisionVotes, currentEpoch.id); + const nextAggregate = aggregateShadowVotes(decisionVotes); + const advancedEpoch: ShadowDemoEpoch = { + ...currentEpoch, + status: 'advanced', + advancedAt: now, + }; + const nextEpoch = createEpoch({ + sequence: currentEpoch.sequence + 1, + weights: nextAggregate.weights, + topicIntent: nextAggregate.topicIntent, + createdAt: now, + label: `Shadow epoch ${currentEpoch.sequence + 1}`, + decidedByEpochId: currentEpoch.id, + }); + const nextState: ShadowDemoSessionState = { + ...state, + phase: 'epoch_advanced', + currentEpochId: nextEpoch.id, + epochs: state.epochs.map((epoch) => (epoch.id === currentEpoch.id ? advancedEpoch : epoch)).concat(nextEpoch), + }; + await this.store.writeSession(nextState, ttlSecondsForState(nextState, this.now())); + return { + sessionId: nextState.sessionId, + payload: sessionPayload(nextState), + warnings: nextState.warnings, + }; + }; + + return this.runIdempotent({ + sessionId: request.sessionId, + idempotencyKey: request.idempotencyKey, + requestPayload: request, + operation, + }); + } + + async getFeed(request: GetFeedRequest): Promise> { + const state = await this.readRequiredSession(request.sessionId); + const epoch = epochByIdOrCurrent(state, request.epochId); + const previousEpoch = previousEpochFor(state, epoch); + const posts = rankedPosts({ + corpus: state.corpus, + epoch, + previousEpoch, + limit: request.limit, + }); + return { + sessionId: state.sessionId, + payload: { + epochId: epoch.id, + corpusId: state.corpusId, + communityId: state.communityId, + corpusHealth: state.corpus.health, + aggregate: epoch.aggregate, + posts, + }, + warnings: state.warnings, + }; + } + + async getReceipt(request: GetReceiptRequest): Promise> { + const state = await this.readRequiredSession(request.sessionId); + const epoch = epochByIdOrCurrent(state, request.epochId); + const item = state.corpus.items.find((candidate) => candidate.postUri === request.postUri); + if (!item) { + throw new DemoValidationError(`Post URI is not part of this frozen demo corpus: ${request.postUri}`); + } + if (item.displayPost.kind !== 'public_post') { + throw new DemoValidationError('Receipt is unavailable for a post hidden by Bluesky public-view policy'); + } + + const previousEpoch = previousEpochFor(state, epoch); + const fullRankedPosts = rankedPosts({ + corpus: state.corpus, + epoch, + previousEpoch, + limit: state.corpus.items.length, + }); + const rankedPost = fullRankedPosts.find( + (candidate) => candidate.post.kind === 'public_post' && candidate.post.uri === request.postUri + ); + if (!rankedPost) { + throw new DemoValidationError(`Ranked post missing from frozen corpus: ${request.postUri}`); + } + if (rankedPost.score === null) { + throw new DemoValidationError(`Receipt score unavailable for hidden post: ${request.postUri}`); + } + + return { + sessionId: state.sessionId, + payload: { + receipt: { + type: 'shadow_demo_receipt', + epochId: epoch.id, + postUri: request.postUri, + visibleRank: rankedPost.rank, + previousRank: rankedPost.previousRank, + score: rankedPost.score, + scoredAt: item.scoredAt, + aggregate: epoch.aggregate, + components: receiptContributions( + item, + epoch.aggregate.weights, + epoch.aggregate.topicIntent + ), + topicSignals: topicSignals(item, epoch.aggregate.topicIntent), + counterfactuals: counterfactualsForReceipt({ + state, + item, + epoch, + visibleRank: rankedPost.rank, + }), + }, + }, + warnings: state.warnings, + }; + } + + private async readRequiredSession(sessionId: string): Promise { + const state = await this.store.readSession(sessionId); + if (!state) { + throw new DemoNotFoundError(`Shadow demo session not found or expired: ${sessionId}`); + } + return state; + } + + private async loadCorpusForSession(options: { + communityId: ShadowDemoCommunityId; + now: Date; + refreshCorpus: boolean; + }): Promise { + const cached = options.refreshCorpus ? null : await this.store.readSharedCorpus(options.communityId); + if (cached) { + return cloneCorpusForSession({ + corpus: cached, + communityId: options.communityId, + now: options.now, + }); + } + + const token = randomUUID(); + const acquired = await this.store.acquireCorpusBuildLock( + options.communityId, + token, + CORPUS_BUILD_LOCK_TTL_MS + ); + if (!acquired) { + if (options.refreshCorpus) { + throw new DemoConflictError( + `Shadow demo corpus refresh is already running for ${options.communityId}; retry shortly` + ); + } + const waited = await this.waitForSharedCorpus(options.communityId); + if (waited) { + return cloneCorpusForSession({ + corpus: waited, + communityId: options.communityId, + now: options.now, + }); + } + throw new DemoConflictError( + `Shadow demo corpus is warming for ${options.communityId}; retry session creation shortly` + ); + } + + const leaseState: { failure: Error | null } = { failure: null }; + let renewalInFlight: Promise = Promise.resolve(); + const renewalTimer = setInterval(() => { + renewalInFlight = renewalInFlight + .then(async () => { + if (leaseState.failure !== null) { + return; + } + const renewed = await this.store.renewCorpusBuildLock( + options.communityId, + token, + CORPUS_BUILD_LOCK_TTL_MS + ); + if (!renewed) { + throw new Error(`Corpus build lease was lost for ${options.communityId}`); + } + }) + .catch((err: unknown) => { + leaseState.failure = err instanceof Error ? err : new Error(String(err)); + }); + }, CORPUS_BUILD_LOCK_RENEW_INTERVAL_MS); + + try { + const corpus = await this.loadCorpus(options); + await renewalInFlight; + if (leaseState.failure !== null) { + throw new DemoConflictError(leaseState.failure.message); + } + const stillOwned = await this.store.renewCorpusBuildLock( + options.communityId, + token, + CORPUS_BUILD_LOCK_TTL_MS + ); + if (!stillOwned) { + throw new DemoConflictError(`Corpus build lease was lost for ${options.communityId}`); + } + await this.store.writeSharedCorpus( + options.communityId, + corpus, + SHADOW_DEMO_SHARED_CORPUS_TTL_SECONDS + ); + return cloneCorpusForSession({ + corpus, + communityId: options.communityId, + now: options.now, + }); + } finally { + clearInterval(renewalTimer); + await renewalInFlight; + if (acquired) { + await this.store.releaseCorpusBuildLock(options.communityId, token); + } + } + } + + private async waitForSharedCorpus( + communityId: ShadowDemoCommunityId + ): Promise { + const maxAttempts = Math.ceil( + (CORPUS_BUILD_LOCK_TTL_MS + CORPUS_BUILD_WAIT_MARGIN_MS) / CORPUS_BUILD_WAIT_INTERVAL_MS + ); + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + await delay(CORPUS_BUILD_WAIT_INTERVAL_MS); + const cached = await this.store.readSharedCorpus(communityId); + if (cached) { + return cached; + } + } + return null; + } + + private async withSessionLock( + sessionId: string, + operation: () => Promise + ): Promise { + const token = randomUUID(); + const acquired = await this.store.acquireSessionLock(sessionId, token, LOCK_TTL_MS); + if (!acquired) { + throw new DemoConflictError(`Shadow demo session is busy: ${sessionId}`); + } + try { + return await operation(); + } finally { + await this.store.releaseSessionLock(sessionId, token); + } + } + + private async runIdempotent(options: { + sessionId: string; + idempotencyKey: string | null; + requestPayload: unknown; + operation: () => Promise; + }): Promise { + return this.withSessionLock(options.sessionId, async () => { + if (!options.idempotencyKey) { + return options.operation(); + } + + const requestHash = hashPayload(options.requestPayload); + const existing = await this.store.readIdempotency( + options.sessionId, + options.idempotencyKey + ); + if (existing) { + if (existing.requestHash !== requestHash) { + throw new DemoConflictError(`Idempotency key reused with a different payload: ${options.idempotencyKey}`); + } + return existing.response; + } + + const response = await options.operation(); + await this.store.writeIdempotency( + options.sessionId, + options.idempotencyKey, + { + requestHash, + response, + createdAt: this.now().toISOString(), + }, + await idempotencyTtlSecondsForSession(this.store, options.sessionId, this.now()) + ); + return response; + }); + } +} + +export function createDefaultShadowDemoService(): ShadowDemoService { + return new ShadowDemoService({ + store: createRedisDemoStore(), + loadCorpus: createDefaultCorpusLoader(), + now: () => new Date(), + }); +} + +function sessionPayload(state: ShadowDemoSessionState): ShadowDemoSessionPayload { + return { + session: { + sessionId: state.sessionId, + community: DEMO_COMMUNITIES[state.communityId], + phase: state.phase, + currentEpochId: state.currentEpochId, + expiresAt: state.expiresAt, + corpusHealth: state.corpus.health, + epochs: state.epochs, + pendingAggregate: pendingAggregateFor(state), + voteCount: state.votes.length, + guidedEpochs: SHADOW_DEMO_GUIDED_EPOCHS, + maxEpochs: SHADOW_DEMO_MAX_EPOCHS_PER_SESSION, + syntheticVoterCount: SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + totalDemoVoters: SHADOW_DEMO_TOTAL_DEMO_VOTERS, + corpusProvenance: SHADOW_DEMO_CORPUS_PROVENANCE, + voterProfiles: getShadowDemoVoterProfiles(), + votes: state.votes, + }, + }; +} + +function cloneCorpusForSession(options: { + corpus: ShadowDemoCorpus; + communityId: ShadowDemoCommunityId; + now: Date; +}): ShadowDemoCorpus { + return { + ...(JSON.parse(JSON.stringify(options.corpus)) as ShadowDemoCorpus), + corpusId: `corpus-${randomUUID()}`, + communityId: options.communityId, + createdAt: options.now.toISOString(), + expiresAt: new Date(options.now.getTime() + SHADOW_DEMO_SESSION_TTL_SECONDS * 1000).toISOString(), + }; +} + +function ttlSecondsForState(state: ShadowDemoSessionState, now: Date): number { + const expiresAt = new Date(state.expiresAt).getTime(); + const ttlMs = expiresAt - now.getTime(); + return Math.max(1, Math.ceil(ttlMs / 1000)); +} + +async function idempotencyTtlSecondsForSession( + store: DemoStore, + sessionId: string, + now: Date +): Promise { + const state = await store.readSession(sessionId); + return state ? ttlSecondsForState(state, now) : SHADOW_DEMO_SESSION_TTL_SECONDS; +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function createEpoch(options: { + sequence: number; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; + createdAt: string; + label: string; + decidedByEpochId: string | null; +}): ShadowDemoEpoch { + return { + id: `shadow-epoch-${options.sequence}`, + sequence: options.sequence, + label: options.label, + status: 'open', + createdAt: options.createdAt, + advancedAt: null, + decidedByEpochId: options.decidedByEpochId, + aggregate: { + aggregateMethod: 'trimmed_mean_no_trim_under_10', + voteCount: 0, + trimCount: 0, + weights: options.weights, + topicIntent: cloneShadowTopicIntent(options.topicIntent), + }, + }; +} + +function validateWeightsForApi(value: unknown): ShadowDemoWeights { + try { + return validateShadowWeights(value); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new DemoValidationError(message); + } +} + +function validateTopicIntentForApi(value: unknown): ShadowDemoTopicIntent { + try { + return validateShadowTopicIntent(value); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new DemoValidationError(message); + } +} + +function assertCurrentEpoch(state: ShadowDemoSessionState, epochId: string): void { + if (state.currentEpochId !== epochId) { + throw new DemoConflictError( + `Stale shadow demo epoch. Current epoch is ${state.currentEpochId}; received ${epochId}` + ); + } +} + +function currentEpochOf(state: ShadowDemoSessionState): ShadowDemoEpoch { + const epoch = state.epochs.find((candidate) => candidate.id === state.currentEpochId); + if (!epoch) { + throw new DemoConflictError(`Current shadow demo epoch is missing: ${state.currentEpochId}`); + } + return epoch; +} + +function epochByIdOrCurrent(state: ShadowDemoSessionState, epochId: string | null): ShadowDemoEpoch { + if (!epochId) { + return currentEpochOf(state); + } + const epoch = state.epochs.find((candidate) => candidate.id === epochId); + if (!epoch) { + throw new DemoValidationError(`Unknown shadow demo epoch for this session: ${epochId}`); + } + return epoch; +} + +function previousEpochFor(state: ShadowDemoSessionState, epoch: ShadowDemoEpoch): ShadowDemoEpoch | null { + const previousSequence = epoch.sequence - 1; + return state.epochs.find((candidate) => candidate.sequence === previousSequence) ?? null; +} + +function latestReviewerVoteForEpoch( + state: ShadowDemoSessionState, + epochId: string +): ShadowDemoVote | null { + const vote = [...state.votes] + .reverse() + .find((candidate) => candidate.epochId === epochId && candidate.actorType === 'reviewer'); + return vote ?? null; +} + +function votesForEpoch(state: ShadowDemoSessionState, epochId: string): ShadowDemoVote[] { + return state.votes.filter((vote) => vote.epochId === epochId); +} + +function pendingAggregateFor(state: ShadowDemoSessionState): ReturnType | null { + const votes = votesForEpoch(state, state.currentEpochId); + return votes.length > 0 ? aggregateShadowVotes(votes) : null; +} + +function assertPhaseForReviewerVote(state: ShadowDemoSessionState): void { + if (!['created', 'epoch_advanced', 'reviewer_voted'].includes(state.phase)) { + throw new DemoConflictError(`Cannot cast a reviewer vote during phase ${state.phase}`); + } +} + +function assertCompleteDemoElectorate(votes: ShadowDemoVote[], epochId: string): void { + const reviewerVotes = votes.filter((vote) => vote.actorType === 'reviewer').length; + const syntheticVotes = votes.filter((vote) => vote.actorType === 'synthetic_voter').length; + if (reviewerVotes !== 1 || syntheticVotes !== SHADOW_DEMO_SYNTHETIC_VOTER_COUNT) { + throw new DemoConflictError( + `Shadow epoch ${epochId} requires 1 reviewer and ${SHADOW_DEMO_SYNTHETIC_VOTER_COUNT} synthetic votes; received ${reviewerVotes} reviewer and ${syntheticVotes} synthetic votes` + ); + } +} + +function rankedPosts(options: { + corpus: ShadowDemoCorpus; + epoch: ShadowDemoEpoch; + previousEpoch: ShadowDemoEpoch | null; + limit: number; +}): ShadowDemoRankedPost[] { + const previousRanks = options.previousEpoch + ? rankMapForEpoch(options.corpus.items, options.previousEpoch) + : new Map(); + return options.corpus.items + .map((item) => { + const scored = scoreFromRawWeights( + item.rawScores, + options.epoch.aggregate.weights, + item.topicVector, + options.epoch.aggregate.topicIntent + ); + return { + item, + score: scored.score, + weightedComponents: scored.weightedComponents, + effectiveRawScores: scored.effectiveRawScores, + }; + }) + .sort((left, right) => right.score - left.score || left.item.postUri.localeCompare(right.item.postUri)) + .slice(0, options.limit) + .map((entry, index) => { + const rank = index + 1; + const previousRank = previousRanks.get(entry.item.postUri) ?? null; + return { + rank, + previousRank, + movement: previousRank === null ? null : previousRank - rank, + score: entry.item.displayPost.kind === 'public_post' ? entry.score : null, + weightedComponents: + entry.item.displayPost.kind === 'public_post' ? entry.weightedComponents : null, + rawScores: entry.item.displayPost.kind === 'public_post' ? entry.effectiveRawScores : null, + post: entry.item.displayPost, + }; + }); +} + +function rankMapForEpoch(items: ShadowDemoCorpusItem[], epoch: ShadowDemoEpoch): Map { + const ranks = new Map(); + items + .map((item) => ({ + item, + score: scoreFromRawWeights( + item.rawScores, + epoch.aggregate.weights, + item.topicVector, + epoch.aggregate.topicIntent + ).score, + })) + .sort((left, right) => right.score - left.score || left.item.postUri.localeCompare(right.item.postUri)) + .forEach((entry, index) => ranks.set(entry.item.postUri, index + 1)); + return ranks; +} + +function receiptContributions( + item: ShadowDemoCorpusItem, + weights: ShadowDemoWeights, + topicIntent: ShadowDemoTopicIntent +): ShadowDemoReceiptContribution[] { + const scored = scoreFromRawWeights(item.rawScores, weights, item.topicVector, topicIntent); + return Object.entries(scored.weightedComponents).map(([signal, contribution]) => ({ + signal: signal as ShadowDemoReceiptContribution['signal'], + rawScore: scored.effectiveRawScores[signal as keyof typeof scored.effectiveRawScores], + weight: weights[signal as keyof ShadowDemoWeights], + contribution, + })); +} + +function topicSignals( + item: ShadowDemoCorpusItem, + topicIntent: ShadowDemoTopicIntent +): Array<{ topic: string; postScore: number }> { + return Object.entries(item.topicVector) + .filter(([topic, value]) => + Object.hasOwn(topicIntent.topicWeights, topic) && + typeof value === 'number' && + Number.isFinite(value) + ) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 5) + .map(([topic, postScore]) => ({ topic, postScore })); +} + +function counterfactualsForReceipt(options: { + state: ShadowDemoSessionState; + item: ShadowDemoCorpusItem; + epoch: ShadowDemoEpoch; + visibleRank: number; +}): ShadowDemoCounterfactual[] { + const previousEpoch = previousEpochFor(options.state, options.epoch); + const previousWeights = previousEpoch?.aggregate.weights ?? options.state.corpus.baseWeights; + const previousTopicIntent = previousEpoch?.aggregate.topicIntent ?? options.state.corpus.baseTopicIntent; + const withoutReviewer = aggregateWithoutReviewerVote(options.state, options.epoch); + return [ + counterfactualForWeights({ + label: 'previous_epoch', + state: options.state, + item: options.item, + weights: previousWeights, + topicIntent: previousTopicIntent, + visibleRank: options.visibleRank, + }), + counterfactualForWeights({ + label: 'engagement_only', + state: options.state, + item: options.item, + weights: engagementOnlyWeights(), + topicIntent: options.epoch.aggregate.topicIntent, + visibleRank: options.visibleRank, + }), + counterfactualForWeights({ + label: 'without_reviewer_vote', + state: options.state, + item: options.item, + weights: withoutReviewer.weights, + topicIntent: withoutReviewer.topicIntent, + visibleRank: options.visibleRank, + }), + ]; +} + +function aggregateWithoutReviewerVote( + state: ShadowDemoSessionState, + epoch: ShadowDemoEpoch +): ShadowDemoEpoch['aggregate'] { + const decisionEpochId = epoch.decidedByEpochId; + if (!decisionEpochId) { + return previousEpochFor(state, epoch)?.aggregate ?? epoch.aggregate; + } + const nonReviewerVotes = state.votes + .filter((vote) => vote.epochId === decisionEpochId && vote.actorType !== 'reviewer'); + if (nonReviewerVotes.length === 0) { + return previousEpochFor(state, epoch)?.aggregate ?? epoch.aggregate; + } + return aggregateShadowVotes(nonReviewerVotes); +} + +function counterfactualForWeights(options: { + label: ShadowDemoCounterfactual['label']; + state: ShadowDemoSessionState; + item: ShadowDemoCorpusItem; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; + visibleRank: number; +}): ShadowDemoCounterfactual { + const rank = rankForWeights( + options.state.corpus.items, + options.item.postUri, + options.weights, + options.topicIntent + ); + return { + label: options.label, + rank, + deltaFromVisible: rank - options.visibleRank, + }; +} + +function rankForWeights( + items: ShadowDemoCorpusItem[], + postUri: string, + weights: ShadowDemoWeights, + topicIntent: ShadowDemoTopicIntent +): number { + const ranked = items + .map((item) => ({ + postUri: item.postUri, + score: scoreFromRawWeights(item.rawScores, weights, item.topicVector, topicIntent).score, + })) + .sort((left, right) => right.score - left.score || left.postUri.localeCompare(right.postUri)); + const index = ranked.findIndex((entry) => entry.postUri === postUri); + if (index < 0) { + throw new DemoValidationError(`Post URI is not rankable in this corpus: ${postUri}`); + } + return index + 1; +} + +function hashPayload(value: unknown): string { + return createHash('sha256').update(stableStringify(value)).digest('hex'); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; +} diff --git a/src/demo/store.ts b/src/demo/store.ts new file mode 100644 index 00000000..bace270a --- /dev/null +++ b/src/demo/store.ts @@ -0,0 +1,464 @@ +import { Redis } from 'ioredis'; +import { z, type ZodTypeAny } from 'zod'; +import { logger } from '../lib/logger.js'; +import { + SHADOW_DEMO_COMMUNITY_IDS, + SHADOW_DEMO_PHASES, + SHADOW_DEMO_SIGNAL_KEYS, + SHADOW_DEMO_VOTER_BLOC_IDS, + type ShadowDemoCommunityId, + type ShadowDemoCorpus, + type ShadowDemoSessionState, +} from './types.js'; + +const StoredWarningSchema = z.object({ + code: z.string().min(1), + message: z.string().min(1), + severity: z.enum(['info', 'warning', 'degraded']), +}); + +const StoredWeightsSchema = z.object( + Object.fromEntries(SHADOW_DEMO_SIGNAL_KEYS.map((key) => [key, z.number().finite()])) as Record< + (typeof SHADOW_DEMO_SIGNAL_KEYS)[number], + z.ZodNumber + > +); + +const StoredTopicIntentSchema = z.object({ + topicWeights: z.record(z.number().finite()), +}); + +const StoredDisplayPostSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('public_post'), + uri: z.string().min(1), + cid: z.string().min(1), + authorDid: z.string().min(1), + authorHandle: z.string().min(1), + authorDisplayName: z.string().min(1), + authorAvatar: z.string().nullable(), + text: z.string().min(1), + likeCount: z.number().int().nonnegative(), + repostCount: z.number().int().nonnegative(), + replyCount: z.number().int().nonnegative(), + quoteCount: z.number().int().nonnegative(), + indexedAt: z.string().min(1), + createdAt: z.string().min(1), + bskyUrl: z.string().url(), + }), + z.object({ + kind: z.literal('hidden_post'), + reason: z.string().min(1), + }), +]); + +const StoredCorpusItemSchema = z.object({ + postUri: z.string().min(1), + authorDid: z.string().min(1).nullable(), + createdAt: z.string().min(1), + topicVector: z.record(z.number().finite()), + rawScores: StoredWeightsSchema, + productionScore: z.number().finite(), + productionEpochId: z.number().int(), + scoredAt: z.string().min(1), + componentDetails: z.record(z.unknown()).nullable(), + displayPost: StoredDisplayPostSchema, +}); + +const StoredCorpusHealthSchema = z.object({ + status: z.enum(['live', 'degraded']), + source: z.enum(['production_scores_appview', 'fixture_fallback']), + candidatePosts72h: z.number().int().nonnegative(), + publicScoredPosts: z.number().int().nonnegative(), + uniqueAuthors72h: z.number().int().nonnegative(), + bridgePostShare: z.number().finite().nonnegative(), + topAuthorConcentration: z.number().finite().nonnegative(), + sampledAt: z.string().min(1), +}); + +const StoredCorpusSchema = z.object({ + corpusId: z.string().min(1), + communityId: z.enum(SHADOW_DEMO_COMMUNITY_IDS), + baseProductionEpochId: z.number().int(), + baseWeights: StoredWeightsSchema, + baseTopicIntent: StoredTopicIntentSchema, + createdAt: z.string().min(1), + expiresAt: z.string().min(1), + items: z.array(StoredCorpusItemSchema), + health: StoredCorpusHealthSchema, + warnings: z.array(StoredWarningSchema), +}); + +const StoredVoteSummarySchema = z.object({ + aggregateMethod: z.literal('trimmed_mean_no_trim_under_10'), + voteCount: z.number().int().nonnegative(), + trimCount: z.number().int().nonnegative(), + weights: StoredWeightsSchema, + topicIntent: StoredTopicIntentSchema, +}); + +const StoredEpochSchema = z.object({ + id: z.string().min(1), + sequence: z.number().int().positive(), + label: z.string().min(1), + status: z.enum(['open', 'advanced']), + createdAt: z.string().min(1), + advancedAt: z.string().min(1).nullable(), + decidedByEpochId: z.string().min(1).nullable(), + aggregate: StoredVoteSummarySchema, +}); + +const StoredVoteBaseSchema = z.object({ + id: z.string().min(1), + epochId: z.string().min(1), + label: z.string().min(1), + weights: StoredWeightsSchema, + topicIntent: StoredTopicIntentSchema, + createdAt: z.string().min(1), +}); + +const StoredVoteSchema = z.discriminatedUnion('actorType', [ + StoredVoteBaseSchema.extend({ + actorType: z.literal('reviewer'), + actorId: z.literal('reviewer'), + }), + StoredVoteBaseSchema.extend({ + actorType: z.literal('synthetic_voter'), + actorId: z.string().regex(/^synthetic-[a-z_]+-\d+$/), + blocId: z.enum(SHADOW_DEMO_VOTER_BLOC_IDS), + }), +]); + +const StoredSessionSchema = z.object({ + sessionId: z.string().min(1), + communityId: z.enum(SHADOW_DEMO_COMMUNITY_IDS), + seed: z.string().min(1), + phase: z.enum(SHADOW_DEMO_PHASES), + createdAt: z.string().min(1), + expiresAt: z.string().min(1), + corpusId: z.string().min(1), + currentEpochId: z.string().min(1), + epochs: z.array(StoredEpochSchema), + votes: z.array(StoredVoteSchema), + corpus: StoredCorpusSchema, + warnings: z.array(StoredWarningSchema), +}); + +const StoredIdempotencySchema = z.object({ + requestHash: z.string().min(1), + response: z.unknown(), + createdAt: z.string().min(1), +}).passthrough(); + +export class DemoStoreCorruptionError extends Error { + constructor(key: string, detail: string) { + super(`Invalid shadow demo Redis record at ${key}: ${detail}`); + this.name = 'DemoStoreCorruptionError'; + } +} + +export interface IdempotencyRecord { + requestHash: string; + response: TPayload; + createdAt: string; +} + +export interface DemoStore { + readSession(sessionId: string): Promise; + writeSession(session: ShadowDemoSessionState, ttlSeconds: number): Promise; + readIdempotency(sessionId: string, key: string): Promise | null>; + writeIdempotency( + sessionId: string, + key: string, + record: IdempotencyRecord, + ttlSeconds: number + ): Promise; + readSharedCorpus(communityId: ShadowDemoCommunityId): Promise; + writeSharedCorpus(communityId: ShadowDemoCommunityId, corpus: ShadowDemoCorpus, ttlSeconds: number): Promise; + acquireCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string, ttlMs: number): Promise; + renewCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string, ttlMs: number): Promise; + releaseCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string): Promise; + acquireSessionLock(sessionId: string, token: string, ttlMs: number): Promise; + releaseSessionLock(sessionId: string, token: string): Promise; +} + +export class RedisDemoStore implements DemoStore { + private readonly redis: Redis; + + constructor(redis: Redis) { + this.redis = redis; + } + + async readSession(sessionId: string): Promise { + const key = sessionKey(sessionId); + const raw = await this.redis.get(key); + return raw ? parseStoredRecord(StoredSessionSchema, raw, key) : null; + } + + async writeSession(session: ShadowDemoSessionState, ttlSeconds: number): Promise { + const result = await this.redis + .multi() + .setex(sessionKey(session.sessionId), ttlSeconds, JSON.stringify(session)) + .setex(corpusKey(session.corpus.corpusId), ttlSeconds, JSON.stringify(session.corpus)) + .exec(); + if (result === null) { + throw new Error(`Shadow demo Redis transaction aborted for session ${session.sessionId}`); + } + const failedCommand = result.find(([error]) => error !== null); + if (failedCommand !== undefined) { + throw new Error( + `Shadow demo Redis transaction failed for session ${session.sessionId}: ${String(failedCommand[0])}` + ); + } + } + + async readIdempotency(sessionId: string, key: string): Promise | null> { + const redisKey = idempotencyKey(sessionId, key); + const raw = await this.redis.get(redisKey); + return raw + ? parseStoredRecord>(StoredIdempotencySchema, raw, redisKey) + : null; + } + + async writeIdempotency( + sessionId: string, + key: string, + record: IdempotencyRecord, + ttlSeconds: number + ): Promise { + await this.redis.setex(idempotencyKey(sessionId, key), ttlSeconds, JSON.stringify(record)); + } + + async readSharedCorpus(communityId: ShadowDemoCommunityId): Promise { + const key = sharedCorpusKey(communityId); + const raw = await this.redis.get(key); + return raw ? parseStoredRecord(StoredCorpusSchema, raw, key) : null; + } + + async writeSharedCorpus( + communityId: ShadowDemoCommunityId, + corpus: ShadowDemoCorpus, + ttlSeconds: number + ): Promise { + await this.redis.setex(sharedCorpusKey(communityId), ttlSeconds, JSON.stringify(corpus)); + } + + async acquireCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string, ttlMs: number): Promise { + const result = await this.redis.set(corpusBuildLockKey(communityId), token, 'PX', ttlMs, 'NX'); + return result === 'OK'; + } + + async renewCorpusBuildLock( + communityId: ShadowDemoCommunityId, + token: string, + ttlMs: number + ): Promise { + const renewed = await this.redis.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end", + 1, + corpusBuildLockKey(communityId), + token, + ttlMs + ); + return renewed === 1; + } + + async releaseCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string): Promise { + await this.redis.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + 1, + corpusBuildLockKey(communityId), + token + ); + } + + async acquireSessionLock(sessionId: string, token: string, ttlMs: number): Promise { + const result = await this.redis.set(lockKey(sessionId), token, 'PX', ttlMs, 'NX'); + return result === 'OK'; + } + + async releaseSessionLock(sessionId: string, token: string): Promise { + await this.redis.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + 1, + lockKey(sessionId), + token + ); + } +} + +export class MemoryDemoStore implements DemoStore { + private readonly sessions = new Map(); + private readonly idempotency = new Map>(); + private readonly sharedCorpus = new Map(); + private readonly locks = new Map(); + + async readSession(sessionId: string): Promise { + return this.sessions.get(sessionId) ?? null; + } + + async writeSession(session: ShadowDemoSessionState, _ttlSeconds: number): Promise { + this.sessions.set(session.sessionId, JSON.parse(JSON.stringify(session)) as ShadowDemoSessionState); + } + + async readIdempotency(sessionId: string, key: string): Promise | null> { + return (this.idempotency.get(idempotencyKey(sessionId, key)) as IdempotencyRecord | undefined) ?? null; + } + + async writeIdempotency( + sessionId: string, + key: string, + record: IdempotencyRecord, + _ttlSeconds: number + ): Promise { + this.idempotency.set(idempotencyKey(sessionId, key), record as IdempotencyRecord); + } + + async readSharedCorpus(communityId: ShadowDemoCommunityId): Promise { + return this.sharedCorpus.get(communityId) ?? null; + } + + async writeSharedCorpus( + communityId: ShadowDemoCommunityId, + corpus: ShadowDemoCorpus, + _ttlSeconds: number + ): Promise { + this.sharedCorpus.set(communityId, JSON.parse(JSON.stringify(corpus)) as ShadowDemoCorpus); + } + + async acquireCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string, _ttlMs: number): Promise { + const key = corpusBuildLockKey(communityId); + if (this.locks.has(key)) { + return false; + } + this.locks.set(key, token); + return true; + } + + async renewCorpusBuildLock( + communityId: ShadowDemoCommunityId, + token: string, + _ttlMs: number + ): Promise { + return this.locks.get(corpusBuildLockKey(communityId)) === token; + } + + async releaseCorpusBuildLock(communityId: ShadowDemoCommunityId, token: string): Promise { + const key = corpusBuildLockKey(communityId); + if (this.locks.get(key) === token) { + this.locks.delete(key); + } + } + + async acquireSessionLock(sessionId: string, token: string, _ttlMs: number): Promise { + const key = lockKey(sessionId); + if (this.locks.has(key)) { + return false; + } + this.locks.set(key, token); + return true; + } + + async releaseSessionLock(sessionId: string, token: string): Promise { + const key = lockKey(sessionId); + if (this.locks.get(key) === token) { + this.locks.delete(key); + } + } +} + +export function createRedisDemoStore(): DemoStore { + const redis = new Redis(redisUrlFromEnv(), { + maxRetriesPerRequest: 3, + commandTimeout: redisCommandTimeoutFromEnv(), + retryStrategy(times: number) { + return Math.min(times * 50, 2000); + }, + }); + redis.on('error', (err: Error) => { + logger.error({ err }, 'Shadow demo Redis connection error'); + }); + return new RedisDemoStore(redis); +} + +function parseStoredRecord(schema: ZodTypeAny, raw: string, key: string): TRecord { + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new DemoStoreCorruptionError(key, `malformed JSON (${detail})`); + } + const result = schema.safeParse(value); + if (!result.success) { + throw new DemoStoreCorruptionError( + key, + result.error.issues.map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`).join('; ') + ); + } + return result.data as TRecord; +} + +function redisUrlFromEnv(): string { + const redisUrl = process.env.REDIS_URL; + if (!redisUrl || !redisUrl.startsWith('redis://')) { + throw new Error('Shadow demo Redis store requires REDIS_URL with redis:// scheme'); + } + return redisUrl; +} + +function redisCommandTimeoutFromEnv(): number { + const raw = process.env.REDIS_COMMAND_TIMEOUT_MS; + if (!raw) { + return 5000; + } + const timeoutMs = Number(raw); + if (!Number.isInteger(timeoutMs) || timeoutMs < 100) { + throw new Error(`Shadow demo Redis store requires REDIS_COMMAND_TIMEOUT_MS >= 100; received ${raw}`); + } + return timeoutMs; +} + +export function demoSessionKeyPrefix(): string { + return 'demo:session:'; +} + +export function demoCorpusKeyPrefix(): string { + return 'demo:corpus:'; +} + +export function demoIdempotencyKeyPrefix(): string { + return 'demo:idempotency:'; +} + +export function demoLockKeyPrefix(): string { + return 'demo:lock:'; +} + +export function demoSharedCorpusKeyPrefix(): string { + return 'demo:corpus:current:'; +} + +function sessionKey(sessionId: string): string { + return `${demoSessionKeyPrefix()}${sessionId}`; +} + +function corpusKey(corpusId: string): string { + return `${demoCorpusKeyPrefix()}${corpusId}`; +} + +function sharedCorpusKey(communityId: ShadowDemoCommunityId): string { + return `${demoSharedCorpusKeyPrefix()}${communityId}`; +} + +function idempotencyKey(sessionId: string, key: string): string { + return `${demoIdempotencyKeyPrefix()}${sessionId}:${key}`; +} + +function lockKey(sessionId: string): string { + return `${demoLockKeyPrefix()}${sessionId}`; +} + +function corpusBuildLockKey(communityId: ShadowDemoCommunityId): string { + return `${demoLockKeyPrefix()}corpus:${communityId}`; +} diff --git a/src/demo/synthetic-voters.ts b/src/demo/synthetic-voters.ts new file mode 100644 index 00000000..3c0a2829 --- /dev/null +++ b/src/demo/synthetic-voters.ts @@ -0,0 +1,250 @@ +import { normalizeShadowWeights } from './weights.js'; +import { + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + SHADOW_DEMO_VOTER_BLOC_IDS, + type ShadowDemoSyntheticVoterId, + type ShadowDemoVoterBlocId, + type ShadowDemoVote, + type ShadowDemoTopicIntent, + type ShadowDemoWeights, +} from './types.js'; + +export interface SyntheticVoterBlocProfile { + id: ShadowDemoVoterBlocId; + label: string; + voterCount: number; + baseWeights: ShadowDemoWeights; + baseTopicWeights: Record; + reviewerBlend: number; + policyInertia: number; +} + +const SYNTHETIC_VOTER_BLOC_PROFILES: SyntheticVoterBlocProfile[] = [ + { + id: 'research_practitioner', + label: 'Research Practitioners', + voterCount: 5, + reviewerBlend: 0.18, + policyInertia: 0.27, + baseWeights: { + recency: 0.24, + engagement: 0.08, + bridging: 0.15, + source_diversity: 0.16, + relevance: 0.37, + }, + baseTopicWeights: { + 'science-research': 0.9, + 'data-science': 0.7, + 'software-development': 0.45, + 'open-source': 0.5, + }, + }, + { + id: 'dataset_steward', + label: 'Data Stewards', + voterCount: 5, + reviewerBlend: 0.22, + policyInertia: 0.3, + baseWeights: { + recency: 0.08, + engagement: 0.12, + bridging: 0.18, + source_diversity: 0.2, + relevance: 0.42, + }, + baseTopicWeights: { + 'science-research': 0.65, + 'data-science': 0.92, + 'software-development': 0.72, + 'open-source': 0.82, + }, + }, + { + id: 'current_awareness', + label: 'Current-Awareness Readers', + voterCount: 5, + reviewerBlend: 0.2, + policyInertia: 0.24, + baseWeights: { + recency: 0.42, + engagement: 0.14, + bridging: 0.12, + source_diversity: 0.1, + relevance: 0.22, + }, + baseTopicWeights: { + 'science-research': 0.82, + 'data-science': 0.45, + 'software-development': 0.35, + 'open-source': 0.4, + }, + }, + { + id: 'community_discussant', + label: 'Community Discussants', + voterCount: 4, + reviewerBlend: 0.16, + policyInertia: 0.2, + baseWeights: { + recency: 0.13, + engagement: 0.48, + bridging: 0.09, + source_diversity: 0.08, + relevance: 0.22, + }, + baseTopicWeights: { + 'science-research': 0.4, + 'data-science': 0.35, + 'software-development': 0.62, + 'open-source': 0.48, + }, + }, + { + id: 'interdisciplinary_connector', + label: 'Interdisciplinary Connectors', + voterCount: 5, + reviewerBlend: 0.24, + policyInertia: 0.32, + baseWeights: { + recency: 0.12, + engagement: 0.1, + bridging: 0.42, + source_diversity: 0.2, + relevance: 0.16, + }, + baseTopicWeights: { + 'science-research': 0.78, + 'data-science': 0.8, + 'software-development': 0.82, + 'open-source': 0.8, + }, + }, +]; + +export function getShadowDemoVoterProfiles(): SyntheticVoterBlocProfile[] { + return SYNTHETIC_VOTER_BLOC_PROFILES.map((profile) => ({ + ...profile, + baseWeights: { ...profile.baseWeights }, + baseTopicWeights: { ...profile.baseTopicWeights }, + })); +} + +export function createSyntheticVoterVotes(options: { + seed: string; + epochId: string; + communityId: string; + reviewerWeights: ShadowDemoWeights; + reviewerTopicIntent: ShadowDemoTopicIntent; + priorCommunityWeights: ShadowDemoWeights; + priorTopicIntent: ShadowDemoTopicIntent; + createdAt: string; +}): ShadowDemoVote[] { + return SYNTHETIC_VOTER_BLOC_PROFILES.flatMap((profile) => + Array.from({ length: profile.voterCount }, (_unused, voterIndex) => { + const voterNumber = voterIndex + 1; + const actorId = syntheticVoterId(profile.id, voterNumber); + const blended = {} as ShadowDemoWeights; + const baseBlend = 1 - profile.reviewerBlend - profile.policyInertia; + for (const key of Object.keys(profile.baseWeights) as Array) { + const jitter = deterministicJitter( + `${options.seed}:${options.communityId}:${options.epochId}:${actorId}:${key}` + ); + const base = profile.baseWeights[key] * baseBlend; + const reviewer = options.reviewerWeights[key] * profile.reviewerBlend; + const priorPolicy = options.priorCommunityWeights[key] * profile.policyInertia; + blended[key] = Math.max(0.001, base + reviewer + priorPolicy + jitter); + } + + const topicIntent = syntheticTopicIntent({ + profile, + actorId, + seed: options.seed, + communityId: options.communityId, + epochId: options.epochId, + reviewerTopicIntent: options.reviewerTopicIntent, + priorTopicIntent: options.priorTopicIntent, + }); + + return { + id: `vote-${actorId}-${options.epochId}`, + epochId: options.epochId, + actorType: 'synthetic_voter', + actorId, + blocId: profile.id, + label: `${profile.label} voter ${voterNumber}`, + weights: normalizeShadowWeights(blended), + topicIntent, + createdAt: options.createdAt, + }; + }) + ); +} + +export function assertSyntheticVoterProfiles(): void { + const configured = new Set(SYNTHETIC_VOTER_BLOC_PROFILES.map((profile) => profile.id)); + for (const id of SHADOW_DEMO_VOTER_BLOC_IDS) { + if (!configured.has(id)) { + throw new Error(`Missing shadow demo voter bloc profile for ${id}`); + } + } + for (const profile of SYNTHETIC_VOTER_BLOC_PROFILES) { + if (profile.reviewerBlend < 0 || profile.policyInertia < 0 || profile.reviewerBlend + profile.policyInertia >= 1) { + throw new Error(`Invalid shadow demo blend configuration for ${profile.id}`); + } + } + const configuredVoterCount = SYNTHETIC_VOTER_BLOC_PROFILES.reduce( + (sum, profile) => sum + profile.voterCount, + 0 + ); + if (configuredVoterCount !== SHADOW_DEMO_SYNTHETIC_VOTER_COUNT) { + throw new Error( + `Shadow demo synthetic voter count must be ${SHADOW_DEMO_SYNTHETIC_VOTER_COUNT}; received ${configuredVoterCount}` + ); + } +} + +function syntheticTopicIntent(options: { + profile: SyntheticVoterBlocProfile; + actorId: ShadowDemoSyntheticVoterId; + seed: string; + communityId: string; + epochId: string; + reviewerTopicIntent: ShadowDemoTopicIntent; + priorTopicIntent: ShadowDemoTopicIntent; +}): ShadowDemoTopicIntent { + const slugs = Array.from(new Set([ + ...Object.keys(options.profile.baseTopicWeights), + ...Object.keys(options.reviewerTopicIntent.topicWeights), + ...Object.keys(options.priorTopicIntent.topicWeights), + ])).sort(); + const baseBlend = 1 - options.profile.reviewerBlend - options.profile.policyInertia; + const topicWeights: Record = {}; + for (const slug of slugs) { + const base = (options.profile.baseTopicWeights[slug] ?? 0.2) * baseBlend; + const reviewer = (options.reviewerTopicIntent.topicWeights[slug] ?? 0.2) * options.profile.reviewerBlend; + const prior = (options.priorTopicIntent.topicWeights[slug] ?? 0.2) * options.profile.policyInertia; + const jitter = deterministicJitter( + `${options.seed}:${options.communityId}:${options.epochId}:${options.actorId}:topic:${slug}` + ); + topicWeights[slug] = Math.min(1, Math.max(0, Number((base + reviewer + prior + jitter).toFixed(3)))); + } + return { topicWeights }; +} + +function syntheticVoterId( + blocId: ShadowDemoVoterBlocId, + voterNumber: number +): ShadowDemoSyntheticVoterId { + return `synthetic-${blocId}-${voterNumber}` as ShadowDemoSyntheticVoterId; +} + +function deterministicJitter(input: string): number { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + const unit = (hash >>> 0) / 0xffffffff; + return (unit - 0.5) * 0.025; +} diff --git a/src/demo/topic-intent.ts b/src/demo/topic-intent.ts new file mode 100644 index 00000000..20b48d81 --- /dev/null +++ b/src/demo/topic-intent.ts @@ -0,0 +1,69 @@ +import type { ShadowDemoTopicIntent } from './types.js'; +import { aggregateRowsWithTrimmedMean } from '../governance/aggregation-math.js'; + +const MAX_TOPIC_COUNT = 32; +const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function validateShadowTopicIntent(value: unknown): ShadowDemoTopicIntent { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Shadow demo topic intent must be an object'); + } + const topicWeightsValue = (value as Record).topicWeights; + if (!topicWeightsValue || typeof topicWeightsValue !== 'object' || Array.isArray(topicWeightsValue)) { + throw new Error('Shadow demo topic intent must include topicWeights'); + } + + const entries = Object.entries(topicWeightsValue as Record); + if (entries.length > MAX_TOPIC_COUNT) { + throw new Error(`Shadow demo topic intent supports at most ${MAX_TOPIC_COUNT} topics`); + } + + const topicWeights: Record = {}; + for (const [slug, rawWeight] of entries) { + if (!TOPIC_SLUG_PATTERN.test(slug)) { + throw new Error(`Shadow demo topic slug is invalid: ${slug}`); + } + if (typeof rawWeight !== 'number' || !Number.isFinite(rawWeight)) { + throw new Error(`Shadow demo topic weight ${slug} must be finite`); + } + if (rawWeight < 0 || rawWeight > 1) { + throw new Error(`Shadow demo topic weight ${slug} must be between 0 and 1`); + } + topicWeights[slug] = rawWeight; + } + + return { topicWeights }; +} + +export function aggregateShadowTopicIntents( + intents: readonly ShadowDemoTopicIntent[] +): ShadowDemoTopicIntent { + const slugs = Array.from( + new Set(intents.flatMap((intent) => Object.keys(intent.topicWeights))) + ).sort(); + const topicWeights: Record = {}; + + for (const slug of slugs) { + const values = intents + .map((intent) => intent.topicWeights[slug]) + .filter((value): value is number => value !== undefined); + if (values.length === 0) { + continue; + } + const aggregation = aggregateRowsWithTrimmedMean({ + rows: values.map((value) => ({ value })), + components: ['value'], + }); + topicWeights[slug] = Math.round(aggregation.values.value * 1000) / 1000; + } + + return { topicWeights }; +} + +export function emptyShadowTopicIntent(): ShadowDemoTopicIntent { + return { topicWeights: {} }; +} + +export function cloneShadowTopicIntent(intent: ShadowDemoTopicIntent): ShadowDemoTopicIntent { + return { topicWeights: { ...intent.topicWeights } }; +} diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 00000000..2a3f6fda --- /dev/null +++ b/src/demo/types.ts @@ -0,0 +1,308 @@ +export const SHADOW_DEMO_CONTRACT_VERSION = '2026-07-10.shadow-demo.v2' as const; + +export const SHADOW_DEMO_SIGNAL_KEYS = [ + 'recency', + 'engagement', + 'bridging', + 'source_diversity', + 'relevance', +] as const; + +export type ShadowDemoSignalKey = (typeof SHADOW_DEMO_SIGNAL_KEYS)[number]; + +export type ShadowDemoWeights = Record; + +export interface ShadowDemoTopicIntent { + topicWeights: Record; +} + +export const SHADOW_DEMO_INTERNAL_SIGNAL_KEYS = [ + 'recency', + 'engagement', + 'bridging', + 'sourceDiversity', + 'relevance', +] as const; + +export type ShadowDemoInternalSignalKey = (typeof SHADOW_DEMO_INTERNAL_SIGNAL_KEYS)[number]; + +export type ShadowDemoInternalWeights = Record; + +export const SHADOW_DEMO_COMMUNITY_IDS = [ + 'open_science_builders', + 'birders_who_code', + 'crit_fumble_pickup', + 'osint_garden_club', +] as const; + +export type ShadowDemoCommunityId = (typeof SHADOW_DEMO_COMMUNITY_IDS)[number]; + +export const SHADOW_DEMO_VOTER_BLOC_IDS = [ + 'research_practitioner', + 'dataset_steward', + 'current_awareness', + 'community_discussant', + 'interdisciplinary_connector', +] as const; + +export type ShadowDemoVoterBlocId = (typeof SHADOW_DEMO_VOTER_BLOC_IDS)[number]; + +export type ShadowDemoSyntheticVoterId = `synthetic-${ShadowDemoVoterBlocId}-${number}`; + +export const SHADOW_DEMO_SYNTHETIC_VOTER_COUNT = 24; + +export const SHADOW_DEMO_TOTAL_DEMO_VOTERS = 25; + +export const SHADOW_DEMO_GUIDED_EPOCHS = 5; + +export const SHADOW_DEMO_CORPUS_PROVENANCE = { + mode: 'production_sourced_session_frozen', + label: 'Live-scored snapshot', + description: + 'Live-scored snapshot, frozen for this demo run so rank movement is attributable to policy changes.', +} as const; + +export const SHADOW_DEMO_PHASES = [ + 'created', + 'reviewer_voted', + 'synthetic_voters_ran', + 'epoch_advanced', +] as const; + +export type ShadowDemoPhase = (typeof SHADOW_DEMO_PHASES)[number]; + +export interface ShadowDemoWarning { + code: string; + message: string; + severity: 'info' | 'warning' | 'degraded'; +} + +export interface ShadowDemoEnvelope { + contractVersion: typeof SHADOW_DEMO_CONTRACT_VERSION; + requestId: string; + generatedAt: string; + sessionId: string | null; + payload: TPayload; + warnings: ShadowDemoWarning[]; +} + +export interface ShadowDemoCommunity { + id: ShadowDemoCommunityId; + name: string; + status: 'live_shadow' | 'degraded'; + description: string; + liveFeedReady: boolean; +} + +export interface ShadowDemoCorpusHealth { + status: 'live' | 'degraded'; + source: 'production_scores_appview' | 'fixture_fallback'; + candidatePosts72h: number; + publicScoredPosts: number; + uniqueAuthors72h: number; + bridgePostShare: number; + topAuthorConcentration: number; + sampledAt: string; +} + +export interface ShadowDemoPublicPost { + kind: 'public_post'; + uri: string; + cid: string; + authorDid: string; + authorHandle: string; + authorDisplayName: string; + authorAvatar: string | null; + text: string; + likeCount: number; + repostCount: number; + replyCount: number; + quoteCount: number; + indexedAt: string; + createdAt: string; + bskyUrl: string; +} + +export interface ShadowDemoHiddenPost { + kind: 'hidden_post'; + reason: string; +} + +export type ShadowDemoDisplayPost = ShadowDemoPublicPost | ShadowDemoHiddenPost; + +export interface ShadowDemoRawScores { + recency: number; + engagement: number; + bridging: number; + source_diversity: number; + relevance: number; +} + +export interface ShadowDemoCorpusItem { + postUri: string; + authorDid: string | null; + createdAt: string; + topicVector: Record; + rawScores: ShadowDemoRawScores; + productionScore: number; + productionEpochId: number; + scoredAt: string; + componentDetails: Record | null; + displayPost: ShadowDemoDisplayPost; +} + +export interface ShadowDemoCorpus { + corpusId: string; + communityId: ShadowDemoCommunityId; + baseProductionEpochId: number; + baseWeights: ShadowDemoWeights; + baseTopicIntent: ShadowDemoTopicIntent; + createdAt: string; + expiresAt: string; + items: ShadowDemoCorpusItem[]; + health: ShadowDemoCorpusHealth; + warnings: ShadowDemoWarning[]; +} + +interface ShadowDemoVoteBase { + id: string; + epochId: string; + label: string; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; + createdAt: string; +} + +export interface ShadowDemoReviewerVote extends ShadowDemoVoteBase { + actorType: 'reviewer'; + actorId: 'reviewer'; + blocId?: never; +} + +export interface ShadowDemoSyntheticVote extends ShadowDemoVoteBase { + actorType: 'synthetic_voter'; + actorId: ShadowDemoSyntheticVoterId; + blocId: ShadowDemoVoterBlocId; +} + +export type ShadowDemoVote = ShadowDemoReviewerVote | ShadowDemoSyntheticVote; + +export interface ShadowDemoVoteSummary { + aggregateMethod: 'trimmed_mean_no_trim_under_10'; + voteCount: number; + trimCount: number; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; +} + +export interface ShadowDemoEpoch { + id: string; + sequence: number; + label: string; + status: 'open' | 'advanced'; + createdAt: string; + advancedAt: string | null; + decidedByEpochId: string | null; + aggregate: ShadowDemoVoteSummary; +} + +export interface ShadowDemoSessionState { + sessionId: string; + communityId: ShadowDemoCommunityId; + seed: string; + phase: ShadowDemoPhase; + createdAt: string; + expiresAt: string; + corpusId: string; + currentEpochId: string; + epochs: ShadowDemoEpoch[]; + votes: ShadowDemoVote[]; + corpus: ShadowDemoCorpus; + warnings: ShadowDemoWarning[]; +} + +export interface ShadowDemoSessionPayload { + session: { + sessionId: string; + community: ShadowDemoCommunity; + phase: ShadowDemoPhase; + currentEpochId: string; + expiresAt: string; + corpusHealth: ShadowDemoCorpusHealth; + epochs: ShadowDemoEpoch[]; + pendingAggregate: ShadowDemoVoteSummary | null; + voteCount: number; + guidedEpochs: typeof SHADOW_DEMO_GUIDED_EPOCHS; + maxEpochs: typeof SHADOW_DEMO_MAX_EPOCHS_PER_SESSION; + syntheticVoterCount: typeof SHADOW_DEMO_SYNTHETIC_VOTER_COUNT; + totalDemoVoters: typeof SHADOW_DEMO_TOTAL_DEMO_VOTERS; + corpusProvenance: typeof SHADOW_DEMO_CORPUS_PROVENANCE; + voterProfiles: Array<{ + id: ShadowDemoVoterBlocId; + label: string; + voterCount: number; + baseWeights: ShadowDemoWeights; + baseTopicWeights: Record; + reviewerBlend: number; + policyInertia: number; + }>; + votes: ShadowDemoVote[]; + }; +} + +export interface ShadowDemoRankedPost { + rank: number; + previousRank: number | null; + movement: number | null; + score: number | null; + weightedComponents: Record | null; + rawScores: ShadowDemoRawScores | null; + post: ShadowDemoDisplayPost; +} + +export interface ShadowDemoFeedPayload { + epochId: string; + corpusId: string; + communityId: ShadowDemoCommunityId; + corpusHealth: ShadowDemoCorpusHealth; + aggregate: ShadowDemoVoteSummary; + posts: ShadowDemoRankedPost[]; +} + +export interface ShadowDemoReceiptContribution { + signal: ShadowDemoSignalKey; + rawScore: number; + weight: number; + contribution: number; +} + +export interface ShadowDemoCounterfactual { + label: 'previous_epoch' | 'engagement_only' | 'without_reviewer_vote'; + rank: number; + deltaFromVisible: number; +} + +export interface ShadowDemoReceiptPayload { + receipt: { + type: 'shadow_demo_receipt'; + epochId: string; + postUri: string; + visibleRank: number; + previousRank: number | null; + score: number; + scoredAt: string; + aggregate: ShadowDemoVoteSummary; + components: ShadowDemoReceiptContribution[]; + topicSignals: Array<{ + topic: string; + postScore: number; + }>; + counterfactuals: ShadowDemoCounterfactual[]; + }; +} + +export const SHADOW_DEMO_SESSION_TTL_SECONDS = 90 * 60; + +export const SHADOW_DEMO_MAX_EPOCHS_PER_SESSION = 10; + +export const SHADOW_DEMO_SHARED_CORPUS_TTL_SECONDS = 120; diff --git a/src/demo/weights.ts b/src/demo/weights.ts new file mode 100644 index 00000000..a9a1cec0 --- /dev/null +++ b/src/demo/weights.ts @@ -0,0 +1,198 @@ +import { + SHADOW_DEMO_INTERNAL_SIGNAL_KEYS, + SHADOW_DEMO_SIGNAL_KEYS, + type ShadowDemoInternalSignalKey, + type ShadowDemoInternalWeights, + type ShadowDemoRawScores, + type ShadowDemoSignalKey, + type ShadowDemoVoteSummary, + type ShadowDemoTopicIntent, + type ShadowDemoWeights, +} from './types.js'; +import { aggregateRowsWithTrimmedMean } from '../governance/aggregation-math.js'; +import { scoreTopicVectorRelevance } from '../scoring/components/relevance.js'; +import { aggregateShadowTopicIntents } from './topic-intent.js'; + +const SUM_TOLERANCE = 0.000001; + +const INTERNAL_TO_WIRE: Record = { + recency: 'recency', + engagement: 'engagement', + bridging: 'bridging', + sourceDiversity: 'source_diversity', + relevance: 'relevance', +}; + +const WIRE_TO_INTERNAL: Record = { + recency: 'recency', + engagement: 'engagement', + bridging: 'bridging', + source_diversity: 'sourceDiversity', + relevance: 'relevance', +}; + +export function validateShadowWeights(value: unknown): ShadowDemoWeights { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Shadow demo weights must be an object with five signal weights'); + } + + const candidate = value as Record; + const weights = {} as ShadowDemoWeights; + for (const key of SHADOW_DEMO_SIGNAL_KEYS) { + const weight = candidate[key]; + if (typeof weight !== 'number' || !Number.isFinite(weight)) { + throw new Error(`Shadow demo weight ${key} must be a finite number`); + } + if (weight < 0) { + throw new Error(`Shadow demo weight ${key} must be non-negative`); + } + weights[key] = weight; + } + + const sum = sumWeights(weights); + if (Math.abs(sum - 1) > SUM_TOLERANCE) { + throw new Error(`Shadow demo weights must sum to 1.0; received ${sum.toFixed(6)}`); + } + + return weights; +} + +export function normalizeShadowWeights(value: ShadowDemoWeights): ShadowDemoWeights { + const sum = sumWeights(value); + if (!Number.isFinite(sum) || sum <= 0) { + throw new Error(`Shadow demo weights cannot be normalized from sum ${String(sum)}`); + } + + const normalized = {} as ShadowDemoWeights; + let running = 0; + for (const key of SHADOW_DEMO_SIGNAL_KEYS.slice(0, SHADOW_DEMO_SIGNAL_KEYS.length - 1)) { + const nextValue = roundScore(value[key] / sum); + normalized[key] = nextValue; + running += nextValue; + } + + const lastKey = SHADOW_DEMO_SIGNAL_KEYS[SHADOW_DEMO_SIGNAL_KEYS.length - 1]; + const remainder = roundScore(1 - running); + normalized[lastKey] = Math.max(0, remainder); + if (remainder < 0) { + const adjustableKey = SHADOW_DEMO_SIGNAL_KEYS + .slice(0, SHADOW_DEMO_SIGNAL_KEYS.length - 1) + .reduce((largest, key) => normalized[key] > normalized[largest] ? key : largest); + normalized[adjustableKey] = roundScore(normalized[adjustableKey] + remainder); + } + return normalized; +} + +export function aggregateShadowVotes( + votes: ReadonlyArray +): ShadowDemoVoteSummary { + if (votes.length === 0) { + throw new Error('Cannot aggregate zero shadow demo votes'); + } + + const voteRecords = votes.map((vote) => ( + 'weights' in vote + ? vote + : { weights: vote, topicIntent: { topicWeights: {} } } + )); + const aggregation = aggregateRowsWithTrimmedMean({ + rows: voteRecords.map((vote) => vote.weights), + components: SHADOW_DEMO_SIGNAL_KEYS, + }); + const averaged = aggregation.values as ShadowDemoWeights; + + return { + aggregateMethod: 'trimmed_mean_no_trim_under_10', + voteCount: votes.length, + trimCount: aggregation.trimCount, + weights: normalizeShadowWeights(averaged), + topicIntent: aggregateShadowTopicIntents(voteRecords.map((vote) => vote.topicIntent)), + }; +} + +export function scoreFromRawWeights( + rawScores: ShadowDemoRawScores, + weights: ShadowDemoWeights, + topicVector: Record, + topicIntent: ShadowDemoTopicIntent +): { + score: number; + weightedComponents: Record; + effectiveRawScores: ShadowDemoRawScores; +} { + const effectiveRawScores: ShadowDemoRawScores = { + ...rawScores, + relevance: Object.keys(topicIntent.topicWeights).length > 0 + ? scoreTopicVectorRelevance(topicVector, topicIntent.topicWeights) + : rawScores.relevance, + }; + const weightedComponents = {} as Record; + let score = 0; + for (const key of SHADOW_DEMO_SIGNAL_KEYS) { + const contribution = roundScore(effectiveRawScores[key] * weights[key]); + weightedComponents[key] = contribution; + score += contribution; + } + + return { + score: roundScore(score), + weightedComponents, + effectiveRawScores, + }; +} + +export function internalWeightsToShadow(weights: ShadowDemoInternalWeights): ShadowDemoWeights { + const wire = {} as ShadowDemoWeights; + for (const key of SHADOW_DEMO_INTERNAL_SIGNAL_KEYS) { + wire[INTERNAL_TO_WIRE[key]] = weights[key]; + } + return normalizeShadowWeights(wire); +} + +export function shadowWeightsToInternal(weights: ShadowDemoWeights): ShadowDemoInternalWeights { + const internal = {} as ShadowDemoInternalWeights; + for (const key of SHADOW_DEMO_SIGNAL_KEYS) { + internal[WIRE_TO_INTERNAL[key]] = weights[key]; + } + return internal; +} + +export function internalRawScoresToShadow( + components: Record +): ShadowDemoRawScores { + const rawScores = {} as ShadowDemoRawScores; + for (const key of SHADOW_DEMO_SIGNAL_KEYS) { + const internalKey = WIRE_TO_INTERNAL[key]; + const component = components[internalKey]; + rawScores[key] = component && Number.isFinite(component.raw) ? component.raw : 0; + } + return rawScores; +} + +export function engagementOnlyWeights(): ShadowDemoWeights { + return { + recency: 0, + engagement: 1, + bridging: 0, + source_diversity: 0, + relevance: 0, + }; +} + +export function equalShadowWeights(): ShadowDemoWeights { + return { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.2, + }; +} + +export function roundScore(value: number): number { + return Number(value.toFixed(6)); +} + +function sumWeights(weights: ShadowDemoWeights): number { + return SHADOW_DEMO_SIGNAL_KEYS.reduce((sum, key) => sum + weights[key], 0); +} diff --git a/src/feed/birders-scout-command.ts b/src/feed/birders-scout-command.ts new file mode 100644 index 00000000..b68c51f7 --- /dev/null +++ b/src/feed/birders-scout-command.ts @@ -0,0 +1,122 @@ +import { + type CommunityScoutReport, + type MaterializedCommunityFeedResult, +} from './community-materializer.js'; +import { + getFeedCommunities, + resolveFeedCommunityByRkey, + type FeedCommunity, +} from './community-registry.js'; + +export type BirdersScoutMode = 'scout' | 'materialize'; + +export interface BirdersScoutCommandOptions { + mode: BirdersScoutMode; + json: boolean; + windowHours: number; + limit: number; +} + +export function parseBirdersScoutArgs(argv: readonly string[]): BirdersScoutCommandOptions { + const options: BirdersScoutCommandOptions = { + mode: 'scout', + json: false, + windowHours: 72, + limit: 500, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') { + options.json = true; + } else if (arg === '--materialize') { + options.mode = 'materialize'; + } else if (arg === '--window-hours') { + const value = argv[index + 1]; + if (value === undefined) { + throw new Error('--window-hours requires a value'); + } + options.windowHours = parsePositiveInteger(value, '--window-hours'); + index += 1; + } else if (arg.startsWith('--window-hours=')) { + options.windowHours = parsePositiveInteger(arg.slice('--window-hours='.length), '--window-hours'); + } else if (arg === '--limit') { + const value = argv[index + 1]; + if (value === undefined) { + throw new Error('--limit requires a value'); + } + options.limit = parsePositiveInteger(value, '--limit'); + index += 1; + } else if (arg.startsWith('--limit=')) { + options.limit = parsePositiveInteger(arg.slice('--limit='.length), '--limit'); + } else if (arg === '--help' || arg === '-h') { + throw new BirdersScoutHelpRequested(); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +export class BirdersScoutHelpRequested extends Error { + constructor() { + super('Help requested'); + this.name = 'BirdersScoutHelpRequested'; + } +} + +export function birdersScoutUsage(): string { + return [ + 'Usage: npx tsx scripts/birders-feed-scout.ts [--json] [--window-hours N] [--limit N] [--materialize]', + '', + 'Default mode is read-only scout. --materialize writes only feed:community:birders_who_code:* Redis keys.', + ].join('\n'); +} + +export function resolveBirdersCommunity(): FeedCommunity { + const community = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (community === null) { + throw new Error('birders-who-code community is missing from the feed registry'); + } + return community; +} + +export function renderBirdersScoutReport(report: CommunityScoutReport): string { + return [ + `${report.name} readiness: ${report.status}`, + `Active production epoch: ${report.activeEpochId ?? 'unavailable'}`, + `Window: ${report.windowHours}h sampled at ${report.sampledAt}`, + `Candidates: ${report.candidatePosts} (${formatNumber(report.candidatePostsPerDay)}/day; threshold ${report.thresholds.candidatePostsPerDay}/day)`, + `Unique authors: ${report.uniqueAuthors} (${formatNumber(report.uniqueAuthorsPerDay)}/day; threshold ${report.thresholds.uniqueAuthorsPerDay}/day)`, + `Bridge-post share: ${formatPercent(report.bridgePostShare)}`, + `Top-author concentration: ${formatPercent(report.topAuthorConcentration)}`, + `Strong bridge/high-relevance posts: ${report.strongBridgeHighRelevancePosts} (${formatNumber(report.strongBridgeHighRelevancePostsPerDay)}/day; threshold ${report.thresholds.strongBridgeHighRelevancePostsPerDay}/day)`, + `Sample URIs: ${report.samplePostUris.length === 0 ? 'none' : report.samplePostUris.join(', ')}`, + `Warnings: ${report.warnings.length === 0 ? 'none' : report.warnings.join(' | ')}`, + ].join('\n'); +} + +export function renderBirdersMaterializeResult(result: MaterializedCommunityFeedResult): string { + return [ + renderBirdersScoutReport(result.report), + `Materialized ranked posts: ${result.rankedCount}`, + `Redis keys written: ${result.redisKeysWritten.join(', ')}`, + ].join('\n'); +} + +function parsePositiveInteger(value: string, flag: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer; received ${value}`); + } + return parsed; +} + +function formatNumber(value: number): string { + return value.toFixed(1); +} + +function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} diff --git a/src/feed/community-materializer.ts b/src/feed/community-materializer.ts new file mode 100644 index 00000000..7ca9a1fe --- /dev/null +++ b/src/feed/community-materializer.ts @@ -0,0 +1,509 @@ +import type { Pool } from 'pg'; +import { config } from '../config.js'; +import { db as defaultDb } from '../db/client.js'; +import { + type FeedCommunity, + type FeedCommunitySeedWeights, +} from './community-registry.js'; + +export interface CommunityScoutThresholds { + candidatePostsPerDay: number; + uniqueAuthorsPerDay: number; + strongBridgeHighRelevancePostsPerDay: number; +} + +export interface CommunityScoutReport { + communityId: FeedCommunity['communityId']; + name: string; + status: 'ready' | 'thin' | 'unavailable'; + source: 'production_scores'; + activeEpochId: number | null; + sampledAt: string; + windowHours: number; + candidatePosts: number; + candidatePostsPerDay: number; + uniqueAuthors: number; + uniqueAuthorsPerDay: number; + bridgePostShare: number; + topAuthorConcentration: number; + strongBridgeHighRelevancePosts: number; + strongBridgeHighRelevancePostsPerDay: number; + samplePostUris: string[]; + thresholds: CommunityScoutThresholds; + warnings: string[]; +} + +export interface MaterializedCommunityFeedResult { + communityId: FeedCommunity['communityId']; + redisKeysWritten: string[]; + rankedCount: number; + activeEpochId: number | null; + report: CommunityScoutReport; +} + +interface CandidateRow { + uri: string | null; + author_did: string | null; + text: string | null; + active_epoch_id: string | number | null; + recency_score: string | number | null; + engagement_score: string | number | null; + bridging_score: string | number | null; + source_diversity_score: string | number | null; + relevance_score: string | number | null; + community_score: string | number | null; + candidate_count: string | number; + unique_author_count: string | number; + bridge_post_count: string | number; + strong_bridge_high_relevance_count: string | number; + top_author_post_count: string | number; +} + +interface CommunityMaterializerRedisTransaction { + del(key: string): CommunityMaterializerRedisTransaction; + zadd(key: string, score: number, member: string): CommunityMaterializerRedisTransaction; + set(key: string, value: string): CommunityMaterializerRedisTransaction; + incr(key: string): CommunityMaterializerRedisTransaction; + exec(): Promise | null>; +} + +export interface CommunityMaterializerRedis { + multi(): CommunityMaterializerRedisTransaction; +} + +export interface CommunityScoutOptions { + community: FeedCommunity; + dbPool: Pick; + now: Date; + windowHours: number; + limit: number; +} + +export interface MaterializeCommunityFeedOptions extends CommunityScoutOptions { + redisClient: CommunityMaterializerRedis; +} + +export const COMMUNITY_SCOUT_THRESHOLDS: CommunityScoutThresholds = { + candidatePostsPerDay: 100, + uniqueAuthorsPerDay: 30, + strongBridgeHighRelevancePostsPerDay: 10, +}; + +export async function scoutCommunityFeed(options: CommunityScoutOptions): Promise { + assertBirdersCommunity(options.community); + const snapshot = await readCommunitySnapshot(options); + return buildScoutReport({ + community: options.community, + now: options.now, + windowHours: options.windowHours, + snapshot, + }); +} + +export async function materializeCommunityFeed( + options: MaterializeCommunityFeedOptions +): Promise { + assertBirdersCommunity(options.community); + const snapshot = await readCommunitySnapshot(options); + const report = buildScoutReport({ + community: options.community, + now: options.now, + windowHours: options.windowHours, + snapshot, + }); + const rankedCandidates = snapshot.candidates + .slice() + .sort((left, right) => right.communityScore - left.communityScore) + .slice(0, config.FEED_MAX_POSTS); + + const transaction = options.redisClient.multi(); + transaction.del(options.community.redis.current); + for (const candidate of rankedCandidates) { + transaction.zadd(options.community.redis.current, candidate.communityScore, candidate.uri); + } + transaction.set(options.community.redis.epoch, String(report.activeEpochId ?? 0)); + transaction.set(options.community.redis.health, JSON.stringify(report)); + transaction.incr(options.community.redis.snapshotGeneration); + transaction.del(options.community.redis.currentSnapshot); + const results = await transaction.exec(); + if (results === null) { + throw new Error(`Birders Redis transaction aborted for ${options.community.communityId}`); + } + const failedCommand = results.find(([error]) => error !== null); + if (failedCommand) { + throw new Error( + `Birders Redis transaction failed for ${options.community.communityId}: ${failedCommand[0]?.message ?? 'unknown command error'}` + ); + } + + return { + communityId: options.community.communityId, + redisKeysWritten: [ + options.community.redis.current, + options.community.redis.epoch, + options.community.redis.health, + options.community.redis.snapshotGeneration, + options.community.redis.currentSnapshot, + ], + rankedCount: rankedCandidates.length, + activeEpochId: report.activeEpochId, + report, + }; +} + +export async function scoutDefaultBirdersFeed(options: { + community: FeedCommunity; + now: Date; + windowHours: number; + limit: number; +}): Promise { + return scoutCommunityFeed({ + community: options.community, + dbPool: defaultDb, + now: options.now, + windowHours: options.windowHours, + limit: options.limit, + }); +} + +export function postMatchesCommunityTerms(text: string, community: FeedCommunity): boolean { + const lowered = text.toLowerCase(); + return [...community.terms.candidateTerms, ...community.terms.bridgeTerms].some((term) => + lowered.includes(term.toLowerCase()) + ); +} + +export function postMatchesBridgeTerms(text: string, community: FeedCommunity): boolean { + const lowered = text.toLowerCase(); + return community.terms.bridgeTerms.some((term) => lowered.includes(term.toLowerCase())); +} + +function assertBirdersCommunity(community: FeedCommunity): void { + if (community.communityId !== 'birders_who_code') { + throw new Error(`Community materializer only supports birders_who_code in v1; received ${community.communityId}`); + } +} + +async function readCommunitySnapshot(options: CommunityScoutOptions): Promise<{ + activeEpochId: number | null; + metrics: CandidateMetrics; + candidates: MaterializedCandidate[]; +}> { + const termPatterns = [...options.community.terms.candidateTerms, ...options.community.terms.bridgeTerms].map((term) => + sqlLikePatternForTerm(term) + ); + const bridgePatterns = options.community.terms.bridgeTerms.map((term) => sqlLikePatternForTerm(term)); + if (termPatterns.length === 0) { + return { + activeEpochId: null, + metrics: emptyCandidateMetrics(), + candidates: [], + }; + } + const weights = options.community.seedWeights; + const result = await options.dbPool.query( + `WITH active_epoch AS ( + SELECT id + FROM governance_epochs + WHERE status = 'active' + ORDER BY id DESC + LIMIT 1 + ), + matching_posts AS ( + SELECT p.uri, + p.author_did, + p.text, + LOWER(COALESCE(p.text, '')) AS normalized_text + FROM posts p + WHERE p.deleted = FALSE + AND p.created_at >= NOW() - ($1::int * INTERVAL '1 hour') + AND p.text ILIKE ANY($2::text[]) + ), + scored_candidates AS ( + SELECT DISTINCT ON (mp.uri) + mp.uri, + mp.author_did, + mp.text, + mp.normalized_text, + ae.id AS epoch_id, + ps.recency_score, + ps.engagement_score, + ps.bridging_score, + ps.source_diversity_score, + ps.relevance_score, + ( + ps.recency_score * $3::float8 + + ps.engagement_score * $4::float8 + + ps.bridging_score * $5::float8 + + ps.source_diversity_score * $6::float8 + + ps.relevance_score * $7::float8 + ) AS community_score + FROM active_epoch ae + JOIN matching_posts mp ON TRUE + JOIN post_scores ps ON ps.post_uri = mp.uri AND ps.epoch_id = ae.id + ORDER BY mp.uri, ps.scored_at DESC + ), + author_counts AS ( + SELECT author_did, COUNT(*) AS author_post_count + FROM scored_candidates + GROUP BY author_did + ), + metrics AS ( + SELECT + ae.id AS active_epoch_id, + COUNT(sc.uri) AS candidate_count, + COUNT(DISTINCT sc.author_did) AS unique_author_count, + COUNT(sc.uri) FILTER (WHERE sc.normalized_text LIKE ANY($8::text[])) AS bridge_post_count, + COUNT(sc.uri) FILTER ( + WHERE sc.normalized_text LIKE ANY($8::text[]) + AND sc.relevance_score >= 0.65 + ) AS strong_bridge_high_relevance_count, + COALESCE(MAX(ac.author_post_count), 0) AS top_author_post_count + FROM (SELECT 1) seed + LEFT JOIN active_epoch ae ON TRUE + LEFT JOIN scored_candidates sc ON TRUE + LEFT JOIN author_counts ac ON ac.author_did = sc.author_did + GROUP BY ae.id + ), + sampled_candidates AS ( + SELECT * + FROM scored_candidates + ORDER BY community_score DESC, uri ASC + LIMIT $9::int + ) + SELECT sc.*, m.active_epoch_id, m.candidate_count, m.unique_author_count, m.bridge_post_count, + m.strong_bridge_high_relevance_count, m.top_author_post_count + FROM metrics m + LEFT JOIN sampled_candidates sc ON TRUE + ORDER BY sc.community_score DESC NULLS LAST, sc.uri ASC NULLS LAST`, + [ + options.windowHours, + termPatterns, + weights.recency, + weights.engagement, + weights.bridging, + weights.sourceDiversity, + weights.relevance, + bridgePatterns, + options.limit, + ] + ); + const firstRow = result.rows[0]; + return { + activeEpochId: firstRow ? nullableFiniteInteger(firstRow.active_epoch_id) : null, + metrics: firstRow ? candidateMetricsFromRow(firstRow) : emptyCandidateMetrics(), + candidates: result.rows + .map((row) => materializedCandidateFromRow(row, weights, options.community)) + .filter((candidate) => candidate !== null), + }; +} + +interface MaterializedCandidate { + uri: string; + authorDid: string; + text: string; + epochId: number; + communityScore: number; + relevanceScore: number; + bridgeMatch: boolean; + metrics: CandidateMetrics; +} + +interface CandidateMetrics { + candidateCount: number; + uniqueAuthorCount: number; + bridgePostCount: number; + strongBridgeHighRelevanceCount: number; + topAuthorPostCount: number; +} + +function materializedCandidateFromRow( + row: CandidateRow, + weights: FeedCommunitySeedWeights, + community: FeedCommunity +): MaterializedCandidate | null { + const activeEpochId = nullableFiniteInteger(row.active_epoch_id); + if (row.uri === null || row.author_did === null || activeEpochId === null) { + return null; + } + const recency = finiteNumber(row.recency_score); + const engagement = finiteNumber(row.engagement_score); + const bridging = finiteNumber(row.bridging_score); + const sourceDiversity = finiteNumber(row.source_diversity_score); + const relevance = finiteNumber(row.relevance_score); + const reportedScore = finiteNumber(row.community_score); + const computedScore = + recency * weights.recency + + engagement * weights.engagement + + bridging * weights.bridging + + sourceDiversity * weights.sourceDiversity + + relevance * weights.relevance; + const score = Number.isFinite(reportedScore) ? reportedScore : computedScore; + const text = row.text ?? ''; + if (!Number.isFinite(score) || !Number.isFinite(relevance) || text.trim().length === 0) { + return null; + } + return { + uri: row.uri, + authorDid: row.author_did, + text, + epochId: activeEpochId, + communityScore: score, + relevanceScore: relevance, + bridgeMatch: postMatchesBridgeTerms(text, community), + metrics: candidateMetricsFromRow(row), + }; +} + +function candidateMetricsFromRow(row: CandidateRow): CandidateMetrics { + return { + candidateCount: finiteNumber(row.candidate_count), + uniqueAuthorCount: finiteNumber(row.unique_author_count), + bridgePostCount: finiteNumber(row.bridge_post_count), + strongBridgeHighRelevanceCount: finiteNumber(row.strong_bridge_high_relevance_count), + topAuthorPostCount: finiteNumber(row.top_author_post_count), + }; +} + +function emptyCandidateMetrics(): CandidateMetrics { + return { + candidateCount: 0, + uniqueAuthorCount: 0, + bridgePostCount: 0, + strongBridgeHighRelevanceCount: 0, + topAuthorPostCount: 0, + }; +} + +function buildScoutReport(options: { + community: FeedCommunity; + now: Date; + windowHours: number; + snapshot: { + activeEpochId: number | null; + metrics: CandidateMetrics; + candidates: MaterializedCandidate[]; + }; +}): CommunityScoutReport { + const activeEpochId = options.snapshot.activeEpochId; + const metrics = options.snapshot.metrics; + const candidatePosts = metrics.candidateCount; + const uniqueAuthors = metrics.uniqueAuthorCount; + const bridgePosts = metrics.bridgePostCount; + const strongBridgeHighRelevancePosts = metrics.strongBridgeHighRelevanceCount; + const candidatePostsPerDay = perDay(candidatePosts, options.windowHours); + const uniqueAuthorsPerDay = perDay(uniqueAuthors, options.windowHours); + const strongBridgeHighRelevancePostsPerDay = perDay(strongBridgeHighRelevancePosts, options.windowHours); + const status = reportStatus({ + activeEpochId, + candidatePostsPerDay, + uniqueAuthorsPerDay, + strongBridgeHighRelevancePostsPerDay, + }); + const warnings = reportWarnings({ + activeEpochId, + status, + candidatePostsPerDay, + uniqueAuthorsPerDay, + strongBridgeHighRelevancePostsPerDay, + }); + + return { + communityId: options.community.communityId, + name: options.community.name, + status, + source: 'production_scores', + activeEpochId, + sampledAt: options.now.toISOString(), + windowHours: options.windowHours, + candidatePosts, + candidatePostsPerDay, + uniqueAuthors, + uniqueAuthorsPerDay, + bridgePostShare: candidatePosts === 0 ? 0 : bridgePosts / candidatePosts, + topAuthorConcentration: candidatePosts === 0 ? 0 : metrics.topAuthorPostCount / candidatePosts, + strongBridgeHighRelevancePosts, + strongBridgeHighRelevancePostsPerDay, + samplePostUris: options.snapshot.candidates.slice(0, 10).map((candidate) => candidate.uri), + thresholds: COMMUNITY_SCOUT_THRESHOLDS, + warnings, + }; +} + +function reportStatus(options: { + activeEpochId: number | null; + candidatePostsPerDay: number; + uniqueAuthorsPerDay: number; + strongBridgeHighRelevancePostsPerDay: number; +}): CommunityScoutReport['status'] { + if (options.activeEpochId === null) { + return 'unavailable'; + } + if ( + options.candidatePostsPerDay >= COMMUNITY_SCOUT_THRESHOLDS.candidatePostsPerDay && + options.uniqueAuthorsPerDay >= COMMUNITY_SCOUT_THRESHOLDS.uniqueAuthorsPerDay && + options.strongBridgeHighRelevancePostsPerDay >= COMMUNITY_SCOUT_THRESHOLDS.strongBridgeHighRelevancePostsPerDay + ) { + return 'ready'; + } + return 'thin'; +} + +function reportWarnings(options: { + activeEpochId: number | null; + status: CommunityScoutReport['status']; + candidatePostsPerDay: number; + uniqueAuthorsPerDay: number; + strongBridgeHighRelevancePostsPerDay: number; +}): string[] { + const warnings: string[] = []; + if (options.activeEpochId === null) { + warnings.push('No active production epoch was available for Birders materialization.'); + return warnings; + } + if (options.status !== 'ready') { + warnings.push('Birders supply is below the readiness threshold; keep the feed disabled.'); + } + if (options.candidatePostsPerDay < COMMUNITY_SCOUT_THRESHOLDS.candidatePostsPerDay) { + warnings.push(`Candidate volume ${options.candidatePostsPerDay.toFixed(1)}/day is below 100/day.`); + } + if (options.uniqueAuthorsPerDay < COMMUNITY_SCOUT_THRESHOLDS.uniqueAuthorsPerDay) { + warnings.push(`Unique authors ${options.uniqueAuthorsPerDay.toFixed(1)}/day is below 30/day.`); + } + if ( + options.strongBridgeHighRelevancePostsPerDay < + COMMUNITY_SCOUT_THRESHOLDS.strongBridgeHighRelevancePostsPerDay + ) { + warnings.push( + `Strong bridge/high-relevance supply ${options.strongBridgeHighRelevancePostsPerDay.toFixed(1)}/day is below 10/day.` + ); + } + return warnings; +} + +function perDay(count: number, windowHours: number): number { + if (windowHours <= 0) { + throw new Error(`windowHours must be positive; received ${windowHours}`); + } + return (count / windowHours) * 24; +} + +function finiteNumber(value: string | number | null): number { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) { + return Number.NaN; + } + return parsed; +} + +function nullableFiniteInteger(value: string | number | null): number | null { + const parsed = finiteNumber(value); + if (!Number.isInteger(parsed)) { + return null; + } + return parsed; +} + +function sqlLikePatternForTerm(term: string): string { + return `%${term.toLowerCase()}%`; +} diff --git a/src/feed/community-registry.ts b/src/feed/community-registry.ts new file mode 100644 index 00000000..b2fa14b6 --- /dev/null +++ b/src/feed/community-registry.ts @@ -0,0 +1,167 @@ +export const BIRDERS_CANDIDATE_TERMS = [ + 'birding', + 'birdwatching', + 'birder', + 'ebird', + 'merlin', + 'audubon', + 'ornithology', + 'warbler', + 'sparrow', + 'finch', + 'owl', + 'migration', + 'field notes', + 'binoculars', +] as const; + +export const BIRDERS_BRIDGE_TERMS = [ + 'python', + 'script', + 'csv', + 'dataset', + 'classifier', + 'api', + 'computer vision', + 'github', +] as const; + +export type FeedCommunityStatus = 'enabled' | 'disabled'; +export type FeedCommunityId = 'community-gov' | 'birders_who_code'; + +export interface FeedCommunitySeedWeights { + recency: number; + engagement: number; + bridging: number; + sourceDiversity: number; + relevance: number; +} + +export interface FeedCommunityTerms { + candidateTerms: readonly string[]; + bridgeTerms: readonly string[]; +} + +export interface FeedCommunityRedisKeys { + current: string; + epoch: string; + health: string; + currentSnapshot: string; + snapshotGeneration: string; + snapshotPrefix: string; +} + +export interface FeedCommunity { + communityId: FeedCommunityId; + name: string; + feedRkey: string; + status: FeedCommunityStatus; + public: boolean; + redisPrefix: string; + redis: FeedCommunityRedisKeys; + terms: FeedCommunityTerms; + seedWeights: FeedCommunitySeedWeights; + includePinnedAnnouncements: boolean; +} + +export const COMMUNITY_GOV_REDIS_KEYS: FeedCommunityRedisKeys = { + current: 'feed:current', + epoch: 'feed:epoch', + health: 'feed:health', + currentSnapshot: 'feed:current_snapshot_id', + snapshotGeneration: 'feed:current_snapshot_generation', + snapshotPrefix: 'snapshot:', +}; + +export const BIRDERS_REDIS_PREFIX = 'feed:community:birders_who_code'; + +export const BIRDERS_REDIS_KEYS: FeedCommunityRedisKeys = { + current: `${BIRDERS_REDIS_PREFIX}:current`, + epoch: `${BIRDERS_REDIS_PREFIX}:epoch`, + health: `${BIRDERS_REDIS_PREFIX}:health`, + currentSnapshot: `${BIRDERS_REDIS_PREFIX}:current_snapshot_id`, + snapshotGeneration: `${BIRDERS_REDIS_PREFIX}:snapshot_generation`, + snapshotPrefix: 'snapshot:community:birders_who_code:', +}; + +export const FEED_COMMUNITIES = [ + { + communityId: 'community-gov', + name: 'Corgi Community Governance', + feedRkey: 'community-gov', + status: 'enabled', + public: true, + redisPrefix: 'feed', + redis: COMMUNITY_GOV_REDIS_KEYS, + terms: { + candidateTerms: [], + bridgeTerms: [], + }, + seedWeights: { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + sourceDiversity: 0.2, + relevance: 0.2, + }, + includePinnedAnnouncements: true, + }, + { + communityId: 'birders_who_code', + name: 'Birders Who Code', + feedRkey: 'birders-who-code', + status: 'disabled', + public: false, + redisPrefix: BIRDERS_REDIS_PREFIX, + redis: BIRDERS_REDIS_KEYS, + terms: { + candidateTerms: BIRDERS_CANDIDATE_TERMS, + bridgeTerms: BIRDERS_BRIDGE_TERMS, + }, + seedWeights: { + recency: 0.22, + engagement: 0.12, + bridging: 0.2, + sourceDiversity: 0.16, + relevance: 0.3, + }, + includePinnedAnnouncements: false, + }, +] as const satisfies readonly FeedCommunity[]; + +export function getFeedCommunities(): readonly FeedCommunity[] { + return FEED_COMMUNITIES; +} + +export function feedUriForCommunity(community: FeedCommunity, publisherDid: string): string { + return `at://${publisherDid}/app.bsky.feed.generator/${community.feedRkey}`; +} + +export function feedUriForRkey(feedRkey: string, publisherDid: string): string { + return `at://${publisherDid}/app.bsky.feed.generator/${feedRkey}`; +} + +export function resolveFeedCommunityByUri( + feedUri: string, + publisherDid: string, + communities: readonly FeedCommunity[] +): FeedCommunity | null { + return communities.find((community) => feedUriForCommunity(community, publisherDid) === feedUri) ?? null; +} + +export function resolveFeedCommunityByRkey( + feedRkey: string, + communities: readonly FeedCommunity[] +): FeedCommunity | null { + return communities.find((community) => community.feedRkey === feedRkey) ?? null; +} + +export function publicFeedUris(communities: readonly FeedCommunity[], publisherDid: string): string[] { + return communities + .filter((community) => community.status === 'enabled' && community.public) + .map((community) => feedUriForCommunity(community, publisherDid)); +} + +export function isFeedCommunityServable(community: FeedCommunity): boolean { + return community.status === 'enabled'; +} diff --git a/src/feed/rate-limit-config.ts b/src/feed/rate-limit-config.ts new file mode 100644 index 00000000..203a8aff --- /dev/null +++ b/src/feed/rate-limit-config.ts @@ -0,0 +1,120 @@ +import type { FastifyRequest } from 'fastify'; +import { config } from '../config.js'; + +export interface RouteRateLimitConfig { + max: number; + timeWindow: number; + keyGenerator?: (request: FastifyRequest) => string | Promise; +} + +function normalizeRouteMethods(method: string | string[]): string[] { + if (Array.isArray(method)) { + return method.map((value) => value.toUpperCase()); + } + return [method.toUpperCase()]; +} + +export function buildRouteRateLimitConfig( + url: string, + method: string | string[], + governanceMutationKeyGenerator: (request: FastifyRequest) => string | Promise +): RouteRateLimitConfig | null { + const methods = normalizeRouteMethods(method); + const isReadOnly = methods.every((value) => value === 'GET' || value === 'HEAD' || value === 'OPTIONS'); + + if (url === '/xrpc/app.bsky.feed.sendInteractions') { + return { + max: config.RATE_LIMIT_INTERACTIONS_MAX, + timeWindow: config.RATE_LIMIT_INTERACTIONS_WINDOW_MS, + }; + } + + if (url === '/mcp') { + return { + max: config.RATE_LIMIT_ADMIN_CRITICAL_MAX, + timeWindow: config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS, + }; + } + + if (url.startsWith('/api/governance/auth/login')) { + return { + max: config.RATE_LIMIT_LOGIN_MAX, + timeWindow: config.RATE_LIMIT_LOGIN_WINDOW_MS, + }; + } + + if (url.startsWith('/api/governance/vote')) { + return { + max: config.RATE_LIMIT_VOTE_MAX, + timeWindow: config.RATE_LIMIT_VOTE_WINDOW_MS, + keyGenerator: governanceMutationKeyGenerator, + }; + } + + if (url === '/api/demo/sessions' && !isReadOnly) { + return { + max: config.RATE_LIMIT_LOGIN_MAX, + timeWindow: config.RATE_LIMIT_LOGIN_WINDOW_MS, + keyGenerator: governanceMutationKeyGenerator, + }; + } + + if (url.startsWith('/api/demo/') && !isReadOnly) { + return { + max: config.RATE_LIMIT_VOTE_MAX, + timeWindow: config.RATE_LIMIT_VOTE_WINDOW_MS, + keyGenerator: governanceMutationKeyGenerator, + }; + } + + if (url.startsWith('/api/demo/') && isReadOnly) { + return { + max: config.RATE_LIMIT_INTERACTIONS_MAX, + timeWindow: config.RATE_LIMIT_INTERACTIONS_WINDOW_MS, + }; + } + + if (url.startsWith('/api/governance/') && !isReadOnly) { + const isCriticalGovernanceMutation = + url === '/api/governance/epochs/transition' || + url === '/api/governance/auth/logout'; + return { + max: isCriticalGovernanceMutation + ? config.RATE_LIMIT_ADMIN_CRITICAL_MAX + : config.RATE_LIMIT_VOTE_MAX, + timeWindow: isCriticalGovernanceMutation + ? config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS + : config.RATE_LIMIT_VOTE_WINDOW_MS, + keyGenerator: governanceMutationKeyGenerator, + }; + } + + if (url.startsWith('/api/admin/')) { + const isCriticalAdminAction = + url === '/api/admin/epochs/transition' || + url === '/api/admin/feed/rescore' || + url === '/api/admin/scheduler/check' || + (url.startsWith('/api/admin/governance/') && !isReadOnly); + return { + max: isCriticalAdminAction + ? config.RATE_LIMIT_ADMIN_CRITICAL_MAX + : config.RATE_LIMIT_ADMIN_MAX, + timeWindow: isCriticalAdminAction + ? config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS + : config.RATE_LIMIT_ADMIN_WINDOW_MS, + }; + } + + if ( + url === '/api/bot/announce' || + url === '/api/bot/retry' || + url === '/api/bot/unpin' + ) { + return { + max: config.RATE_LIMIT_ADMIN_CRITICAL_MAX, + timeWindow: config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS, + }; + } + + return null; +} diff --git a/src/feed/routes/describe-generator.ts b/src/feed/routes/describe-generator.ts index 1e0f33bb..5b85027b 100644 --- a/src/feed/routes/describe-generator.ts +++ b/src/feed/routes/describe-generator.ts @@ -1,5 +1,10 @@ import type { FastifyInstance } from 'fastify'; import { config } from '../../config.js'; +import { getFeedCommunities, publicFeedUris, type FeedCommunity } from '../community-registry.js'; + +export interface RegisterDescribeGeneratorOptions { + communities: readonly FeedCommunity[]; +} /** * Register the describeFeedGenerator endpoint. @@ -7,7 +12,9 @@ import { config } from '../../config.js'; * * Spec: §9.4 - GET /xrpc/app.bsky.feed.describeFeedGenerator */ -export function registerDescribeGenerator(app: FastifyInstance): void { +export function registerDescribeGenerator(app: FastifyInstance, options?: RegisterDescribeGeneratorOptions): void { + const communities = options?.communities ?? getFeedCommunities(); + app.get('/xrpc/app.bsky.feed.describeFeedGenerator', { schema: { tags: ['Feed'], @@ -37,11 +44,7 @@ export function registerDescribeGenerator(app: FastifyInstance): void { }, async (_request, reply) => { return reply.send({ did: config.FEEDGEN_SERVICE_DID, - feeds: [ - { - uri: `at://${config.FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/community-gov`, - }, - ], + feeds: publicFeedUris(communities, config.FEEDGEN_PUBLISHER_DID).map((uri) => ({ uri })), }); }); } diff --git a/src/feed/routes/feed-skeleton.ts b/src/feed/routes/feed-skeleton.ts index e9fcc4e1..02b38ca7 100644 --- a/src/feed/routes/feed-skeleton.ts +++ b/src/feed/routes/feed-skeleton.ts @@ -20,20 +20,26 @@ import { upsertSubscriberAsync } from '../../db/queries/subscribers.js'; import { encodeCursor, decodeCursor } from '../cursor.js'; import { verifyFeedRequesterDid } from '../jwt-verifier.js'; import { isParticipantApproved } from '../access-control.js'; +import { + feedUriForCommunity, + getFeedCommunities, + isFeedCommunityServable, + resolveFeedCommunityByUri, + type FeedCommunity, +} from '../community-registry.js'; import { enqueueFeedRequestTracking, noteFeedRequestTrackingAbandonedBackendOperation, } from '../request-tracker.js'; -import { getCurrentFeedSnapshot, getFeedSnapshotById } from '../snapshot-cache.js'; +import { getCommunityFeedSnapshot, getCommunityFeedSnapshotById } from '../snapshot-cache.js'; -// The AT-URI for this feed -const FEED_URI = `at://${config.FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/community-gov`; const MIN_CURSOR_OFFSET = 0; const MAX_CURSOR_OFFSET = 10000; const SnapshotIdSchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9:_-]+$/); interface FeedRequestTrackingContext { authHeader: string | undefined; + community: FeedCommunity; precomputedViewerDid: string | undefined; signal: AbortSignal; snapshotId: string; @@ -117,12 +123,13 @@ async function trackFeedRequest(context: FeedRequestTrackingContext): Promise redis.get('feed:epoch'), - 'redis.get(feed:epoch)' + () => redis.get(context.community.redis.epoch), + `redis.get(${context.community.redis.epoch})` ); const logEntry = JSON.stringify({ viewer_did: viewerDid, + community_id: context.community.communityId, epoch_id: epochIdStr ? parseInt(epochIdStr, 10) : 0, snapshot_id: context.snapshotId, page_offset: context.pageOffset, @@ -174,6 +181,10 @@ interface FeedSkeletonQuery { limit?: string; } +export interface RegisterFeedSkeletonOptions { + communities: readonly FeedCommunity[]; +} + /** Route-level schema for OpenAPI docs (no superRefine — Ajv can't compile Zod effects). */ const FeedSkeletonRouteSchema = z.object({ feed: z.string(), @@ -202,7 +213,9 @@ const FeedSkeletonQuerySchema = FeedSkeletonRouteSchema.superRefine((query, ctx) * * Spec: §9.1-9.3 - GET /xrpc/app.bsky.feed.getFeedSkeleton */ -export function registerFeedSkeleton(app: FastifyInstance): void { +export function registerFeedSkeleton(app: FastifyInstance, options?: RegisterFeedSkeletonOptions): void { + const communities = options?.communities ?? getFeedCommunities(); + app.get( '/xrpc/app.bsky.feed.getFeedSkeleton', { @@ -253,14 +266,29 @@ export function registerFeedSkeleton(app: FastifyInstance): void { const isFirstPage = !cursor; let precomputedViewerDid: string | undefined; - // Validate this is a request for OUR feed - if (feed !== FEED_URI) { - logger.warn({ feed, expected: FEED_URI }, 'Unknown feed requested'); + const community = resolveFeedCommunityByUri(feed, config.FEEDGEN_PUBLISHER_DID, communities); + if (community === null) { + logger.warn( + { + feed, + supportedFeeds: communities.map((candidate) => + feedUriForCommunity(candidate, config.FEEDGEN_PUBLISHER_DID) + ), + }, + 'Unknown feed requested' + ); return reply.code(400).send({ error: 'UnsupportedAlgorithm', message: 'Unknown feed', }); } + if (!isFeedCommunityServable(community)) { + logger.warn({ feed, communityId: community.communityId }, 'Disabled feed requested'); + return reply.code(400).send({ + error: 'UnsupportedAlgorithm', + message: 'Feed is disabled', + }); + } // Private feed mode: require approved participant if (config.FEED_PRIVATE_MODE) { @@ -317,9 +345,9 @@ export function registerFeedSkeleton(app: FastifyInstance): void { // Try to get snapshot from the shared in-memory/Redis snapshot cache. let snapshot; try { - snapshot = await getFeedSnapshotById(snapshotId); + snapshot = await getCommunityFeedSnapshotById(community, snapshotId); } catch (err) { - logger.warn({ err, snapshotId }, 'Failed to read feed snapshot by id'); + logger.warn({ err, communityId: community.communityId, snapshotId }, 'Failed to read feed snapshot by id'); return reply.send({ feed: [] }); } if (snapshot === null) { @@ -334,9 +362,9 @@ export function registerFeedSkeleton(app: FastifyInstance): void { offset = 0; let snapshot; try { - snapshot = await getCurrentFeedSnapshot(); + snapshot = await getCommunityFeedSnapshot(community); } catch (err) { - logger.warn({ err }, 'Failed to read current feed snapshot'); + logger.warn({ err, communityId: community.communityId }, 'Failed to read current feed snapshot'); return reply.send({ feed: [] }); } if (snapshot === null) { @@ -350,7 +378,7 @@ export function registerFeedSkeleton(app: FastifyInstance): void { // Check for pinned announcement (first page only) let pinnedUri: string | null = null; - if (isFirstPage) { + if (isFirstPage && community.includePinnedAnnouncements) { const pinnedData = await redis.get('bot:latest_announcement'); if (pinnedData) { try { @@ -379,6 +407,7 @@ export function registerFeedSkeleton(app: FastifyInstance): void { logger.debug( { feedItems: feedItems.length, + communityId: community.communityId, hasMore, snapshotId, authHeaderPresent: Boolean(authHeader), @@ -396,6 +425,7 @@ export function registerFeedSkeleton(app: FastifyInstance): void { const trackingAccepted = enqueueFeedRequestTracking((signal) => trackFeedRequest({ authHeader, + community, precomputedViewerDid, signal, snapshotId, diff --git a/src/feed/server.ts b/src/feed/server.ts index f4410b14..bc4ca7e4 100644 --- a/src/feed/server.ts +++ b/src/feed/server.ts @@ -16,6 +16,7 @@ import { registerFeedSkeleton } from './routes/feed-skeleton.js'; import { registerSendInteractions } from './routes/send-interactions.js'; import { registerGovernanceRoutes } from '../governance/server.js'; import { registerTransparencyRoutes } from '../transparency/server.js'; +import { registerShadowDemoRoutes } from '../demo/routes.js'; import { registerDebugRoutes } from './routes/debug.js'; import { registerAdminRoutes } from '../admin/routes/index.js'; import { registerLegalRoutes } from '../legal/server.js'; @@ -26,6 +27,8 @@ import { AppError, isAppError } from '../lib/errors.js'; import { redis } from '../db/redis.js'; import { getAuthenticatedDid } from '../governance/auth.js'; import { requireAdmin } from '../auth/admin.js'; +import type { ShadowDemoService } from '../demo/service.js'; +import { buildRouteRateLimitConfig } from './rate-limit-config.js'; // Extend FastifyRequest to include correlationId declare module 'fastify' { @@ -38,7 +41,11 @@ declare module 'fastify' { * Create and configure the Fastify server instance. * Registers all feed-related routes. */ -export async function createServer() { +export interface CreateServerOptions { + shadowDemoService?: ShadowDemoService | null; +} + +export async function createServer(options?: CreateServerOptions) { const app = Fastify({ logger: false, // We use our own pino logger trustProxy: parseTrustProxyConfig(config.TRUST_PROXY), @@ -116,6 +123,7 @@ export async function createServer() { { name: 'Auth', description: 'Bluesky authentication for governance actions' }, { name: 'Topics', description: 'Topic catalog and topic weight voting' }, { name: 'Transparency', description: 'Score explanations, feed stats, and audit logs' }, + { name: 'Demo', description: 'Public shadow demo sessions and read-only reviewer walkthroughs' }, { name: 'Admin', description: 'Admin-only endpoints (requires BOT_ADMIN_DIDS)' }, { name: 'Export', description: 'Research data export (anonymized, admin-only)' }, { name: 'Health', description: 'Server health checks and liveness probes' }, @@ -141,7 +149,10 @@ export async function createServer() { }); await app.register(swaggerUi, { - routePrefix: '/docs', + // `/docs` belongs to the public Next product documentation. Keep the + // admin-gated API explorer under the API namespace so a static export can + // register its own `/docs/` route without Fastify startup collisions. + routePrefix: '/api/docs', uiConfig: { docExpansion: 'list', deepLinking: true, @@ -230,6 +241,9 @@ export async function createServer() { // Register transparency routes registerTransparencyRoutes(app); + // Register public shadow demo routes + registerShadowDemoRoutes(app, options?.shadowDemoService ?? null); + // Register debug routes registerDebugRoutes(app); @@ -515,104 +529,6 @@ export async function createServer() { return app; } -interface RouteRateLimitConfig { - max: number; - timeWindow: number; - keyGenerator?: (request: FastifyRequest) => string | Promise; -} - -function normalizeRouteMethods(method: string | string[]): string[] { - if (Array.isArray(method)) { - return method.map((value) => value.toUpperCase()); - } - return [method.toUpperCase()]; -} - -export function buildRouteRateLimitConfig( - url: string, - method: string | string[], - governanceMutationKeyGenerator: (request: FastifyRequest) => string | Promise -): RouteRateLimitConfig | null { - const methods = normalizeRouteMethods(method); - const isReadOnly = methods.every((value) => value === 'GET' || value === 'HEAD' || value === 'OPTIONS'); - - // Tight rate limit on sendInteractions due to cold-cache DID resolution cost - if (url === '/xrpc/app.bsky.feed.sendInteractions') { - return { - max: config.RATE_LIMIT_INTERACTIONS_MAX, - timeWindow: config.RATE_LIMIT_INTERACTIONS_WINDOW_MS, - }; - } - - // MCP transport is admin-only and can invoke expensive backend actions. - // Use critical admin limits instead of relying on the looser global cap. - if (url === '/mcp') { - return { - max: config.RATE_LIMIT_ADMIN_CRITICAL_MAX, - timeWindow: config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS, - }; - } - - if (url.startsWith('/api/governance/auth/login')) { - return { - max: config.RATE_LIMIT_LOGIN_MAX, - timeWindow: config.RATE_LIMIT_LOGIN_WINDOW_MS, - }; - } - - if (url.startsWith('/api/governance/vote')) { - return { - max: config.RATE_LIMIT_VOTE_MAX, - timeWindow: config.RATE_LIMIT_VOTE_WINDOW_MS, - keyGenerator: governanceMutationKeyGenerator, - }; - } - - if (url.startsWith('/api/governance/') && !isReadOnly) { - const isCriticalGovernanceMutation = - url === '/api/governance/epochs/transition' || - url === '/api/governance/auth/logout'; - return { - max: isCriticalGovernanceMutation - ? config.RATE_LIMIT_ADMIN_CRITICAL_MAX - : config.RATE_LIMIT_VOTE_MAX, - timeWindow: isCriticalGovernanceMutation - ? config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS - : config.RATE_LIMIT_VOTE_WINDOW_MS, - keyGenerator: governanceMutationKeyGenerator, - }; - } - - if (url.startsWith('/api/admin/')) { - const isCriticalAdminAction = - url === '/api/admin/epochs/transition' || - url === '/api/admin/feed/rescore' || - url === '/api/admin/scheduler/check' || - (url.startsWith('/api/admin/governance/') && !isReadOnly); - return { - max: isCriticalAdminAction - ? config.RATE_LIMIT_ADMIN_CRITICAL_MAX - : config.RATE_LIMIT_ADMIN_MAX, - timeWindow: isCriticalAdminAction - ? config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS - : config.RATE_LIMIT_ADMIN_WINDOW_MS, - }; - } - - if ( - url === '/api/bot/announce' || - url === '/api/bot/retry' || - url === '/api/bot/unpin' - ) { - return { - max: config.RATE_LIMIT_ADMIN_CRITICAL_MAX, - timeWindow: config.RATE_LIMIT_ADMIN_CRITICAL_WINDOW_MS, - }; - } - - return null; -} - function parseAllowedOrigins(): Set { const configured = config.CORS_ALLOWED_ORIGINS .split(',') diff --git a/src/feed/snapshot-cache.ts b/src/feed/snapshot-cache.ts index e600e1c2..6765ed9d 100644 --- a/src/feed/snapshot-cache.ts +++ b/src/feed/snapshot-cache.ts @@ -1,11 +1,12 @@ import { randomUUID } from 'crypto'; import { config } from '../config.js'; import { redis } from '../db/redis.js'; +import { COMMUNITY_GOV_REDIS_KEYS, type FeedCommunity } from './community-registry.js'; const SNAPSHOT_TTL_SECONDS = 300; -export const CURRENT_FEED_SNAPSHOT_KEY = 'feed:current_snapshot_id'; -const CURRENT_FEED_GENERATION_KEY = 'feed:current_snapshot_generation'; -const SNAPSHOT_KEY_PREFIX = 'snapshot:'; +export const CURRENT_FEED_SNAPSHOT_KEY = COMMUNITY_GOV_REDIS_KEYS.currentSnapshot; +const CURRENT_FEED_GENERATION_KEY = COMMUNITY_GOV_REDIS_KEYS.snapshotGeneration; +const SNAPSHOT_KEY_PREFIX = COMMUNITY_GOV_REDIS_KEYS.snapshotPrefix; export const FEED_SNAPSHOT_BY_ID_MEMORY_CACHE_MAX_ENTRIES = 1_000; const PUBLISH_CURRENT_SNAPSHOT_SCRIPT = ` local generationKey = KEYS[1] @@ -36,12 +37,36 @@ export interface FeedSnapshot { uris: string[]; } -let currentSnapshotPromise: Promise | null = null; -let currentMemorySnapshotCache: { expiresAtMs: number; generation: string; snapshot: FeedSnapshot } | null = null; +interface FeedSnapshotSpec { + sortedSetKey: string; + currentSnapshotKey: string; + generationKey: string; + snapshotKeyPrefix: string; + maxPosts: number; +} + +const CURRENT_FEED_SNAPSHOT_SPEC: FeedSnapshotSpec = { + sortedSetKey: COMMUNITY_GOV_REDIS_KEYS.current, + currentSnapshotKey: CURRENT_FEED_SNAPSHOT_KEY, + generationKey: CURRENT_FEED_GENERATION_KEY, + snapshotKeyPrefix: SNAPSHOT_KEY_PREFIX, + maxPosts: config.FEED_MAX_POSTS, +}; + +let currentSnapshotPromises = new Map>(); +let currentMemorySnapshotCache = new Map(); const byIdMemorySnapshotCache = new Map(); -function snapshotKey(snapshotId: string): string { - return `${SNAPSHOT_KEY_PREFIX}${snapshotId}`; +function specCacheKey(spec: FeedSnapshotSpec): string { + return spec.currentSnapshotKey; +} + +function snapshotMemoryKey(spec: FeedSnapshotSpec, snapshotId: string): string { + return `${spec.snapshotKeyPrefix}${snapshotId}`; +} + +function snapshotKey(spec: FeedSnapshotSpec, snapshotId: string): string { + return snapshotMemoryKey(spec, snapshotId); } function parseSnapshot(snapshotId: string, snapshotData: string): FeedSnapshot | null { @@ -60,45 +85,47 @@ function parseSnapshot(snapshotId: string, snapshotData: string): FeedSnapshot | }; } -async function readCurrentGeneration(): Promise { - return (await redis.get(CURRENT_FEED_GENERATION_KEY)) ?? '0'; +async function readCurrentGeneration(spec: FeedSnapshotSpec): Promise { + return (await redis.get(spec.generationKey)) ?? '0'; } -async function readCurrentMemorySnapshot(): Promise { - const cached = currentMemorySnapshotCache; - if (cached === null) { +async function readCurrentMemorySnapshot(spec: FeedSnapshotSpec): Promise { + const key = specCacheKey(spec); + const cached = currentMemorySnapshotCache.get(key); + if (cached === undefined) { return null; } if (cached.expiresAtMs <= Date.now()) { - if (currentMemorySnapshotCache === cached) { - currentMemorySnapshotCache = null; + if (currentMemorySnapshotCache.get(key) === cached) { + currentMemorySnapshotCache.delete(key); } return null; } - const generation = await readCurrentGeneration(); - if (currentMemorySnapshotCache !== cached) { + const generation = await readCurrentGeneration(spec); + if (currentMemorySnapshotCache.get(key) !== cached) { return null; } if (cached.generation !== generation) { - if (currentMemorySnapshotCache === cached) { - currentMemorySnapshotCache = null; + if (currentMemorySnapshotCache.get(key) === cached) { + currentMemorySnapshotCache.delete(key); } return null; } return cached.snapshot; } -function readMemorySnapshotById(snapshotId: string): FeedSnapshot | null { - const cached = byIdMemorySnapshotCache.get(snapshotId); +function readMemorySnapshotById(spec: FeedSnapshotSpec, snapshotId: string): FeedSnapshot | null { + const memoryKey = snapshotMemoryKey(spec, snapshotId); + const cached = byIdMemorySnapshotCache.get(memoryKey); if (cached === undefined) { return null; } if (cached.expiresAtMs <= Date.now()) { - byIdMemorySnapshotCache.delete(snapshotId); + byIdMemorySnapshotCache.delete(memoryKey); return null; } - byIdMemorySnapshotCache.delete(snapshotId); - byIdMemorySnapshotCache.set(snapshotId, cached); + byIdMemorySnapshotCache.delete(memoryKey); + byIdMemorySnapshotCache.set(memoryKey, cached); return cached.snapshot; } @@ -120,41 +147,51 @@ function evictOldestByIdMemorySnapshots(): void { } } -async function readSnapshotMemoryTtlMs(snapshotId: string): Promise { - const ttlMs = await redis.pttl(snapshotKey(snapshotId)); +async function readSnapshotMemoryTtlMs(spec: FeedSnapshotSpec, snapshotId: string): Promise { + const ttlMs = await redis.pttl(snapshotKey(spec, snapshotId)); if (ttlMs <= 0) { return null; } return Math.min(ttlMs, SNAPSHOT_TTL_SECONDS * 1000); } -async function writeCurrentMemorySnapshot(snapshot: FeedSnapshot, generation: string): Promise { - const ttlMs = await readSnapshotMemoryTtlMs(snapshot.snapshotId); +async function writeCurrentMemorySnapshot( + spec: FeedSnapshotSpec, + snapshot: FeedSnapshot, + generation: string +): Promise { + const key = specCacheKey(spec); + const ttlMs = await readSnapshotMemoryTtlMs(spec, snapshot.snapshotId); if (ttlMs === null) { - currentMemorySnapshotCache = null; - byIdMemorySnapshotCache.delete(snapshot.snapshotId); + currentMemorySnapshotCache.delete(key); + byIdMemorySnapshotCache.delete(snapshotMemoryKey(spec, snapshot.snapshotId)); return snapshot; } const nowMs = Date.now(); - currentMemorySnapshotCache = { + currentMemorySnapshotCache.set(key, { expiresAtMs: nowMs + ttlMs, generation, snapshot, - }; - await writeByIdMemorySnapshot(snapshot, ttlMs); + }); + await writeByIdMemorySnapshot(spec, snapshot, ttlMs); return snapshot; } -async function writeByIdMemorySnapshot(snapshot: FeedSnapshot, knownTtlMs?: number | null): Promise { - const ttlMs = knownTtlMs === undefined ? await readSnapshotMemoryTtlMs(snapshot.snapshotId) : knownTtlMs; +async function writeByIdMemorySnapshot( + spec: FeedSnapshotSpec, + snapshot: FeedSnapshot, + knownTtlMs?: number | null +): Promise { + const ttlMs = knownTtlMs === undefined ? await readSnapshotMemoryTtlMs(spec, snapshot.snapshotId) : knownTtlMs; + const memoryKey = snapshotMemoryKey(spec, snapshot.snapshotId); const nowMs = Date.now(); sweepExpiredByIdMemorySnapshots(nowMs); if (ttlMs === null) { - byIdMemorySnapshotCache.delete(snapshot.snapshotId); + byIdMemorySnapshotCache.delete(memoryKey); return snapshot; } - byIdMemorySnapshotCache.delete(snapshot.snapshotId); - byIdMemorySnapshotCache.set(snapshot.snapshotId, { + byIdMemorySnapshotCache.delete(memoryKey); + byIdMemorySnapshotCache.set(memoryKey, { expiresAtMs: nowMs + ttlMs, snapshot, }); @@ -162,41 +199,41 @@ async function writeByIdMemorySnapshot(snapshot: FeedSnapshot, knownTtlMs?: numb return snapshot; } -async function deleteCorruptSnapshot(snapshotId: string): Promise { - byIdMemorySnapshotCache.delete(snapshotId); - await redis.del(snapshotKey(snapshotId)); - const currentSnapshotId = await redis.get(CURRENT_FEED_SNAPSHOT_KEY); +async function deleteCorruptSnapshot(spec: FeedSnapshotSpec, snapshotId: string): Promise { + byIdMemorySnapshotCache.delete(snapshotMemoryKey(spec, snapshotId)); + await redis.del(snapshotKey(spec, snapshotId)); + const currentSnapshotId = await redis.get(spec.currentSnapshotKey); if (currentSnapshotId === snapshotId) { - await redis.del(CURRENT_FEED_SNAPSHOT_KEY); - currentMemorySnapshotCache = null; + await redis.del(spec.currentSnapshotKey); + currentMemorySnapshotCache.delete(specCacheKey(spec)); } } -async function readCurrentSnapshotFromRedis(): Promise { - const generation = await readCurrentGeneration(); - const snapshotId = await redis.get(CURRENT_FEED_SNAPSHOT_KEY); +async function readCurrentSnapshotFromRedis(spec: FeedSnapshotSpec): Promise { + const generation = await readCurrentGeneration(spec); + const snapshotId = await redis.get(spec.currentSnapshotKey); if (!snapshotId) { return null; } - const snapshotData = await redis.get(snapshotKey(snapshotId)); + const snapshotData = await redis.get(snapshotKey(spec, snapshotId)); if (!snapshotData) { - await redis.del(CURRENT_FEED_SNAPSHOT_KEY); + await redis.del(spec.currentSnapshotKey); return null; } const snapshot = parseSnapshot(snapshotId, snapshotData); if (snapshot === null) { - await deleteCorruptSnapshot(snapshotId); + await deleteCorruptSnapshot(spec, snapshotId); return null; } - return await writeCurrentMemorySnapshot(snapshot, generation); + return await writeCurrentMemorySnapshot(spec, snapshot, generation); } -async function createCurrentSnapshot(): Promise { +async function createCurrentSnapshot(spec: FeedSnapshotSpec): Promise { for (let attempt = 0; attempt < 2; attempt++) { - const generation = await readCurrentGeneration(); - const rankedUris = await redis.zrevrange('feed:current', 0, config.FEED_MAX_POSTS - 1); + const generation = await readCurrentGeneration(spec); + const rankedUris = await redis.zrevrange(spec.sortedSetKey, 0, spec.maxPosts - 1); if (rankedUris.length === 0) { return null; } @@ -209,19 +246,19 @@ async function createCurrentSnapshot(): Promise { const published = await redis.eval( PUBLISH_CURRENT_SNAPSHOT_SCRIPT, 3, - CURRENT_FEED_GENERATION_KEY, - CURRENT_FEED_SNAPSHOT_KEY, - snapshotKey(snapshot.snapshotId), + spec.generationKey, + spec.currentSnapshotKey, + snapshotKey(spec, snapshot.snapshotId), generation, snapshot.snapshotId, String(SNAPSHOT_TTL_SECONDS), serializedSnapshot ); if (published === 1) { - return await writeCurrentMemorySnapshot(snapshot, generation); + return await writeCurrentMemorySnapshot(spec, snapshot, generation); } - const currentSnapshot = await readCurrentSnapshotFromRedis(); + const currentSnapshot = await readCurrentSnapshotFromRedis(spec); if (currentSnapshot !== null) { return currentSnapshot; } @@ -230,72 +267,117 @@ async function createCurrentSnapshot(): Promise { return null; } -export async function getCurrentFeedSnapshot(): Promise { - const memorySnapshot = await readCurrentMemorySnapshot(); +async function getCurrentFeedSnapshotForSpec(spec: FeedSnapshotSpec): Promise { + const memorySnapshot = await readCurrentMemorySnapshot(spec); if (memorySnapshot !== null) { return memorySnapshot; } - const redisSnapshot = await readCurrentSnapshotFromRedis(); + const redisSnapshot = await readCurrentSnapshotFromRedis(spec); if (redisSnapshot !== null) { return redisSnapshot; } - if (currentSnapshotPromise !== null) { - return currentSnapshotPromise; + const key = specCacheKey(spec); + const pending = currentSnapshotPromises.get(key); + if (pending !== undefined) { + return pending; } - currentSnapshotPromise = createCurrentSnapshot(); + const currentSnapshotPromise = createCurrentSnapshot(spec); + currentSnapshotPromises.set(key, currentSnapshotPromise); try { return await currentSnapshotPromise; } finally { - currentSnapshotPromise = null; + if (currentSnapshotPromises.get(key) === currentSnapshotPromise) { + currentSnapshotPromises.delete(key); + } } } -export async function getFeedSnapshotById(snapshotId: string): Promise { - const memorySnapshot = readMemorySnapshotById(snapshotId); +async function getFeedSnapshotByIdForSpec( + spec: FeedSnapshotSpec, + snapshotId: string +): Promise { + const memorySnapshot = readMemorySnapshotById(spec, snapshotId); if (memorySnapshot !== null) { return memorySnapshot; } - const snapshotData = await redis.get(snapshotKey(snapshotId)); + const snapshotData = await redis.get(snapshotKey(spec, snapshotId)); if (!snapshotData) { return null; } const snapshot = parseSnapshot(snapshotId, snapshotData); if (snapshot === null) { - await deleteCorruptSnapshot(snapshotId); + await deleteCorruptSnapshot(spec, snapshotId); return null; } - return await writeByIdMemorySnapshot(snapshot); + return await writeByIdMemorySnapshot(spec, snapshot); +} + +function feedSnapshotSpecForCommunity(community: FeedCommunity): FeedSnapshotSpec { + return { + sortedSetKey: community.redis.current, + currentSnapshotKey: community.redis.currentSnapshot, + generationKey: community.redis.snapshotGeneration, + snapshotKeyPrefix: community.redis.snapshotPrefix, + maxPosts: config.FEED_MAX_POSTS, + }; +} + +export async function getCurrentFeedSnapshot(): Promise { + return getCurrentFeedSnapshotForSpec(CURRENT_FEED_SNAPSHOT_SPEC); +} + +export async function getFeedSnapshotById(snapshotId: string): Promise { + return getFeedSnapshotByIdForSpec(CURRENT_FEED_SNAPSHOT_SPEC, snapshotId); +} + +export async function getCommunityFeedSnapshot(community: FeedCommunity): Promise { + return getCurrentFeedSnapshotForSpec(feedSnapshotSpecForCommunity(community)); +} + +export async function getCommunityFeedSnapshotById( + community: FeedCommunity, + snapshotId: string +): Promise { + return getFeedSnapshotByIdForSpec(feedSnapshotSpecForCommunity(community), snapshotId); } export function clearCurrentFeedSnapshotMemoryCache(): void { - currentMemorySnapshotCache = null; + currentMemorySnapshotCache.clear(); byIdMemorySnapshotCache.clear(); - currentSnapshotPromise = null; + currentSnapshotPromises.clear(); } -function clearCurrentSnapshotPointerMemoryCache(): void { - currentMemorySnapshotCache = null; - currentSnapshotPromise = null; +function clearCurrentSnapshotPointerMemoryCache(spec: FeedSnapshotSpec): void { + currentMemorySnapshotCache.delete(specCacheKey(spec)); + currentSnapshotPromises.delete(specCacheKey(spec)); } -export async function invalidateCurrentFeedSnapshot(): Promise { +async function invalidateFeedSnapshotForSpec(spec: FeedSnapshotSpec): Promise { try { await redis.eval( INVALIDATE_CURRENT_SNAPSHOT_SCRIPT, 2, - CURRENT_FEED_GENERATION_KEY, - CURRENT_FEED_SNAPSHOT_KEY + spec.generationKey, + spec.currentSnapshotKey ); } finally { - clearCurrentSnapshotPointerMemoryCache(); + clearCurrentSnapshotPointerMemoryCache(spec); } } +export async function invalidateCurrentFeedSnapshot(): Promise { + await invalidateFeedSnapshotForSpec(CURRENT_FEED_SNAPSHOT_SPEC); +} + +export async function invalidateCommunityFeedSnapshot(community: FeedCommunity): Promise { + await invalidateFeedSnapshotForSpec(feedSnapshotSpecForCommunity(community)); +} + export const __snapshotCacheKeysForTests = { currentSnapshotKey: CURRENT_FEED_SNAPSHOT_KEY, currentGenerationKey: CURRENT_FEED_GENERATION_KEY, diff --git a/src/governance/aggregation-math.ts b/src/governance/aggregation-math.ts new file mode 100644 index 00000000..486c0f7d --- /dev/null +++ b/src/governance/aggregation-math.ts @@ -0,0 +1,47 @@ +export interface TrimmedMeanAggregationInput { + rows: ReadonlyArray>; + components: readonly string[]; +} + +export interface TrimmedMeanAggregationResult { + values: Record; + trimCount: number; +} + +export function aggregateRowsWithTrimmedMean( + input: TrimmedMeanAggregationInput +): TrimmedMeanAggregationResult { + const n = input.rows.length; + if (n === 0) { + throw new Error('aggregateRowsWithTrimmedMean requires at least one row'); + } + + const trimPct = 0.1; + const trimCount = Math.floor(n * trimPct); + const effectiveTrimCount = n >= 10 ? trimCount : 0; + const values: Record = {}; + + for (const component of input.components) { + const componentValues = input.rows + .map((row, rowIndex) => { + const value = row[component]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error( + `aggregateRowsWithTrimmedMean received a non-finite value for component ${component} at row ${rowIndex}` + ); + } + return value; + }) + .sort((a, b) => a - b); + const trimmed = + effectiveTrimCount > 0 + ? componentValues.slice(effectiveTrimCount, n - effectiveTrimCount) + : componentValues; + values[component] = trimmed.reduce((sum, value) => sum + value, 0) / trimmed.length; + } + + return { + values, + trimCount: effectiveTrimCount, + }; +} diff --git a/src/governance/aggregation.ts b/src/governance/aggregation.ts index efea350f..bb2f650b 100644 --- a/src/governance/aggregation.ts +++ b/src/governance/aggregation.ts @@ -10,6 +10,7 @@ import { config } from '../config.js'; import { GOVERNANCE_WEIGHT_VOTE_FIELDS, VOTABLE_WEIGHT_PARAMS } from '../config/votable-params.js'; import { logger } from '../lib/logger.js'; import { GovernanceWeights, normalizeWeights, ContentRules, emptyContentRules } from './governance.types.js'; +import { aggregateRowsWithTrimmedMean } from './aggregation-math.js'; /** * Weight component names for iteration. @@ -91,34 +92,20 @@ export async function aggregateVotes(epochId: number): Promise= 10 ? trimCount : 0; - - const aggregated = Object.fromEntries( - WEIGHT_COMPONENTS.map((component) => [component, 0] as const) - ) as Record; + const aggregation = aggregateRowsWithTrimmedMean({ + rows: votes.rows as Array>, + components: WEIGHT_COMPONENTS, + }); + const aggregated = aggregation.values as Record; for (const component of WEIGHT_COMPONENTS) { - const values = votes.rows - .map((v: Record) => v[component]) - .sort((a: number, b: number) => a - b); - - // Trim extremes - const trimmed = - effectiveTrimCount > 0 - ? values.slice(effectiveTrimCount, n - effectiveTrimCount) - : values; - - // Calculate mean - const mean = trimmed.reduce((sum: number, v: number) => sum + v, 0) / trimmed.length; - aggregated[component] = mean; - logger.debug( - { component, original: values.length, trimmed: trimmed.length, mean }, + { + component, + original: n, + trimmed: n - aggregation.trimCount * 2, + mean: aggregated[component], + }, 'Component aggregation' ); } diff --git a/src/scoring/components/relevance.ts b/src/scoring/components/relevance.ts index d380ffe0..7d604bf9 100644 --- a/src/scoring/components/relevance.ts +++ b/src/scoring/components/relevance.ts @@ -38,8 +38,13 @@ export const CONFIDENCE_THRESHOLD = 0.5; * @returns Score between 0.0 and 1.0 (higher = more relevant to community preferences) */ export function scoreRelevance(post: PostForScoring, context: ScoringContext): number { - const topicVector = post.topicVector; - const topicWeights = context.epoch.topicWeights; + return scoreTopicVectorRelevance(post.topicVector, context.epoch.topicWeights); +} + +export function scoreTopicVectorRelevance( + topicVector: Record | undefined, + topicWeights: Record | undefined +): number { // No topic data on post = neutral if (!topicVector || Object.keys(topicVector).length === 0) { diff --git a/tests/birders-feed-materializer.test.ts b/tests/birders-feed-materializer.test.ts new file mode 100644 index 00000000..7fd4bec5 --- /dev/null +++ b/tests/birders-feed-materializer.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + materializeCommunityFeed, + postMatchesBridgeTerms, + postMatchesCommunityTerms, + scoutCommunityFeed, + type CommunityMaterializerRedis, +} from '../src/feed/community-materializer.js'; +import { + getFeedCommunities, + resolveFeedCommunityByRkey, +} from '../src/feed/community-registry.js'; + +function birdersCommunity() { + const community = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (!community) { + throw new Error('Birders community fixture missing from registry'); + } + return community; +} + +function communityGovCommunity() { + const community = resolveFeedCommunityByRkey('community-gov', getFeedCommunities()); + if (!community) { + throw new Error('community-gov fixture missing from registry'); + } + return community; +} + +function buildDbMock(rows: unknown[]) { + return { + query: vi.fn().mockResolvedValue({ rows }), + }; +} + +function buildRedisMock(): { + redisClient: CommunityMaterializerRedis; + calls: Array<{ command: string; args: unknown[] }>; + setCommandError: (error: Error | null) => void; +} { + const calls: Array<{ command: string; args: unknown[] }> = []; + let commandError: Error | null = null; + const transaction = { + del(key: string) { + calls.push({ command: 'del', args: [key] }); + return transaction; + }, + zadd(key: string, score: number, member: string) { + calls.push({ command: 'zadd', args: [key, score, member] }); + return transaction; + }, + set(key: string, value: string) { + calls.push({ command: 'set', args: [key, value] }); + return transaction; + }, + incr(key: string) { + calls.push({ command: 'incr', args: [key] }); + return transaction; + }, + async exec() { + const queuedCommands = calls.length; + calls.push({ command: 'exec', args: [] }); + return Array.from({ length: queuedCommands }, (_unused, index) => [ + index === 0 ? commandError : null, + index === 0 && commandError ? null : 'OK', + ] as [Error | null, unknown]); + }, + }; + return { + redisClient: { + multi() { + return transaction; + }, + }, + calls, + setCommandError(error: Error | null) { + commandError = error; + }, + }; +} + +describe('Birders feed readiness materializer', () => { + it('matches Birders and bridge terms without requiring Bluesky-specific fake data', () => { + const community = birdersCommunity(); + + expect(postMatchesCommunityTerms('Warbler field notes exported to a tiny CSV script.', community)).toBe(true); + expect(postMatchesBridgeTerms('Warbler field notes exported to a tiny CSV script.', community)).toBe(true); + expect(postMatchesCommunityTerms('A general product launch thread with no relevant terms.', community)).toBe(false); + }); + + it('builds a scout report from production-scored Birders candidates', async () => { + const community = birdersCommunity(); + const dbPool = buildDbMock([ + { + uri: 'at://did:plc:a/app.bsky.feed.post/1', + author_did: 'did:plc:a', + text: 'Warbler migration notes with a messy CSV export.', + active_epoch_id: 12, + recency_score: 0.5, + engagement_score: 0.2, + bridging_score: 0.9, + source_diversity_score: 0.7, + relevance_score: 0.95, + community_score: 0.73, + candidate_count: 150, + unique_author_count: 60, + bridge_post_count: 45, + strong_bridge_high_relevance_count: 33, + top_author_post_count: 12, + }, + { + uri: 'at://did:plc:b/app.bsky.feed.post/2', + author_did: 'did:plc:b', + text: 'Finch sighting and binocular notes from the park.', + active_epoch_id: 12, + recency_score: 0.7, + engagement_score: 0.1, + bridging_score: 0.3, + source_diversity_score: 0.6, + relevance_score: 0.9, + community_score: 0.592, + candidate_count: 150, + unique_author_count: 60, + bridge_post_count: 45, + strong_bridge_high_relevance_count: 33, + top_author_post_count: 12, + }, + ]); + + const report = await scoutCommunityFeed({ + community, + dbPool, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + }); + + expect(report).toMatchObject({ + communityId: 'birders_who_code', + activeEpochId: 12, + candidatePosts: 150, + candidatePostsPerDay: 50, + uniqueAuthors: 60, + uniqueAuthorsPerDay: 20, + bridgePostShare: 0.3, + topAuthorConcentration: 0.08, + strongBridgeHighRelevancePosts: 33, + strongBridgeHighRelevancePostsPerDay: 11, + status: 'thin', + }); + expect(report.samplePostUris).toEqual([ + 'at://did:plc:a/app.bsky.feed.post/1', + 'at://did:plc:b/app.bsky.feed.post/2', + ]); + expect(dbPool.query).toHaveBeenCalledWith( + expect.stringContaining('JOIN post_scores ps ON ps.post_uri = mp.uri AND ps.epoch_id = ae.id'), + expect.arrayContaining([72, expect.arrayContaining(['%birding%', '%python%'])]) + ); + }); + + it('reports a thin active epoch instead of unavailable when Birders has zero matching candidates', async () => { + const community = birdersCommunity(); + const dbPool = buildDbMock([ + { + uri: null, + author_did: null, + text: null, + active_epoch_id: 12, + recency_score: null, + engagement_score: null, + bridging_score: null, + source_diversity_score: null, + relevance_score: null, + community_score: null, + candidate_count: 0, + unique_author_count: 0, + bridge_post_count: 0, + strong_bridge_high_relevance_count: 0, + top_author_post_count: 0, + }, + ]); + + const report = await scoutCommunityFeed({ + community, + dbPool, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + }); + + expect(report).toMatchObject({ + activeEpochId: 12, + candidatePosts: 0, + uniqueAuthors: 0, + status: 'thin', + }); + expect(report.samplePostUris).toEqual([]); + expect(report.warnings).toEqual( + expect.arrayContaining([ + 'Birders supply is below the readiness threshold; keep the feed disabled.', + 'Candidate volume 0.0/day is below 100/day.', + ]) + ); + }); + + it('materializes only namespaced Birders Redis keys and leaves production feed keys untouched', async () => { + const community = birdersCommunity(); + const dbPool = buildDbMock([ + { + uri: 'at://did:plc:b/app.bsky.feed.post/2', + author_did: 'did:plc:b', + text: 'Finch sighting and binocular notes from the park.', + active_epoch_id: 12, + recency_score: 0.7, + engagement_score: 0.1, + bridging_score: 0.3, + source_diversity_score: 0.6, + relevance_score: 0.9, + community_score: 0.592, + candidate_count: 2, + unique_author_count: 2, + bridge_post_count: 1, + strong_bridge_high_relevance_count: 1, + top_author_post_count: 1, + }, + { + uri: 'at://did:plc:a/app.bsky.feed.post/1', + author_did: 'did:plc:a', + text: 'Warbler migration notes with a messy CSV export.', + active_epoch_id: 12, + recency_score: 0.5, + engagement_score: 0.2, + bridging_score: 0.9, + source_diversity_score: 0.7, + relevance_score: 0.95, + community_score: 0.73, + candidate_count: 2, + unique_author_count: 2, + bridge_post_count: 1, + strong_bridge_high_relevance_count: 1, + top_author_post_count: 1, + }, + ]); + const { redisClient, calls } = buildRedisMock(); + + const result = await materializeCommunityFeed({ + community, + dbPool, + redisClient, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + }); + + expect(result.rankedCount).toBe(2); + expect(calls.filter((call) => call.command === 'zadd')).toEqual([ + { + command: 'zadd', + args: ['feed:community:birders_who_code:current', 0.73, 'at://did:plc:a/app.bsky.feed.post/1'], + }, + { + command: 'zadd', + args: ['feed:community:birders_who_code:current', 0.592, 'at://did:plc:b/app.bsky.feed.post/2'], + }, + ]); + expect(calls.map((call) => call.args[0]).filter((key): key is string => typeof key === 'string')).toEqual( + expect.arrayContaining([ + 'feed:community:birders_who_code:current', + 'feed:community:birders_who_code:epoch', + 'feed:community:birders_who_code:health', + 'feed:community:birders_who_code:snapshot_generation', + 'feed:community:birders_who_code:current_snapshot_id', + ]) + ); + expect(calls.map((call) => call.args[0])).not.toContain('feed:current'); + expect(calls.map((call) => call.args[0])).not.toContain('feed:epoch'); + expect(calls.map((call) => call.command)).toContain('exec'); + }); + + it('rejects community-gov so the readiness materializer cannot overwrite production feed keys', async () => { + const dbPool = buildDbMock([]); + const { redisClient, calls } = buildRedisMock(); + + await expect( + materializeCommunityFeed({ + community: communityGovCommunity(), + dbPool, + redisClient, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + }) + ).rejects.toThrow('only supports birders_who_code'); + expect(dbPool.query).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it('surfaces Redis transaction command failures instead of reporting a successful feed write', async () => { + const community = birdersCommunity(); + const dbPool = buildDbMock([ + { + uri: 'at://did:plc:a/app.bsky.feed.post/1', + author_did: 'did:plc:a', + text: 'Open bird-call dataset and classifier notes.', + active_epoch_id: 12, + recency_score: 0.5, + engagement_score: 0.2, + bridging_score: 0.9, + source_diversity_score: 0.7, + relevance_score: 0.95, + community_score: 0.73, + candidate_count: 1, + unique_author_count: 1, + bridge_post_count: 1, + strong_bridge_high_relevance_count: 1, + top_author_post_count: 1, + }, + ]); + const { redisClient, setCommandError } = buildRedisMock(); + setCommandError(new Error('simulated Redis command failure')); + + await expect(materializeCommunityFeed({ + community, + dbPool, + redisClient, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + })).rejects.toThrow('simulated Redis command failure'); + }); +}); diff --git a/tests/birders-scout-command.test.ts b/tests/birders-scout-command.test.ts new file mode 100644 index 00000000..17de467a --- /dev/null +++ b/tests/birders-scout-command.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + BirdersScoutHelpRequested, + birdersScoutUsage, + parseBirdersScoutArgs, + renderBirdersScoutReport, + resolveBirdersCommunity, +} from '../src/feed/birders-scout-command.js'; + +describe('Birders scout command helpers', () => { + it('parses read-only scout defaults', () => { + expect(parseBirdersScoutArgs([])).toEqual({ + mode: 'scout', + json: false, + windowHours: 72, + limit: 500, + }); + }); + + it('parses explicit JSON materialize options', () => { + expect(parseBirdersScoutArgs(['--json', '--materialize', '--window-hours=24', '--limit', '75'])).toEqual({ + mode: 'materialize', + json: true, + windowHours: 24, + limit: 75, + }); + }); + + it('rejects invalid numeric options and unknown arguments', () => { + expect(() => parseBirdersScoutArgs(['--limit=0'])).toThrow('--limit must be a positive integer'); + expect(() => parseBirdersScoutArgs(['--window-hours', '1.5'])).toThrow( + '--window-hours must be a positive integer' + ); + expect(() => parseBirdersScoutArgs(['--surprise'])).toThrow('Unknown argument'); + }); + + it('uses a typed help signal for help requests', () => { + expect(() => parseBirdersScoutArgs(['--help'])).toThrow(BirdersScoutHelpRequested); + expect(birdersScoutUsage()).toContain('Default mode is read-only scout'); + }); + + it('resolves the disabled Birders registry entry for scout use', () => { + const community = resolveBirdersCommunity(); + + expect(community.communityId).toBe('birders_who_code'); + expect(community.status).toBe('disabled'); + }); + + it('renders the quantitative readiness report', () => { + const rendered = renderBirdersScoutReport({ + communityId: 'birders_who_code', + name: 'Birders Who Code', + status: 'thin', + source: 'production_scores', + activeEpochId: 12, + sampledAt: '2026-07-09T20:00:00.000Z', + windowHours: 72, + candidatePosts: 150, + candidatePostsPerDay: 50, + uniqueAuthors: 60, + uniqueAuthorsPerDay: 20, + bridgePostShare: 0.3, + topAuthorConcentration: 0.08, + strongBridgeHighRelevancePosts: 33, + strongBridgeHighRelevancePostsPerDay: 11, + samplePostUris: ['at://did:plc:a/app.bsky.feed.post/1'], + thresholds: { + candidatePostsPerDay: 100, + uniqueAuthorsPerDay: 30, + strongBridgeHighRelevancePostsPerDay: 10, + }, + warnings: ['Birders supply is below the readiness threshold; keep the feed disabled.'], + }); + + expect(rendered).toContain('Candidates: 150 (50.0/day; threshold 100/day)'); + expect(rendered).toContain('Bridge-post share: 30.0%'); + expect(rendered).toContain('Top-author concentration: 8.0%'); + }); +}); diff --git a/tests/demo-shadow-isolation.test.ts b/tests/demo-shadow-isolation.test.ts new file mode 100644 index 00000000..e36b82c4 --- /dev/null +++ b/tests/demo-shadow-isolation.test.ts @@ -0,0 +1,60 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + demoCorpusKeyPrefix, + demoIdempotencyKeyPrefix, + demoLockKeyPrefix, + demoSessionKeyPrefix, + demoSharedCorpusKeyPrefix, +} from '../src/demo/store.js'; + +const DEMO_SRC_DIR = new URL('../src/demo', import.meta.url).pathname; + +describe('shadow demo isolation guards', () => { + it('keeps Redis state inside the demo namespace', () => { + expect(demoSessionKeyPrefix()).toBe('demo:session:'); + expect(demoCorpusKeyPrefix()).toBe('demo:corpus:'); + expect(demoSharedCorpusKeyPrefix()).toBe('demo:corpus:current:'); + expect(demoIdempotencyKeyPrefix()).toBe('demo:idempotency:'); + expect(demoLockKeyPrefix()).toBe('demo:lock:'); + }); + + it('does not import production mutation or scoring pipeline entry points', () => { + const source = demoSourceText(); + + expect(source).not.toMatch(/forceEpochTransition/); + expect(source).not.toMatch(/closeCurrentEpochAndCreateNext/); + expect(source).not.toMatch(/runScoringPipeline/); + expect(source).not.toMatch(/from ['"].*\.\.\/governance\/routes\/vote/); + expect(source).not.toMatch(/from ['"].*\.\.\/scoring\/pipeline/); + }); + + it('does not write production governance, audit, feed, or export storage', () => { + const source = demoSourceText(); + + expect(source).not.toMatch(/\bINSERT\s+INTO\s+governance_/i); + expect(source).not.toMatch(/\bUPDATE\s+governance_/i); + expect(source).not.toMatch(/\bDELETE\s+FROM\s+governance_/i); + expect(source).not.toContain('feed:current'); + expect(source).not.toContain('feed:current_snapshot_id'); + expect(source).not.toMatch(/\bresearch_exports?\b/i); + }); +}); + +function demoSourceText(): string { + return sourceFiles(DEMO_SRC_DIR) + .map((path) => readFileSync(path, 'utf8')) + .join('\n'); +} + +function sourceFiles(dir: string): string[] { + const entries = readdirSync(dir).map((entry) => join(dir, entry)); + return entries.flatMap((entry) => { + const stat = statSync(entry); + if (stat.isDirectory()) { + return sourceFiles(entry); + } + return entry.endsWith('.ts') ? [entry] : []; + }); +} diff --git a/tests/demo-shadow-public-view.test.ts b/tests/demo-shadow-public-view.test.ts new file mode 100644 index 00000000..bb40c50b --- /dev/null +++ b/tests/demo-shadow-public-view.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { buildAppViewGetPostsUrl, hydrateCorpusItemsWithAppView } from '../src/demo/appview.js'; +import { + bskyPostUrlFromAtUri, + hiddenReasonForPublicView, + publicPostFromAppView, + type AppViewPost, +} from '../src/demo/public-view.js'; + +const PUBLIC_POST: AppViewPost = { + uri: 'at://did:plc:author/app.bsky.feed.post/abc123', + cid: 'bafyabc123', + author: { + did: 'did:plc:author', + handle: 'maya-keene.bsky.social', + displayName: 'Maya Keene', + avatar: 'https://cdn.example/avatar.jpg', + }, + record: { + text: 'A public birding post', + createdAt: '2026-07-09T12:00:00.000Z', + }, + indexedAt: '2026-07-09T12:00:01.000Z', + likeCount: 3, + repostCount: 2, + replyCount: 1, + quoteCount: 0, + labels: [], +}; + +describe('shadow demo public-view filtering', () => { + it('builds official AppView getPosts URLs with repeated uri params', () => { + const url = new URL( + buildAppViewGetPostsUrl([ + 'at://did:plc:one/app.bsky.feed.post/a', + 'at://did:plc:two/app.bsky.feed.post/b', + ]) + ); + + expect(url.origin).toBe('https://public.api.bsky.app'); + expect(url.pathname).toBe('/xrpc/app.bsky.feed.getPosts'); + expect(url.searchParams.getAll('uris')).toEqual([ + 'at://did:plc:one/app.bsky.feed.post/a', + 'at://did:plc:two/app.bsky.feed.post/b', + ]); + }); + + it('renders public AppView records as Bluesky display posts', () => { + expect(publicPostFromAppView(PUBLIC_POST)).toMatchObject({ + kind: 'public_post', + uri: PUBLIC_POST.uri, + authorHandle: 'maya-keene.bsky.social', + text: 'A public birding post', + likeCount: 3, + }); + }); + + it('hides no-unauthenticated, hide, and adult-only labeled records', () => { + expect( + hiddenReasonForPublicView({ + ...PUBLIC_POST, + labels: [{ val: '!no-unauthenticated' }], + }) + ).toContain('!no-unauthenticated'); + expect( + hiddenReasonForPublicView({ + ...PUBLIC_POST, + labels: [{ val: '!hide' }], + }) + ).toContain('!hide'); + expect( + hiddenReasonForPublicView({ + ...PUBLIC_POST, + labels: [{ val: 'porn' }], + }) + ).toContain('porn'); + }); + + it('hidden rows do not expose text, handle, avatar, or URL fields', () => { + const display = publicPostFromAppView({ + ...PUBLIC_POST, + labels: [{ val: '!hide' }], + }); + + expect(display).toEqual({ + kind: 'hidden_post', + reason: 'Hidden by Bluesky public-view label !hide', + }); + expect(JSON.stringify(display)).not.toContain('maya-keene'); + expect(JSON.stringify(display)).not.toContain('A public birding post'); + expect(JSON.stringify(display)).not.toContain('avatar'); + expect(JSON.stringify(display)).not.toContain('bsky.app'); + }); + + it('builds Bluesky source URLs from AT Protocol post URIs', () => { + expect(bskyPostUrlFromAtUri('at://did:plc:author/app.bsky.feed.post/abc123')).toBe( + 'https://bsky.app/profile/did%3Aplc%3Aauthor/post/abc123' + ); + }); + + it('rejects reserved or control characters in Bluesky post AT-URIs', () => { + for (const uri of [ + 'at://did:plc:author/app.bsky.feed.post/abc?next=1', + 'at://did:plc:author/app.bsky.feed.post/abc#fragment', + 'at://did:plc:author/app.bsky.feed.post/abc 123', + ]) { + expect(() => bskyPostUrlFromAtUri(uri)).toThrow(/non-post AT-URI/); + } + }); + + it('fails closed for malformed post and author label arrays', () => { + for (const malformed of [ + { ...PUBLIC_POST, labels: 'not-an-array' }, + { ...PUBLIC_POST, author: { ...PUBLIC_POST.author, labels: 'not-an-array' } }, + ]) { + expect(publicPostFromAppView(malformed)).toEqual({ + kind: 'hidden_post', + reason: 'Post metadata unavailable from Bluesky public AppView', + }); + } + }); + + it('normalizes invalid engagement counters to zero', () => { + const display = publicPostFromAppView({ + ...PUBLIC_POST, + likeCount: -1, + repostCount: 1.5, + replyCount: Number.NaN, + quoteCount: Number.POSITIVE_INFINITY, + }); + + expect(display).toMatchObject({ + kind: 'public_post', + likeCount: 0, + repostCount: 0, + replyCount: 0, + quoteCount: 0, + }); + }); + + it('turns missing public metadata into an identity-free hidden row', () => { + for (const malformed of [ + { ...PUBLIC_POST, cid: undefined }, + { ...PUBLIC_POST, indexedAt: undefined }, + { ...PUBLIC_POST, author: { ...PUBLIC_POST.author, did: undefined } }, + { ...PUBLIC_POST, author: { ...PUBLIC_POST.author, handle: ' ' } }, + { ...PUBLIC_POST, record: { ...PUBLIC_POST.record, createdAt: undefined } }, + { ...PUBLIC_POST, record: { ...PUBLIC_POST.record, text: ' ' } }, + ]) { + const display = publicPostFromAppView(malformed); + expect(display.kind).toBe('hidden_post'); + expect(JSON.stringify(display)).not.toContain('maya-keene'); + expect(JSON.stringify(display)).not.toContain('A public birding post'); + } + }); + + it('isolates malformed AppView posts while hydrating valid neighbors', async () => { + const items = [ + corpusItem('at://did:plc:author/app.bsky.feed.post/abc123'), + corpusItem('at://did:plc:other/app.bsky.feed.post/broken'), + ]; + const hydrated = await hydrateCorpusItemsWithAppView({ + items, + timeoutMs: 1_000, + fetchFn: async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + posts: [ + PUBLIC_POST, + { + ...PUBLIC_POST, + uri: 'at://did:plc:other/app.bsky.feed.post/broken', + cid: 42, + }, + ], + }), + }), + }); + + expect(hydrated[0].displayPost.kind).toBe('public_post'); + expect(hydrated[1].displayPost).toEqual({ + kind: 'hidden_post', + reason: 'Post unavailable from Bluesky public AppView', + }); + }); + + it('withholds a failed AppView batch while preserving successful batches', async () => { + const items = Array.from({ length: 26 }, (_, index) => + corpusItem(`at://did:plc:author/app.bsky.feed.post/post-${index}`) + ); + let requestCount = 0; + + const hydrated = await hydrateCorpusItemsWithAppView({ + items, + timeoutMs: 1_000, + fetchFn: async (input) => { + requestCount += 1; + if (requestCount === 1) { + return { + ok: false, + status: 503, + text: async () => 'temporarily unavailable', + }; + } + const uri = new URL(input).searchParams.get('uris'); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + posts: [{ ...PUBLIC_POST, uri }], + }), + }; + }, + }); + + expect(requestCount).toBe(2); + expect(hydrated.slice(0, 25).every((item) => item.displayPost.kind === 'hidden_post')).toBe(true); + expect(hydrated[25].displayPost.kind).toBe('public_post'); + }); +}); + +function corpusItem(postUri: string) { + return { + postUri, + authorDid: null, + createdAt: '2026-07-09T12:00:00.000Z', + topicVector: { 'science-research': 0.8 }, + rawScores: { + recency: 0.5, + engagement: 0.5, + bridging: 0.5, + source_diversity: 0.5, + relevance: 0.5, + }, + productionScore: 0.5, + productionEpochId: 2, + scoredAt: '2026-07-09T12:00:00.000Z', + componentDetails: null, + displayPost: { kind: 'hidden_post' as const, reason: 'Not hydrated' }, + }; +} diff --git a/tests/demo-shadow-routes.test.ts b/tests/demo-shadow-routes.test.ts new file mode 100644 index 00000000..2fad59ab --- /dev/null +++ b/tests/demo-shadow-routes.test.ts @@ -0,0 +1,767 @@ +import { describe, expect, it, vi } from 'vitest'; +import swagger from '@fastify/swagger'; +import { ShadowDemoService } from '../src/demo/service.js'; +import { MemoryDemoStore } from '../src/demo/store.js'; +import { registerShadowDemoRoutes } from '../src/demo/routes.js'; +import type { + ShadowDemoCorpus, + ShadowDemoEnvelope, + ShadowDemoSessionPayload, +} from '../src/demo/types.js'; +import { + SHADOW_DEMO_GUIDED_EPOCHS, + SHADOW_DEMO_MAX_EPOCHS_PER_SESSION, + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + SHADOW_DEMO_TOTAL_DEMO_VOTERS, +} from '../src/demo/types.js'; +import { buildTestApp } from './helpers/index.js'; + +const NOW = new Date('2026-07-09T12:00:00.000Z'); + +describe('shadow demo routes', () => { + it('publishes demo endpoints under the Demo OpenAPI tag', async () => { + const app = buildTestApp(); + await app.register(swagger, { + openapi: { + info: { title: 'Shadow demo test', version: '1.0.0' }, + }, + }); + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + await app.ready(); + + const specification = app.swagger() as { + paths?: Record; + }; + expect(specification.paths?.['/api/demo/sessions']?.post?.tags).toContain('Demo'); + expect(specification.paths?.['/api/demo/sessions/{sessionId}/feed']?.get?.tags).toContain('Demo'); + + await app.close(); + }); + + it('runs the reviewer vote, deterministic synthetic voters, epoch advance, feed, and receipt flow', async () => { + const app = buildTestApp(); + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + expect(createResponse.statusCode).toBe(200); + const createBody = createResponse.json() as ShadowDemoEnvelope; + const sessionId = createBody.payload.session.sessionId; + const firstEpochId = createBody.payload.session.currentEpochId; + expect(createBody.payload.session.maxEpochs).toBe(10); + expect(createBody.payload.session.guidedEpochs).toBe(SHADOW_DEMO_GUIDED_EPOCHS); + expect(createBody.payload.session.syntheticVoterCount).toBe(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT); + expect(createBody.payload.session.totalDemoVoters).toBe(SHADOW_DEMO_TOTAL_DEMO_VOTERS); + expect(createBody.payload.session.corpusProvenance.description).toContain('frozen for this demo run'); + expect(createBody.payload.session.voterProfiles.map((profile) => profile.id)).toEqual([ + 'research_practitioner', + 'dataset_steward', + 'current_awareness', + 'community_discussant', + 'interdisciplinary_connector', + ]); + + const initialFeedResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${sessionId}/feed?limit=3`, + }); + expect(initialFeedResponse.statusCode).toBe(200); + expect(initialFeedResponse.json().payload.posts[0].post.uri).toBe('at://did:plc:demo2/app.bsky.feed.post/two'); + + const votePayload = { + baseEpochId: firstEpochId, + idempotencyKey: 'vote-1', + weights: { + recency: 0, + engagement: 0, + bridging: 0, + source_diversity: 0, + relevance: 1, + }, + topicIntent: { + topicWeights: { + 'science-research': 0.9, + 'data-science': 0.85, + 'software-development': 0.7, + 'open-source': 0.8, + }, + }, + }; + const voteResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + payload: votePayload, + }); + expect(voteResponse.statusCode).toBe(200); + expect(voteResponse.json().payload.session.voteCount).toBe(1); + + const repeatedVoteResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + payload: votePayload, + }); + expect(repeatedVoteResponse.statusCode).toBe(200); + expect(repeatedVoteResponse.json().payload.session.voteCount).toBe(1); + + const conflictingVoteResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + payload: { + ...votePayload, + weights: { + recency: 1, + engagement: 0, + bridging: 0, + source_diversity: 0, + relevance: 0, + }, + }, + }); + expect(conflictingVoteResponse.statusCode).toBe(409); + + const syntheticVotersResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/agents/run`, + payload: { + baseEpochId: firstEpochId, + idempotencyKey: 'synthetic-voters-1', + }, + }); + expect(syntheticVotersResponse.statusCode).toBe(200); + expect(syntheticVotersResponse.json().payload.session.voteCount).toBe(SHADOW_DEMO_TOTAL_DEMO_VOTERS); + expect(syntheticVotersResponse.json().payload.session.votes).toHaveLength(SHADOW_DEMO_TOTAL_DEMO_VOTERS); + expect(syntheticVotersResponse.json().payload.session.epochs[0].aggregate).toMatchObject({ + voteCount: 0, + trimCount: 0, + }); + expect(syntheticVotersResponse.json().payload.session.pendingAggregate).toMatchObject({ + voteCount: SHADOW_DEMO_TOTAL_DEMO_VOTERS, + trimCount: 2, + }); + + const advanceResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/epochs/advance`, + payload: { + fromEpochId: firstEpochId, + idempotencyKey: 'advance-1', + }, + }); + expect(advanceResponse.statusCode).toBe(200); + const nextEpochId = advanceResponse.json().payload.session.currentEpochId; + expect(nextEpochId).not.toBe(firstEpochId); + + const rerankedFeedResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${sessionId}/feed?epochId=${nextEpochId}&limit=3`, + }); + expect(rerankedFeedResponse.statusCode).toBe(200); + const rerankedPosts = rerankedFeedResponse.json().payload.posts; + const firstPost = rerankedPosts[0]; + expect(firstPost.post.kind).toBe('public_post'); + expect(firstPost.previousRank).toBeTypeOf('number'); + expect(rerankedPosts.some((post: { movement: number | null }) => post.movement !== null && post.movement !== 0)).toBe(true); + + const receiptResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${sessionId}/receipts?epochId=${nextEpochId}&postUri=${encodeURIComponent(firstPost.post.uri)}`, + }); + expect(receiptResponse.statusCode).toBe(200); + const receipt = receiptResponse.json().payload.receipt; + const contributionSum = receipt.components.reduce( + (sum: number, component: { contribution: number }) => sum + component.contribution, + 0 + ); + expect(contributionSum).toBeCloseTo(receipt.score, 5); + expect(receipt.counterfactuals.map((entry: { label: string }) => entry.label)).toEqual([ + 'previous_epoch', + 'engagement_only', + 'without_reviewer_vote', + ]); + for (const topic of receipt.topicSignals as Array<{ topic: string }>) { + expect(Object.hasOwn(receipt.aggregate.topicIntent.topicWeights, topic.topic)).toBe(true); + } + + const staleVoteResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + payload: { + baseEpochId: firstEpochId, + weights: votePayload.weights, + topicIntent: votePayload.topicIntent, + }, + }); + expect(staleVoteResponse.statusCode).toBe(409); + + const outsideReceiptResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${sessionId}/receipts?postUri=${encodeURIComponent('at://did:plc:nope/app.bsky.feed.post/nope')}`, + }); + expect(outsideReceiptResponse.statusCode).toBe(400); + + await app.close(); + }); + + it('does not expose score math for rows hidden by Bluesky public-view policy', async () => { + const app = buildTestApp(); + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => ({ + ...demoCorpus(), + items: [ + { + ...item({ + index: 4, + text: 'Hidden but internally rankable', + rawScores: { + recency: 1, + engagement: 1, + bridging: 1, + source_diversity: 1, + relevance: 1, + }, + }), + displayPost: { + kind: 'hidden_post', + reason: 'Hidden by Bluesky public-view label !hide', + }, + }, + item({ + index: 1, + text: 'Public comparator', + rawScores: { + recency: 0.1, + engagement: 0.1, + bridging: 0.1, + source_diversity: 0.1, + relevance: 0.1, + }, + }), + ], + }), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + const sessionId = createResponse.json().payload.session.sessionId; + const feedResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${sessionId}/feed?limit=2`, + }); + const hiddenRow = feedResponse.json().payload.posts[0]; + + expect(hiddenRow.post).toEqual({ + kind: 'hidden_post', + reason: 'Hidden by Bluesky public-view label !hide', + }); + expect(hiddenRow.score).toBeNull(); + expect(hiddenRow.rawScores).toBeNull(); + expect(hiddenRow.weightedComponents).toBeNull(); + + await app.close(); + }); + + it('caps a session at ten shadow epochs', async () => { + const app = buildTestApp(); + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + const sessionId = createResponse.json().payload.session.sessionId; + let currentEpochId = createResponse.json().payload.session.currentEpochId as string; + + for (let sequence = 1; sequence < SHADOW_DEMO_MAX_EPOCHS_PER_SESSION; sequence += 1) { + const voteResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + payload: { + baseEpochId: currentEpochId, + idempotencyKey: `vote-${sequence}`, + weights: { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.2, + }, + topicIntent: { topicWeights: { 'science-research': 0.8 } }, + }, + }); + expect(voteResponse.statusCode).toBe(200); + const votersResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/agents/run`, + payload: { + baseEpochId: currentEpochId, + idempotencyKey: `voters-${sequence}`, + }, + }); + expect(votersResponse.statusCode).toBe(200); + const advanceResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/epochs/advance`, + payload: { + fromEpochId: currentEpochId, + idempotencyKey: `advance-${sequence}`, + }, + }); + expect(advanceResponse.statusCode).toBe(200); + currentEpochId = advanceResponse.json().payload.session.currentEpochId as string; + } + + const cappedResponse = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/epochs/advance`, + payload: { + fromEpochId: currentEpochId, + idempotencyKey: 'advance-over-cap', + }, + }); + + expect(cappedResponse.statusCode).toBe(409); + expect(cappedResponse.json().message).toContain('10 epoch limit'); + + await app.close(); + }); + + it('reuses a short-lived shared corpus cache while freezing separate session corpora', async () => { + const app = buildTestApp(); + let loadCount = 0; + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => { + loadCount += 1; + return demoCorpus(); + }, + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + const secondResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + expect(loadCount).toBe(1); + expect(firstResponse.json().payload.session.sessionId).not.toBe(secondResponse.json().payload.session.sessionId); + + await app.close(); + }); + + it('refreshes the live corpus only by creating a new frozen session boundary', async () => { + const app = buildTestApp(); + let loadCount = 0; + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => { + loadCount += 1; + if (loadCount === 1) { + return demoCorpus(); + } + return { + ...demoCorpus(), + items: [ + item({ + index: 1, + text: 'Refreshed candidate now leads the live-scored snapshot.', + rawScores: { + recency: 0.9, + engagement: 1, + bridging: 0.8, + source_diversity: 0.8, + relevance: 0.9, + }, + }), + ...demoCorpus().items.slice(1), + ], + }; + }, + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + const firstSessionId = firstResponse.json().payload.session.sessionId; + + const refreshedResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders', refreshCorpus: true }, + }); + const refreshedSessionId = refreshedResponse.json().payload.session.sessionId; + + const firstFeedResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${firstSessionId}/feed?limit=1`, + }); + const refreshedFeedResponse = await app.inject({ + method: 'GET', + url: `/api/demo/sessions/${refreshedSessionId}/feed?limit=1`, + }); + + expect(loadCount).toBe(2); + expect(firstFeedResponse.json().payload.posts[0].post.uri).toBe('at://did:plc:demo2/app.bsky.feed.post/two'); + expect(refreshedFeedResponse.json().payload.posts[0].post.uri).toBe('at://did:plc:demo1/app.bsky.feed.post/one'); + + await app.close(); + }); + + it('rejects a refresh immediately when another corpus refresh owns the build lock', async () => { + const app = buildTestApp(); + const store = new MemoryDemoStore(); + await store.acquireCorpusBuildLock('open_science_builders', 'held-refresh', 15_000); + const service = new ShadowDemoService({ + store, + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + + const response = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders', refreshCorpus: true }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().message).toContain('refresh is already running'); + await store.releaseCorpusBuildLock('open_science_builders', 'held-refresh'); + await app.close(); + }); + + it('waits through a slow shared corpus build instead of creating a false conflict', async () => { + vi.useFakeTimers(); + const store = new MemoryDemoStore(); + await store.acquireCorpusBuildLock('open_science_builders', 'slow-builder', 15_000); + const service = new ShadowDemoService({ + store, + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + + try { + const sessionPromise = service.createSession({ + communityId: 'open_science_builders', + refreshCorpus: false, + }); + setTimeout(() => { + void store.writeSharedCorpus('open_science_builders', demoCorpus(), 300); + }, 3_000); + + await vi.advanceTimersByTimeAsync(3_100); + + await expect(sessionPromise).resolves.toMatchObject({ + payload: { + session: { + community: { id: 'open_science_builders' }, + }, + }, + }); + } finally { + await store.releaseCorpusBuildLock('open_science_builders', 'slow-builder'); + vi.useRealTimers(); + } + }); + + it('returns one conflict when the corpus build never publishes before its lease expires', async () => { + vi.useFakeTimers(); + const store = new MemoryDemoStore(); + await store.acquireCorpusBuildLock('open_science_builders', 'stalled-builder', 15_000); + const service = new ShadowDemoService({ + store, + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + + try { + const sessionPromise = service.createSession({ + communityId: 'open_science_builders', + refreshCorpus: false, + }); + const conflict = expect(sessionPromise).rejects.toThrow(/corpus is warming/); + + await vi.advanceTimersByTimeAsync(15_600); + + await conflict; + } finally { + await store.releaseCorpusBuildLock('open_science_builders', 'stalled-builder'); + vi.useRealTimers(); + } + }); + + it('renews a long corpus-build lease so a second builder cannot take ownership', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const store = new ExpiringCorpusLockStore(); + const service = new ShadowDemoService({ + store, + loadCorpus: async () => { + for (let batch = 0; batch < 4; batch += 1) { + await testDelay(8_000); + } + return demoCorpus(); + }, + now: () => NOW, + }); + + try { + const firstSession = service.createSession({ + communityId: 'open_science_builders', + refreshCorpus: false, + }); + await vi.advanceTimersByTimeAsync(16_000); + const secondSession = service.createSession({ + communityId: 'open_science_builders', + refreshCorpus: false, + }); + const secondConflict = expect(secondSession).rejects.toThrow(/corpus is warming/); + + await vi.advanceTimersByTimeAsync(17_000); + + await expect(firstSession).resolves.toMatchObject({ sessionId: expect.stringMatching(/^demo-/) }); + await secondConflict; + expect(store.successfulAcquireCount).toBe(1); + expect(store.renewalCount).toBeGreaterThanOrEqual(6); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects malformed header idempotency keys with the same validation as body keys', async () => { + const app = buildTestApp(); + const service = new ShadowDemoService({ + store: new MemoryDemoStore(), + loadCorpus: async () => demoCorpus(), + now: () => NOW, + }); + registerShadowDemoRoutes(app, service); + const createResponse = await app.inject({ + method: 'POST', + url: '/api/demo/sessions', + payload: { communityId: 'open_science_builders' }, + }); + const sessionId = createResponse.json().payload.session.sessionId; + const epochId = createResponse.json().payload.session.currentEpochId; + + const response = await app.inject({ + method: 'POST', + url: `/api/demo/sessions/${sessionId}/votes`, + headers: { 'idempotency-key': 'contains spaces' }, + payload: { + baseEpochId: epochId, + weights: { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.2, + }, + topicIntent: { topicWeights: {} }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('idempotency-key header is malformed'); + await app.close(); + }); +}); + +function demoCorpus(): ShadowDemoCorpus { + return { + corpusId: 'corpus-test', + communityId: 'open_science_builders', + baseProductionEpochId: 2, + baseWeights: { + recency: 0, + engagement: 1, + bridging: 0, + source_diversity: 0, + relevance: 0, + }, + baseTopicIntent: { + topicWeights: {}, + }, + createdAt: NOW.toISOString(), + expiresAt: new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(), + health: { + status: 'live', + source: 'production_scores_appview', + candidatePosts72h: 3, + publicScoredPosts: 3, + uniqueAuthors72h: 3, + bridgePostShare: 0.333, + topAuthorConcentration: 0.333, + sampledAt: NOW.toISOString(), + }, + warnings: [], + items: [ + item({ + index: 1, + text: 'Open-source bird-call classifier dataset just dropped.', + rawScores: { + recency: 0.4, + engagement: 0.2, + bridging: 0.9, + source_diversity: 0.7, + relevance: 0.95, + }, + }), + item({ + index: 2, + text: 'Programmers will do anything except go outside.', + rawScores: { + recency: 0.5, + engagement: 0.98, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.35, + }, + }), + item({ + index: 3, + text: 'Field notes from a rainy owl survey, plus the messy CSV.', + rawScores: { + recency: 0.7, + engagement: 0.5, + bridging: 0.8, + source_diversity: 0.8, + relevance: 0.82, + }, + }), + ], + }; +} + +class ExpiringCorpusLockStore extends MemoryDemoStore { + successfulAcquireCount = 0; + renewalCount = 0; + private corpusLockOwner: string | null = null; + private corpusLockExpiresAt = 0; + + override async acquireCorpusBuildLock( + _communityId: 'open_science_builders', + token: string, + ttlMs: number + ): Promise { + if (this.corpusLockOwner !== null && this.corpusLockExpiresAt > Date.now()) { + return false; + } + this.corpusLockOwner = token; + this.corpusLockExpiresAt = Date.now() + ttlMs; + this.successfulAcquireCount += 1; + return true; + } + + override async renewCorpusBuildLock( + _communityId: 'open_science_builders', + token: string, + ttlMs: number + ): Promise { + if (this.corpusLockOwner !== token || this.corpusLockExpiresAt <= Date.now()) { + return false; + } + this.corpusLockExpiresAt = Date.now() + ttlMs; + this.renewalCount += 1; + return true; + } + + override async releaseCorpusBuildLock( + _communityId: 'open_science_builders', + token: string + ): Promise { + if (this.corpusLockOwner === token) { + this.corpusLockOwner = null; + this.corpusLockExpiresAt = 0; + } + } +} + +async function testDelay(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function item(options: { + index: number; + text: string; + rawScores: ShadowDemoCorpus['items'][number]['rawScores']; +}): ShadowDemoCorpus['items'][number] { + const uri = `at://did:plc:demo${options.index}/app.bsky.feed.post/${numberWord(options.index)}`; + return { + postUri: uri, + authorDid: `did:plc:demo${options.index}`, + createdAt: NOW.toISOString(), + topicVector: { 'science-research': options.rawScores.relevance }, + rawScores: options.rawScores, + productionScore: options.rawScores.engagement, + productionEpochId: 2, + scoredAt: NOW.toISOString(), + componentDetails: null, + displayPost: { + kind: 'public_post', + uri, + cid: `bafy${options.index}`, + authorDid: `did:plc:demo${options.index}`, + authorHandle: `user${options.index}.bsky.social`, + authorDisplayName: `User ${options.index}`, + authorAvatar: null, + text: options.text, + likeCount: options.index, + repostCount: options.index, + replyCount: options.index, + quoteCount: 0, + indexedAt: NOW.toISOString(), + createdAt: NOW.toISOString(), + bskyUrl: `https://bsky.app/profile/user${options.index}.bsky.social/post/${numberWord(options.index)}`, + }, + }; +} + +function numberWord(value: number): string { + if (value === 1) { + return 'one'; + } + if (value === 2) { + return 'two'; + } + return 'three'; +} diff --git a/tests/demo-shadow-store.test.ts b/tests/demo-shadow-store.test.ts new file mode 100644 index 00000000..a503a728 --- /dev/null +++ b/tests/demo-shadow-store.test.ts @@ -0,0 +1,112 @@ +import type { Redis } from 'ioredis'; +import { describe, expect, it, vi } from 'vitest'; +import { DemoStoreCorruptionError, RedisDemoStore } from '../src/demo/store.js'; +import type { ShadowDemoSessionState } from '../src/demo/types.js'; + +describe('Redis shadow demo store', () => { + it('writes session and frozen corpus in one Redis transaction', async () => { + const transaction = { + setex: vi.fn(), + exec: vi.fn(async () => [[null, 'OK'], [null, 'OK']]), + }; + transaction.setex.mockReturnValue(transaction); + const redis = { multi: vi.fn(() => transaction) } as unknown as Redis; + const store = new RedisDemoStore(redis); + const session = storedSession(); + + await store.writeSession(session, 120); + + expect(redis.multi).toHaveBeenCalledTimes(1); + expect(transaction.setex).toHaveBeenCalledTimes(2); + expect(transaction.setex.mock.calls.map((call) => call[0])).toEqual([ + 'demo:session:demo-store-session', + 'demo:corpus:demo-store-corpus', + ]); + expect(transaction.exec).toHaveBeenCalledTimes(1); + }); + + it('returns null only for missing records and raises explicit corruption errors', async () => { + const get = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('{not-json') + .mockResolvedValueOnce(JSON.stringify({ sessionId: 'partial' })); + const store = new RedisDemoStore({ get } as unknown as Redis); + + await expect(store.readSession('missing')).resolves.toBeNull(); + await expect(store.readSession('malformed')).rejects.toBeInstanceOf(DemoStoreCorruptionError); + await expect(store.readSession('partial')).rejects.toBeInstanceOf(DemoStoreCorruptionError); + }); + + it('raises when a Redis transaction command fails', async () => { + const transaction = { + setex: vi.fn(), + exec: vi.fn(async () => [[null, 'OK'], [new Error('corpus write failed'), null]]), + }; + transaction.setex.mockReturnValue(transaction); + const redis = { multi: vi.fn(() => transaction) } as unknown as Redis; + const store = new RedisDemoStore(redis); + + await expect(store.writeSession(storedSession(), 120)).rejects.toThrow( + /Redis transaction failed.*corpus write failed/ + ); + }); + + it('rejects malformed nested epochs, votes, and corpus items', async () => { + const session = storedSession(); + const get = vi.fn() + .mockResolvedValueOnce(JSON.stringify({ ...session, epochs: [{}] })) + .mockResolvedValueOnce(JSON.stringify({ ...session, votes: [{}] })) + .mockResolvedValueOnce(JSON.stringify({ + ...session, + corpus: { ...session.corpus, items: [{}] }, + })); + const store = new RedisDemoStore({ get } as unknown as Redis); + + await expect(store.readSession('invalid-epoch')).rejects.toBeInstanceOf(DemoStoreCorruptionError); + await expect(store.readSession('invalid-vote')).rejects.toBeInstanceOf(DemoStoreCorruptionError); + await expect(store.readSession('invalid-item')).rejects.toBeInstanceOf(DemoStoreCorruptionError); + }); +}); + +function storedSession(): ShadowDemoSessionState { + return { + sessionId: 'demo-store-session', + communityId: 'open_science_builders', + seed: 'seed', + phase: 'created', + createdAt: '2026-07-10T00:00:00.000Z', + expiresAt: '2026-07-10T01:30:00.000Z', + corpusId: 'demo-store-corpus', + currentEpochId: 'epoch-1', + epochs: [], + votes: [], + corpus: { + corpusId: 'demo-store-corpus', + communityId: 'open_science_builders', + baseProductionEpochId: 2, + baseWeights: { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.2, + }, + baseTopicIntent: { topicWeights: { 'science-research': 0.8 } }, + createdAt: '2026-07-10T00:00:00.000Z', + expiresAt: '2026-07-10T01:30:00.000Z', + items: [], + health: { + status: 'live', + source: 'production_scores_appview', + candidatePosts72h: 100, + publicScoredPosts: 12, + uniqueAuthors72h: 50, + bridgePostShare: 0.2, + topAuthorConcentration: 0.05, + sampledAt: '2026-07-10T00:00:00.000Z', + }, + warnings: [], + }, + warnings: [], + }; +} diff --git a/tests/demo-shadow-weights.test.ts b/tests/demo-shadow-weights.test.ts new file mode 100644 index 00000000..8e724a5c --- /dev/null +++ b/tests/demo-shadow-weights.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { + aggregateShadowVotes, + scoreFromRawWeights, + validateShadowWeights, +} from '../src/demo/weights.js'; +import { + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + SHADOW_DEMO_TOTAL_DEMO_VOTERS, + type ShadowDemoWeights, +} from '../src/demo/types.js'; +import { + assertSyntheticVoterProfiles, + createSyntheticVoterVotes, + getShadowDemoVoterProfiles, +} from '../src/demo/synthetic-voters.js'; +import { aggregateRowsWithTrimmedMean } from '../src/governance/aggregation-math.js'; + +const RECENCY_ONLY: ShadowDemoWeights = { + recency: 1, + engagement: 0, + bridging: 0, + source_diversity: 0, + relevance: 0, +}; + +const ENGAGEMENT_ONLY: ShadowDemoWeights = { + recency: 0, + engagement: 1, + bridging: 0, + source_diversity: 0, + relevance: 0, +}; + +const TOPIC_INTENT = { + topicWeights: { + 'science-research': 0.9, + 'data-science': 0.75, + 'software-development': 0.7, + 'open-source': 0.8, + }, +}; + +describe('shadow demo weight math', () => { + it('validates finite non-negative weights that sum to one', () => { + expect(validateShadowWeights(RECENCY_ONLY)).toEqual(RECENCY_ONLY); + expect(() => + validateShadowWeights({ + recency: 0.5, + engagement: 0.5, + bridging: 0, + source_diversity: 0, + relevance: 0.2, + }) + ).toThrow(/sum to 1.0/); + expect(() => + validateShadowWeights({ + recency: Number.NaN, + engagement: 1, + bridging: 0, + source_diversity: 0, + relevance: 0, + }) + ).toThrow(/finite/); + }); + + it('does not trim small electorates below ten votes', () => { + const summary = aggregateShadowVotes([RECENCY_ONLY, ENGAGEMENT_ONLY]); + + expect(summary.aggregateMethod).toBe('trimmed_mean_no_trim_under_10'); + expect(summary.voteCount).toBe(2); + expect(summary.trimCount).toBe(0); + expect(summary.weights.recency).toBe(0.5); + expect(summary.weights.engagement).toBe(0.5); + }); + + it('trims extremes once ten or more votes exist', () => { + const middle: ShadowDemoWeights = { + recency: 0.5, + engagement: 0.5, + bridging: 0, + source_diversity: 0, + relevance: 0, + }; + const summary = aggregateShadowVotes([ + RECENCY_ONLY, + ENGAGEMENT_ONLY, + middle, + middle, + middle, + middle, + middle, + middle, + middle, + middle, + ]); + + expect(summary.voteCount).toBe(10); + expect(summary.trimCount).toBe(1); + expect(summary.weights.recency).toBe(0.5); + expect(summary.weights.engagement).toBe(0.5); + }); + + it('rejects malformed rows before trimmed-mean arithmetic', () => { + expect(() => + aggregateRowsWithTrimmedMean({ + rows: [{ recency: 0.5 }, { recency: Number.NaN }], + components: ['recency'], + }) + ).toThrow(/component recency at row 1/); + expect(() => + aggregateRowsWithTrimmedMean({ + rows: [{ recency: 0.5 }, {}], + components: ['recency'], + }) + ).toThrow(/component recency at row 1/); + }); + + it('generates twenty-four deterministic synthetic voters across visible blocs', () => { + assertSyntheticVoterProfiles(); + const profiles = getShadowDemoVoterProfiles(); + const profileVoteCount = profiles.reduce((sum, profile) => sum + profile.voterCount, 0); + const syntheticVotes = createSyntheticVoterVotes({ + seed: 'session-seed', + epochId: 'shadow-epoch-1', + communityId: 'open_science_builders', + reviewerWeights: RECENCY_ONLY, + reviewerTopicIntent: TOPIC_INTENT, + priorCommunityWeights: ENGAGEMENT_ONLY, + priorTopicIntent: TOPIC_INTENT, + createdAt: '2026-07-09T12:00:00.000Z', + }); + const repeatedVotes = createSyntheticVoterVotes({ + seed: 'session-seed', + epochId: 'shadow-epoch-1', + communityId: 'open_science_builders', + reviewerWeights: RECENCY_ONLY, + reviewerTopicIntent: TOPIC_INTENT, + priorCommunityWeights: ENGAGEMENT_ONLY, + priorTopicIntent: TOPIC_INTENT, + createdAt: '2026-07-09T12:00:00.000Z', + }); + + expect(profileVoteCount).toBe(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT); + expect(syntheticVotes).toHaveLength(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT); + expect(syntheticVotes).toEqual(repeatedVotes); + expect(new Set(syntheticVotes.map((vote) => vote.actorId)).size).toBe(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT); + expect(new Set(syntheticVotes.map((vote) => vote.blocId))).toEqual( + new Set([ + 'research_practitioner', + 'dataset_steward', + 'current_awareness', + 'community_discussant', + 'interdisciplinary_connector', + ]) + ); + }); + + it('exercises production trimming with reviewer plus synthetic voters', () => { + const syntheticVotes = createSyntheticVoterVotes({ + seed: 'session-seed', + epochId: 'shadow-epoch-1', + communityId: 'open_science_builders', + reviewerWeights: RECENCY_ONLY, + reviewerTopicIntent: TOPIC_INTENT, + priorCommunityWeights: ENGAGEMENT_ONLY, + priorTopicIntent: TOPIC_INTENT, + createdAt: '2026-07-09T12:00:00.000Z', + }); + const summary = aggregateShadowVotes([ + { weights: RECENCY_ONLY, topicIntent: TOPIC_INTENT }, + ...syntheticVotes, + ]); + + expect(summary.voteCount).toBe(SHADOW_DEMO_TOTAL_DEMO_VOTERS); + expect(summary.trimCount).toBe(2); + }); + + it('carries prior-policy inertia across otherwise identical epoch proposals', () => { + const common = { + seed: 'session-seed', + epochId: 'shadow-epoch-2', + communityId: 'open_science_builders', + reviewerWeights: RECENCY_ONLY, + reviewerTopicIntent: TOPIC_INTENT, + priorTopicIntent: TOPIC_INTENT, + createdAt: '2026-07-09T12:00:00.000Z', + }; + const engagementHistory = createSyntheticVoterVotes({ + ...common, + priorCommunityWeights: ENGAGEMENT_ONLY, + }); + const recencyHistory = createSyntheticVoterVotes({ + ...common, + priorCommunityWeights: RECENCY_ONLY, + }); + + expect(engagementHistory).not.toEqual(recencyHistory); + const engagementAggregate = aggregateShadowVotes(engagementHistory); + const recencyAggregate = aggregateShadowVotes(recencyHistory); + expect(recencyAggregate.weights.recency).toBeGreaterThan(engagementAggregate.weights.recency); + expect(engagementAggregate.weights.engagement).toBeGreaterThan(recencyAggregate.weights.engagement); + }); + + it('keeps receipt contribution math equal to displayed total', () => { + const scored = scoreFromRawWeights( + { + recency: 0.5, + engagement: 0.8, + bridging: 0.25, + source_diversity: 1, + relevance: 0.4, + }, + { + recency: 0.2, + engagement: 0.3, + bridging: 0.1, + source_diversity: 0.1, + relevance: 0.3, + }, + { 'science-research': 0.8 }, + TOPIC_INTENT + ); + const contributionSum = Object.values(scored.weightedComponents).reduce( + (sum, value) => sum + value, + 0 + ); + + expect(scored.score).toBeCloseTo(0.735, 6); + expect(contributionSum).toBeCloseTo(scored.score, 6); + }); +}); diff --git a/tests/feed-community-registry.test.ts b/tests/feed-community-registry.test.ts new file mode 100644 index 00000000..c2a8085c --- /dev/null +++ b/tests/feed-community-registry.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { config } from '../src/config.js'; +import { + feedUriForCommunity, + getFeedCommunities, + publicFeedUris, + resolveFeedCommunityByRkey, + resolveFeedCommunityByUri, + type FeedCommunity, +} from '../src/feed/community-registry.js'; +import { registerDescribeGenerator } from '../src/feed/routes/describe-generator.js'; +import { buildTestApp } from './helpers/index.js'; + +describe('feed community registry', () => { + it('resolves the public community-gov feed and keeps Birders disabled by default', () => { + const communities = getFeedCommunities(); + const communityGov = resolveFeedCommunityByRkey('community-gov', communities); + const birders = resolveFeedCommunityByRkey('birders-who-code', communities); + + expect(communityGov?.communityId).toBe('community-gov'); + expect(communityGov?.status).toBe('enabled'); + expect(communityGov?.public).toBe(true); + expect(birders?.communityId).toBe('birders_who_code'); + expect(birders?.status).toBe('disabled'); + expect(birders?.public).toBe(false); + expect(birders?.redis.current).toBe('feed:community:birders_who_code:current'); + }); + + it('advertises only public enabled feeds by default', () => { + const uris = publicFeedUris(getFeedCommunities(), config.FEEDGEN_PUBLISHER_DID); + + expect(uris).toEqual([ + `at://${config.FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/community-gov`, + ]); + }); + + it('resolves feed URIs from caller-provided registry entries', () => { + const birders = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (!birders) { + throw new Error('Birders community fixture missing from registry'); + } + const enabledBirders: FeedCommunity = { + ...birders, + status: 'enabled', + public: true, + }; + const uri = feedUriForCommunity(enabledBirders, config.FEEDGEN_PUBLISHER_DID); + + expect(resolveFeedCommunityByUri(uri, config.FEEDGEN_PUBLISHER_DID, [enabledBirders])).toEqual(enabledBirders); + }); +}); + +describe('describeFeedGenerator community visibility', () => { + it('hides disabled/private Birders from default discovery', async () => { + const app = buildTestApp(); + registerDescribeGenerator(app); + + const response = await app.inject({ + method: 'GET', + url: '/xrpc/app.bsky.feed.describeFeedGenerator', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + did: config.FEEDGEN_SERVICE_DID, + feeds: [ + { + uri: `at://${config.FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/community-gov`, + }, + ], + }); + + await app.close(); + }); +}); diff --git a/tests/feed-skeleton-validation.test.ts b/tests/feed-skeleton-validation.test.ts index 62fc5ee2..ac5890cd 100644 --- a/tests/feed-skeleton-validation.test.ts +++ b/tests/feed-skeleton-validation.test.ts @@ -37,6 +37,12 @@ vi.mock('../src/db/client.js', () => ({ import { config } from '../src/config.js'; import { decodeCursor } from '../src/feed/cursor.js'; +import { + feedUriForCommunity, + getFeedCommunities, + resolveFeedCommunityByRkey, + type FeedCommunity, +} from '../src/feed/community-registry.js'; import { __resetFeedRequestTrackerForTests, drainFeedRequestTracker } from '../src/feed/request-tracker.js'; import { registerFeedSkeleton } from '../src/feed/routes/feed-skeleton.js'; import { clearCurrentFeedSnapshotMemoryCache } from '../src/feed/snapshot-cache.js'; @@ -249,6 +255,72 @@ describe('getFeedSkeleton query validation', () => { await app.close(); }); + it('rejects the disabled Birders feed without reading its Redis bucket', async () => { + const app = buildTestApp(); + registerFeedSkeleton(app); + + const birdersUri = `at://${config.FEEDGEN_PUBLISHER_DID}/app.bsky.feed.generator/birders-who-code`; + const response = await app.inject({ + method: 'GET', + url: `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(birdersUri)}&limit=2`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: 'UnsupportedAlgorithm', + message: 'Feed is disabled', + }); + expect(redisMock.zrevrange).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('serves an explicitly enabled Birders test config from its namespaced Redis bucket', async () => { + const birders = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (!birders) { + throw new Error('Birders community fixture missing from registry'); + } + const enabledBirders: FeedCommunity = { + ...birders, + status: 'enabled', + public: true, + includePinnedAnnouncements: false, + }; + const app = buildTestApp(); + registerFeedSkeleton(app, { communities: [enabledBirders] }); + + redisMock.zrevrange.mockResolvedValueOnce([ + 'at://did:plc:bird/app.bsky.feed.post/1', + 'at://did:plc:bird/app.bsky.feed.post/2', + ]); + redisMock.get.mockImplementation((key: string) => { + if (key === enabledBirders.redis.snapshotGeneration) return Promise.resolve('0'); + if (key === enabledBirders.redis.epoch) return Promise.resolve('9'); + return Promise.resolve(null); + }); + + const response = await app.inject({ + method: 'GET', + url: `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUriForCommunity(enabledBirders, config.FEEDGEN_PUBLISHER_DID))}&limit=2`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + feed: [ + { post: 'at://did:plc:bird/app.bsky.feed.post/1' }, + { post: 'at://did:plc:bird/app.bsky.feed.post/2' }, + ], + }); + expect(redisMock.zrevrange).toHaveBeenCalledWith( + 'feed:community:birders_who_code:current', + 0, + expect.any(Number) + ); + expect(redisMock.get).not.toHaveBeenCalledWith('bot:latest_announcement'); + + await app.close(); + }); + it('treats an empty cursor query as the first page', async () => { const app = buildTestApp(); registerFeedSkeleton(app); diff --git a/tests/rate-limit-config.test.ts b/tests/rate-limit-config.test.ts index 719abb25..7f20ec87 100644 --- a/tests/rate-limit-config.test.ts +++ b/tests/rate-limit-config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { FastifyRequest } from 'fastify'; import { config } from '../src/config.js'; -import { buildRouteRateLimitConfig } from '../src/feed/server.js'; +import { buildRouteRateLimitConfig } from '../src/feed/rate-limit-config.js'; const noopKeyGenerator = (_request: FastifyRequest) => 'key'; @@ -42,6 +42,47 @@ describe('route rate-limit policy', () => { expect(policy).toBeNull(); }); + it('applies vote-like limits to shadow demo mutation endpoints', () => { + const policy = buildRouteRateLimitConfig( + '/api/demo/sessions/session-1/votes', + 'POST', + noopKeyGenerator + ); + + expect(policy).toMatchObject({ + max: config.RATE_LIMIT_VOTE_MAX, + timeWindow: config.RATE_LIMIT_VOTE_WINDOW_MS, + keyGenerator: noopKeyGenerator, + }); + }); + + it('applies login-like limits to shadow demo session creation', () => { + const policy = buildRouteRateLimitConfig( + '/api/demo/sessions', + 'POST', + noopKeyGenerator + ); + + expect(policy).toMatchObject({ + max: config.RATE_LIMIT_LOGIN_MAX, + timeWindow: config.RATE_LIMIT_LOGIN_WINDOW_MS, + keyGenerator: noopKeyGenerator, + }); + }); + + it('applies bounded read limits to shadow demo read endpoints', () => { + const policy = buildRouteRateLimitConfig( + '/api/demo/sessions/session-1/feed', + 'GET', + noopKeyGenerator + ); + + expect(policy).toMatchObject({ + max: config.RATE_LIMIT_INTERACTIONS_MAX, + timeWindow: config.RATE_LIMIT_INTERACTIONS_WINDOW_MS, + }); + }); + it('applies critical limits to MCP transport endpoint', () => { const policy = buildRouteRateLimitConfig( '/mcp', diff --git a/tests/web-next-shadow-demo-contract.test.ts b/tests/web-next-shadow-demo-contract.test.ts new file mode 100644 index 00000000..3a5521bb --- /dev/null +++ b/tests/web-next-shadow-demo-contract.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + SHADOW_DEMO_AGGREGATION_METHOD, + SHADOW_DEMO_CONTRACT_VERSION, + SHADOW_DEMO_CORPUS_PROVENANCE, + SHADOW_DEMO_ENDPOINTS, + SHADOW_DEMO_GUIDED_EPOCHS, + SHADOW_DEMO_ISOLATION_CONTRACT, + SHADOW_DEMO_MAX_EPOCHS_PER_SESSION, + SHADOW_DEMO_SIGNAL_KEYS, + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT, + SHADOW_DEMO_TOTAL_DEMO_VOTERS, + SHADOW_DEMO_VOTER_BLOC_IDS, +} from '../web-next/app/demo/shadow-demo-contract'; +import { + SHADOW_DEMO_COMMUNITY_IDS as BACKEND_COMMUNITY_IDS, + SHADOW_DEMO_CONTRACT_VERSION as BACKEND_CONTRACT_VERSION, + SHADOW_DEMO_GUIDED_EPOCHS as BACKEND_GUIDED_EPOCHS, + SHADOW_DEMO_MAX_EPOCHS_PER_SESSION as BACKEND_MAX_EPOCHS, + SHADOW_DEMO_SIGNAL_KEYS as BACKEND_SIGNAL_KEYS, + SHADOW_DEMO_SYNTHETIC_VOTER_COUNT as BACKEND_SYNTHETIC_VOTERS, + SHADOW_DEMO_TOTAL_DEMO_VOTERS as BACKEND_TOTAL_VOTERS, + SHADOW_DEMO_VOTER_BLOC_IDS as BACKEND_VOTER_BLOC_IDS, +} from '../src/demo/types'; + +describe('web-next shadow demo contract', () => { + it('publishes the backend contract version, endpoints, and signal keys for the UI', () => { + expect(SHADOW_DEMO_CONTRACT_VERSION).toBe('2026-07-10.shadow-demo.v2'); + expect(SHADOW_DEMO_SIGNAL_KEYS).toEqual([ + 'recency', + 'engagement', + 'bridging', + 'source_diversity', + 'relevance', + ]); + expect(SHADOW_DEMO_ENDPOINTS).toEqual({ + createSession: '/api/demo/sessions', + readSession: '/api/demo/sessions/:sessionId', + castVote: '/api/demo/sessions/:sessionId/votes', + runSyntheticVoters: '/api/demo/sessions/:sessionId/agents/run', + advanceEpoch: '/api/demo/sessions/:sessionId/epochs/advance', + readFeed: '/api/demo/sessions/:sessionId/feed?epochId=&limit=', + readReceipt: '/api/demo/sessions/:sessionId/receipts?epochId=&postUri=', + }); + expect(SHADOW_DEMO_MAX_EPOCHS_PER_SESSION).toBe(10); + expect(SHADOW_DEMO_GUIDED_EPOCHS).toBe(5); + expect(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT).toBe(24); + expect(SHADOW_DEMO_TOTAL_DEMO_VOTERS).toBe(25); + expect(SHADOW_DEMO_CORPUS_PROVENANCE.description).toContain('frozen for this demo run'); + }); + + it('names production-faithful aggregation and demo-only isolation semantics', () => { + expect(SHADOW_DEMO_AGGREGATION_METHOD).toBe('trimmed_mean_no_trim_under_10'); + expect(SHADOW_DEMO_ISOLATION_CONTRACT).toMatchObject({ + productionGovernanceMutates: false, + productionFeedMutates: false, + productionAuditLogMutates: false, + researchExportsMutate: false, + stateBackend: 'redis_only_demo_namespace', + liveShadowCommunities: ['open_science_builders'], + }); + expect(SHADOW_DEMO_ISOLATION_CONTRACT.redisPrefixes).toEqual([ + 'demo:session:', + 'demo:corpus:', + 'demo:corpus:current:', + 'demo:idempotency:', + 'demo:lock:', + ]); + }); + + it('keeps backend and exported web constants in exact parity', () => { + expect(SHADOW_DEMO_CONTRACT_VERSION).toBe(BACKEND_CONTRACT_VERSION); + expect(SHADOW_DEMO_SIGNAL_KEYS).toEqual(BACKEND_SIGNAL_KEYS); + expect(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT).toBe(BACKEND_SYNTHETIC_VOTERS); + expect(SHADOW_DEMO_TOTAL_DEMO_VOTERS).toBe(BACKEND_TOTAL_VOTERS); + expect(SHADOW_DEMO_GUIDED_EPOCHS).toBe(BACKEND_GUIDED_EPOCHS); + expect(SHADOW_DEMO_MAX_EPOCHS_PER_SESSION).toBe(BACKEND_MAX_EPOCHS); + expect(SHADOW_DEMO_VOTER_BLOC_IDS).toEqual(BACKEND_VOTER_BLOC_IDS); + expect(SHADOW_DEMO_ISOLATION_CONTRACT.liveShadowCommunities).toEqual( + BACKEND_COMMUNITY_IDS.filter((id) => id === 'open_science_builders') + ); + + expect(new Set(SHADOW_DEMO_SIGNAL_KEYS).size).toBe(SHADOW_DEMO_SIGNAL_KEYS.length); + expect(SHADOW_DEMO_GUIDED_EPOCHS).toBeLessThanOrEqual(SHADOW_DEMO_MAX_EPOCHS_PER_SESSION); + expect(SHADOW_DEMO_TOTAL_DEMO_VOTERS).toBe(SHADOW_DEMO_SYNTHETIC_VOTER_COUNT + 1); + }); + + it('keeps the lab contract aligned with the exported contract language', () => { + const doc = readFileSync(new URL('../docs/lab/demo-shadow-governance-contract.md', import.meta.url), 'utf8'); + + expect(doc).toContain('shadow-demo'); + expect(doc).toContain(SHADOW_DEMO_AGGREGATION_METHOD); + expect(doc).toContain('reviewer plus 24 deterministic synthetic community voters'); + expect(doc).toContain('5 guided shadow epochs'); + expect(doc).toContain('10 shadow epochs'); + expect(doc).toContain('demo:session:*'); + expect(doc).toContain('demo:lock:*'); + expect(doc).not.toContain('equal voter average'); + }); +}); diff --git a/web-next/app/demo/shadow-demo-contract.ts b/web-next/app/demo/shadow-demo-contract.ts new file mode 100644 index 00000000..fddf615f --- /dev/null +++ b/web-next/app/demo/shadow-demo-contract.ts @@ -0,0 +1,253 @@ +export const SHADOW_DEMO_CONTRACT_VERSION = '2026-07-10.shadow-demo.v2' as const; + +export const SHADOW_DEMO_SIGNAL_KEYS = [ + 'recency', + 'engagement', + 'bridging', + 'source_diversity', + 'relevance', +] as const; + +export type ShadowDemoSignalKey = (typeof SHADOW_DEMO_SIGNAL_KEYS)[number]; + +export type ShadowDemoWeights = Record; + +export interface ShadowDemoTopicIntent { + topicWeights: Record; +} + +export type ShadowDemoRawScores = Record; + +export const SHADOW_DEMO_COMMUNITY_IDS = [ + 'open_science_builders', + 'birders_who_code', + 'crit_fumble_pickup', + 'osint_garden_club', +] as const; + +export type ShadowDemoCommunityId = (typeof SHADOW_DEMO_COMMUNITY_IDS)[number]; + +export const SHADOW_DEMO_VOTER_BLOC_IDS = [ + 'research_practitioner', + 'dataset_steward', + 'current_awareness', + 'community_discussant', + 'interdisciplinary_connector', +] as const; + +export type ShadowDemoVoterBlocId = (typeof SHADOW_DEMO_VOTER_BLOC_IDS)[number]; +export type ShadowDemoSyntheticVoterId = `synthetic-${ShadowDemoVoterBlocId}-${number}`; + +export const SHADOW_DEMO_ENDPOINTS = { + createSession: '/api/demo/sessions', + readSession: '/api/demo/sessions/:sessionId', + castVote: '/api/demo/sessions/:sessionId/votes', + runSyntheticVoters: '/api/demo/sessions/:sessionId/agents/run', + advanceEpoch: '/api/demo/sessions/:sessionId/epochs/advance', + readFeed: '/api/demo/sessions/:sessionId/feed?epochId=&limit=', + readReceipt: '/api/demo/sessions/:sessionId/receipts?epochId=&postUri=', +} as const; + +export const SHADOW_DEMO_AGGREGATION_METHOD = 'trimmed_mean_no_trim_under_10' as const; + +export const SHADOW_DEMO_MAX_EPOCHS_PER_SESSION = 10; + +export const SHADOW_DEMO_GUIDED_EPOCHS = 5; + +export const SHADOW_DEMO_SYNTHETIC_VOTER_COUNT = 24; + +export const SHADOW_DEMO_TOTAL_DEMO_VOTERS = 25; + +export const SHADOW_DEMO_CORPUS_PROVENANCE = { + mode: 'production_sourced_session_frozen', + label: 'Live-scored snapshot', + description: + 'Live-scored snapshot, frozen for this demo run so rank movement is attributable to policy changes.', +} as const; + +export const SHADOW_DEMO_ISOLATION_CONTRACT = { + productionGovernanceMutates: false, + productionFeedMutates: false, + productionAuditLogMutates: false, + researchExportsMutate: false, + stateBackend: 'redis_only_demo_namespace', + redisPrefixes: ['demo:session:', 'demo:corpus:', 'demo:corpus:current:', 'demo:idempotency:', 'demo:lock:'], + liveShadowCommunities: ['open_science_builders'], +} as const; + +export interface ShadowDemoWarning { + code: string; + message: string; + severity: 'info' | 'warning' | 'degraded'; +} + +export interface ShadowDemoEnvelope { + contractVersion: typeof SHADOW_DEMO_CONTRACT_VERSION; + requestId: string; + generatedAt: string; + sessionId: string | null; + payload: TPayload; + warnings: ShadowDemoWarning[]; +} + +export interface ShadowDemoCommunity { + id: ShadowDemoCommunityId; + name: string; + status: 'live_shadow' | 'degraded'; + description: string; + liveFeedReady: boolean; +} + +export interface ShadowDemoCorpusHealth { + status: 'live' | 'degraded'; + source: 'production_scores_appview' | 'fixture_fallback'; + candidatePosts72h: number; + publicScoredPosts: number; + uniqueAuthors72h: number; + bridgePostShare: number; + topAuthorConcentration: number; + sampledAt: string; +} + +export interface ShadowDemoVoteSummary { + aggregateMethod: typeof SHADOW_DEMO_AGGREGATION_METHOD; + voteCount: number; + trimCount: number; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; +} + +export interface ShadowDemoEpoch { + id: string; + sequence: number; + label: string; + status: 'open' | 'advanced'; + createdAt: string; + advancedAt: string | null; + decidedByEpochId: string | null; + aggregate: ShadowDemoVoteSummary; +} + +interface ShadowDemoVoteBase { + id: string; + epochId: string; + label: string; + weights: ShadowDemoWeights; + topicIntent: ShadowDemoTopicIntent; + createdAt: string; +} + +export interface ShadowDemoReviewerVote extends ShadowDemoVoteBase { + actorType: 'reviewer'; + actorId: 'reviewer'; + blocId?: never; +} + +export interface ShadowDemoSyntheticVote extends ShadowDemoVoteBase { + actorType: 'synthetic_voter'; + actorId: ShadowDemoSyntheticVoterId; + blocId: ShadowDemoVoterBlocId; +} + +export type ShadowDemoVote = ShadowDemoReviewerVote | ShadowDemoSyntheticVote; + +export interface ShadowDemoSessionPayload { + session: { + sessionId: string; + community: ShadowDemoCommunity; + phase: 'created' | 'reviewer_voted' | 'synthetic_voters_ran' | 'epoch_advanced'; + currentEpochId: string; + expiresAt: string; + corpusHealth: ShadowDemoCorpusHealth; + epochs: ShadowDemoEpoch[]; + pendingAggregate: ShadowDemoVoteSummary | null; + voteCount: number; + guidedEpochs: typeof SHADOW_DEMO_GUIDED_EPOCHS; + maxEpochs: typeof SHADOW_DEMO_MAX_EPOCHS_PER_SESSION; + syntheticVoterCount: typeof SHADOW_DEMO_SYNTHETIC_VOTER_COUNT; + totalDemoVoters: typeof SHADOW_DEMO_TOTAL_DEMO_VOTERS; + corpusProvenance: typeof SHADOW_DEMO_CORPUS_PROVENANCE; + voterProfiles: Array<{ + id: ShadowDemoVoterBlocId; + label: string; + voterCount: number; + baseWeights: ShadowDemoWeights; + baseTopicWeights: Record; + reviewerBlend: number; + policyInertia: number; + }>; + votes: ShadowDemoVote[]; + }; +} + +export interface ShadowDemoPublicPost { + kind: 'public_post'; + uri: string; + cid: string; + authorDid: string; + authorHandle: string; + authorDisplayName: string; + authorAvatar: string | null; + text: string; + likeCount: number; + repostCount: number; + replyCount: number; + quoteCount: number; + indexedAt: string; + createdAt: string; + bskyUrl: string; +} + +export interface ShadowDemoHiddenPost { + kind: 'hidden_post'; + reason: string; +} + +export type ShadowDemoDisplayPost = ShadowDemoPublicPost | ShadowDemoHiddenPost; + +export interface ShadowDemoRankedPost { + rank: number; + previousRank: number | null; + movement: number | null; + score: number | null; + weightedComponents: Record | null; + rawScores: ShadowDemoRawScores | null; + post: ShadowDemoDisplayPost; +} + +export interface ShadowDemoFeedPayload { + epochId: string; + corpusId: string; + communityId: ShadowDemoCommunityId; + corpusHealth: ShadowDemoCorpusHealth; + aggregate: ShadowDemoVoteSummary; + posts: ShadowDemoRankedPost[]; +} + +export interface ShadowDemoReceiptPayload { + receipt: { + type: 'shadow_demo_receipt'; + epochId: string; + postUri: string; + visibleRank: number; + previousRank: number | null; + score: number; + scoredAt: string; + aggregate: ShadowDemoVoteSummary; + components: Array<{ + signal: ShadowDemoSignalKey; + rawScore: number; + weight: number; + contribution: number; + }>; + topicSignals: Array<{ + topic: string; + postScore: number; + }>; + counterfactuals: Array<{ + label: 'previous_epoch' | 'engagement_only' | 'without_reviewer_vote'; + rank: number; + deltaFromVisible: number; + }>; + }; +} diff --git a/web-next/app/demo/shadow-demo-contract.type-test.ts b/web-next/app/demo/shadow-demo-contract.type-test.ts new file mode 100644 index 00000000..b45b1e19 --- /dev/null +++ b/web-next/app/demo/shadow-demo-contract.type-test.ts @@ -0,0 +1,46 @@ +import type { ShadowDemoVote, ShadowDemoWeights } from './shadow-demo-contract'; + +const weights: ShadowDemoWeights = { + recency: 0.2, + engagement: 0.2, + bridging: 0.2, + source_diversity: 0.2, + relevance: 0.2, +}; + +const reviewerVote: ShadowDemoVote = { + id: 'reviewer-vote', + epochId: 'epoch-1', + actorType: 'reviewer', + actorId: 'reviewer', + label: 'Reviewer', + weights, + topicIntent: { topicWeights: {} }, + createdAt: '2026-07-10T00:00:00.000Z', +}; + +const syntheticVote: ShadowDemoVote = { + id: 'synthetic-vote', + epochId: 'epoch-1', + actorType: 'synthetic_voter', + actorId: 'synthetic-research_practitioner-1', + blocId: 'research_practitioner', + label: 'Research practitioner voter 1', + weights, + topicIntent: { topicWeights: {} }, + createdAt: '2026-07-10T00:00:00.000Z', +}; + +// @ts-expect-error Reviewer votes cannot carry a synthetic voter id. +const invalidReviewerVote: ShadowDemoVote = { + ...reviewerVote, + actorId: 'synthetic-research_practitioner-1', +}; + +// @ts-expect-error Synthetic votes must carry their bloc id. +const invalidSyntheticVote: ShadowDemoVote = { + ...syntheticVote, + blocId: undefined, +}; + +void [reviewerVote, syntheticVote, invalidReviewerVote, invalidSyntheticVote]; From 6d433f83ad44e57f61f21286c1041d9979f8018f Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:52:12 -0700 Subject: [PATCH 2/6] [PROJ-1431] Document shadow demo API --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc83661..efe75381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Public, rate-limited `/api/demo/*` shadow-governance sessions with production-sourced score components, deterministic synthetic voters, isolated Redis state, reranked feeds, and inspectable receipts. - Topic scoring engine: winkNLP-based topic classification at ingestion time - Community topic weight voting (boost/penalize topics via governance) - Bluesky content label filtering (replaces keyword-based NSFW exclusion) From 766210c6fa1a16b17e95812d521b585412062477 Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:24:56 -0700 Subject: [PATCH 3/6] [PROJ-1431] Harden shadow demo review boundaries --- scripts/birders-feed-scout.ts | 22 +++++++++- src/demo/corpus.ts | 53 ++++++++++++++----------- src/demo/store.ts | 19 +-------- src/feed/community-materializer.ts | 3 ++ src/feed/routes/feed-skeleton.ts | 4 +- tests/birders-feed-materializer.test.ts | 39 ++++++++++++++++++ tests/demo-shadow-store.test.ts | 34 ++++++---------- tests/demo-shadow-weights.test.ts | 11 +++++ tests/feed-community-registry.test.ts | 25 ++++++++++++ tests/feed-skeleton-auth.test.ts | 32 +++++++++++++++ 10 files changed, 175 insertions(+), 67 deletions(-) diff --git a/scripts/birders-feed-scout.ts b/scripts/birders-feed-scout.ts index c839902b..b5c5abec 100644 --- a/scripts/birders-feed-scout.ts +++ b/scripts/birders-feed-scout.ts @@ -19,6 +19,7 @@ async function main(): Promise { import('../src/feed/community-materializer.js'), ]); + let operationError: unknown; try { const community = resolveBirdersCommunity(); const now = new Date(); @@ -44,9 +45,26 @@ async function main(): Promise { limit: options.limit, }); console.log(options.json ? JSON.stringify(report, null, 2) : renderBirdersScoutReport(report)); + } catch (err) { + operationError = err; + throw err; } finally { - redis.disconnect(); - await db.end(); + const cleanupErrors: unknown[] = []; + try { + redis.disconnect(); + } catch (err) { + cleanupErrors.push(err); + console.error('Failed to disconnect Redis after Birders scout:', err); + } + try { + await db.end(); + } catch (err) { + cleanupErrors.push(err); + console.error('Failed to close PostgreSQL after Birders scout:', err); + } + if (operationError === undefined && cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Birders scout cleanup failed'); + } } } diff --git a/src/demo/corpus.ts b/src/demo/corpus.ts index cd0d5e90..c66a8478 100644 --- a/src/demo/corpus.ts +++ b/src/demo/corpus.ts @@ -56,6 +56,7 @@ export const DEMO_COMMUNITIES: Record Promise; }): Promise { const items: ShadowDemoCorpusItem[] = []; - for (const row of options.rows) { - const score = await options.readScore({ - postUri: row.uri, - epochId: options.epochId, - }); - if (!score) { - continue; - } - const rawScores = internalRawScoresToShadow(score.components); - if (!hasAllFiniteRawScores(rawScores)) { - continue; - } - items.push({ - postUri: row.uri, - authorDid: row.author_did, - createdAt: dateToIso(row.created_at), - topicVector: row.topic_vector ?? {}, - rawScores, - productionScore: score.totalScore, - productionEpochId: score.epochId, - scoredAt: score.scoredAt.toISOString(), - componentDetails: score.componentDetails, - displayPost: hiddenDisplayPost('Post has not been hydrated from Bluesky public AppView yet'), - }); + for (let start = 0; start < options.rows.length; start += SCORE_READ_BATCH_SIZE) { + const batch = options.rows.slice(start, start + SCORE_READ_BATCH_SIZE); + const scoredBatch = await Promise.all(batch.map(async (row) => { + const score = await options.readScore({ + postUri: row.uri, + epochId: options.epochId, + }); + if (!score) { + return null; + } + const rawScores = internalRawScoresToShadow(score.components); + if (!hasAllFiniteRawScores(rawScores)) { + return null; + } + return { + postUri: row.uri, + authorDid: row.author_did, + createdAt: dateToIso(row.created_at), + topicVector: row.topic_vector ?? {}, + rawScores, + productionScore: score.totalScore, + productionEpochId: score.epochId, + scoredAt: score.scoredAt.toISOString(), + componentDetails: score.componentDetails, + displayPost: hiddenDisplayPost('Post has not been hydrated from Bluesky public AppView yet'), + } satisfies ShadowDemoCorpusItem; + })); + items.push(...scoredBatch.filter((item): item is NonNullable => item !== null)); } return items; } diff --git a/src/demo/store.ts b/src/demo/store.ts index bace270a..a1540b08 100644 --- a/src/demo/store.ts +++ b/src/demo/store.ts @@ -196,20 +196,7 @@ export class RedisDemoStore implements DemoStore { } async writeSession(session: ShadowDemoSessionState, ttlSeconds: number): Promise { - const result = await this.redis - .multi() - .setex(sessionKey(session.sessionId), ttlSeconds, JSON.stringify(session)) - .setex(corpusKey(session.corpus.corpusId), ttlSeconds, JSON.stringify(session.corpus)) - .exec(); - if (result === null) { - throw new Error(`Shadow demo Redis transaction aborted for session ${session.sessionId}`); - } - const failedCommand = result.find(([error]) => error !== null); - if (failedCommand !== undefined) { - throw new Error( - `Shadow demo Redis transaction failed for session ${session.sessionId}: ${String(failedCommand[0])}` - ); - } + await this.redis.setex(sessionKey(session.sessionId), ttlSeconds, JSON.stringify(session)); } async readIdempotency(sessionId: string, key: string): Promise | null> { @@ -443,10 +430,6 @@ function sessionKey(sessionId: string): string { return `${demoSessionKeyPrefix()}${sessionId}`; } -function corpusKey(corpusId: string): string { - return `${demoCorpusKeyPrefix()}${corpusId}`; -} - function sharedCorpusKey(communityId: ShadowDemoCommunityId): string { return `${demoSharedCorpusKeyPrefix()}${communityId}`; } diff --git a/src/feed/community-materializer.ts b/src/feed/community-materializer.ts index 7ca9a1fe..2b95ceaf 100644 --- a/src/feed/community-materializer.ts +++ b/src/feed/community-materializer.ts @@ -497,6 +497,9 @@ function finiteNumber(value: string | number | null): number { } function nullableFiniteInteger(value: string | number | null): number | null { + if (value === null) { + return null; + } const parsed = finiteNumber(value); if (!Number.isInteger(parsed)) { return null; diff --git a/src/feed/routes/feed-skeleton.ts b/src/feed/routes/feed-skeleton.ts index 02b38ca7..04794625 100644 --- a/src/feed/routes/feed-skeleton.ts +++ b/src/feed/routes/feed-skeleton.ts @@ -290,8 +290,8 @@ export function registerFeedSkeleton(app: FastifyInstance, options?: RegisterFee }); } - // Private feed mode: require approved participant - if (config.FEED_PRIVATE_MODE) { + // Private communities and global private mode require an approved participant. + if (!community.public || config.FEED_PRIVATE_MODE) { const viewerDid = await verifyFeedRequesterDid(authHeader); if (!viewerDid) return reply.send({ feed: [] }); const approved = await isParticipantApproved(viewerDid); diff --git a/tests/birders-feed-materializer.test.ts b/tests/birders-feed-materializer.test.ts index 7fd4bec5..9b937c17 100644 --- a/tests/birders-feed-materializer.test.ts +++ b/tests/birders-feed-materializer.test.ts @@ -203,6 +203,45 @@ describe('Birders feed readiness materializer', () => { ); }); + it('reports unavailable when there is no active production epoch', async () => { + const community = birdersCommunity(); + const dbPool = buildDbMock([ + { + uri: null, + author_did: null, + text: null, + active_epoch_id: null, + recency_score: null, + engagement_score: null, + bridging_score: null, + source_diversity_score: null, + relevance_score: null, + community_score: null, + candidate_count: 0, + unique_author_count: 0, + bridge_post_count: 0, + strong_bridge_high_relevance_count: 0, + top_author_post_count: 0, + }, + ]); + + const report = await scoutCommunityFeed({ + community, + dbPool, + now: new Date('2026-07-09T20:00:00.000Z'), + windowHours: 72, + limit: 50, + }); + + expect(report).toMatchObject({ + activeEpochId: null, + status: 'unavailable', + }); + expect(report.warnings).toContain( + 'No active production epoch was available for Birders materialization.' + ); + }); + it('materializes only namespaced Birders Redis keys and leaves production feed keys untouched', async () => { const community = birdersCommunity(); const dbPool = buildDbMock([ diff --git a/tests/demo-shadow-store.test.ts b/tests/demo-shadow-store.test.ts index a503a728..c716374e 100644 --- a/tests/demo-shadow-store.test.ts +++ b/tests/demo-shadow-store.test.ts @@ -4,25 +4,20 @@ import { DemoStoreCorruptionError, RedisDemoStore } from '../src/demo/store.js'; import type { ShadowDemoSessionState } from '../src/demo/types.js'; describe('Redis shadow demo store', () => { - it('writes session and frozen corpus in one Redis transaction', async () => { - const transaction = { - setex: vi.fn(), - exec: vi.fn(async () => [[null, 'OK'], [null, 'OK']]), - }; - transaction.setex.mockReturnValue(transaction); - const redis = { multi: vi.fn(() => transaction) } as unknown as Redis; + it('writes one session record without duplicating the frozen corpus', async () => { + const setex = vi.fn().mockResolvedValue('OK'); + const redis = { setex } as unknown as Redis; const store = new RedisDemoStore(redis); const session = storedSession(); await store.writeSession(session, 120); - expect(redis.multi).toHaveBeenCalledTimes(1); - expect(transaction.setex).toHaveBeenCalledTimes(2); - expect(transaction.setex.mock.calls.map((call) => call[0])).toEqual([ + expect(setex).toHaveBeenCalledTimes(1); + expect(setex).toHaveBeenCalledWith( 'demo:session:demo-store-session', - 'demo:corpus:demo-store-corpus', - ]); - expect(transaction.exec).toHaveBeenCalledTimes(1); + 120, + JSON.stringify(session) + ); }); it('returns null only for missing records and raises explicit corruption errors', async () => { @@ -37,18 +32,15 @@ describe('Redis shadow demo store', () => { await expect(store.readSession('partial')).rejects.toBeInstanceOf(DemoStoreCorruptionError); }); - it('raises when a Redis transaction command fails', async () => { - const transaction = { - setex: vi.fn(), - exec: vi.fn(async () => [[null, 'OK'], [new Error('corpus write failed'), null]]), - }; - transaction.setex.mockReturnValue(transaction); - const redis = { multi: vi.fn(() => transaction) } as unknown as Redis; + it('surfaces a failed session write without leaving a second corpus key', async () => { + const setex = vi.fn().mockRejectedValue(new Error('session write failed')); + const redis = { setex } as unknown as Redis; const store = new RedisDemoStore(redis); await expect(store.writeSession(storedSession(), 120)).rejects.toThrow( - /Redis transaction failed.*corpus write failed/ + 'session write failed' ); + expect(setex).toHaveBeenCalledTimes(1); }); it('rejects malformed nested epochs, votes, and corpus items', async () => { diff --git a/tests/demo-shadow-weights.test.ts b/tests/demo-shadow-weights.test.ts index 8e724a5c..506ad450 100644 --- a/tests/demo-shadow-weights.test.ts +++ b/tests/demo-shadow-weights.test.ts @@ -62,6 +62,17 @@ describe('shadow demo weight math', () => { relevance: 0, }) ).toThrow(/finite/); + expect(() => validateShadowWeights({ + recency: 0, + engagement: 0, + bridging: 0, + source_diversity: 0, + relevance: 0, + })).toThrow(/sum to 1.0/); + }); + + it('rejects an empty electorate', () => { + expect(() => aggregateShadowVotes([])).toThrow('Cannot aggregate zero shadow demo votes'); }); it('does not trim small electorates below ten votes', () => { diff --git a/tests/feed-community-registry.test.ts b/tests/feed-community-registry.test.ts index c2a8085c..c3bc95d4 100644 --- a/tests/feed-community-registry.test.ts +++ b/tests/feed-community-registry.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { config } from '../src/config.js'; import { feedUriForCommunity, + feedUriForRkey, getFeedCommunities, + isFeedCommunityServable, publicFeedUris, resolveFeedCommunityByRkey, resolveFeedCommunityByUri, @@ -48,6 +50,29 @@ describe('feed community registry', () => { expect(resolveFeedCommunityByUri(uri, config.FEEDGEN_PUBLISHER_DID, [enabledBirders])).toEqual(enabledBirders); }); + + it('returns null for unknown or empty registry lookups', () => { + expect(resolveFeedCommunityByRkey('unknown', getFeedCommunities())).toBeNull(); + expect(resolveFeedCommunityByUri( + feedUriForRkey('unknown', config.FEEDGEN_PUBLISHER_DID), + config.FEEDGEN_PUBLISHER_DID, + getFeedCommunities() + )).toBeNull(); + expect(resolveFeedCommunityByRkey('community-gov', [])).toBeNull(); + expect(publicFeedUris([], config.FEEDGEN_PUBLISHER_DID)).toEqual([]); + }); + + it('serves only enabled registry entries', () => { + const communities = getFeedCommunities(); + const communityGov = resolveFeedCommunityByRkey('community-gov', communities); + const birders = resolveFeedCommunityByRkey('birders-who-code', communities); + if (!communityGov || !birders) { + throw new Error('Feed community fixtures missing from registry'); + } + + expect(isFeedCommunityServable(communityGov)).toBe(true); + expect(isFeedCommunityServable(birders)).toBe(false); + }); }); describe('describeFeedGenerator community visibility', () => { diff --git a/tests/feed-skeleton-auth.test.ts b/tests/feed-skeleton-auth.test.ts index 761f6f4f..7a6c411c 100644 --- a/tests/feed-skeleton-auth.test.ts +++ b/tests/feed-skeleton-auth.test.ts @@ -46,6 +46,12 @@ vi.mock('../src/feed/access-control.js', () => ({ })); import { config } from '../src/config.js'; +import { + feedUriForCommunity, + getFeedCommunities, + resolveFeedCommunityByRkey, + type FeedCommunity, +} from '../src/feed/community-registry.js'; import { __resetFeedRequestTrackerForTests, drainFeedRequestTracker } from '../src/feed/request-tracker.js'; import { registerFeedSkeleton } from '../src/feed/routes/feed-skeleton.js'; import { clearCurrentFeedSnapshotMemoryCache } from '../src/feed/snapshot-cache.js'; @@ -148,4 +154,30 @@ describe('getFeedSkeleton auth handling', () => { await app.close(); }); + + it('does not serve an enabled private community to an anonymous caller', async () => { + const birders = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (!birders) { + throw new Error('Birders community fixture missing from registry'); + } + const privateBirders: FeedCommunity = { + ...birders, + status: 'enabled', + public: false, + }; + const app = buildTestApp(); + registerFeedSkeleton(app, { communities: [privateBirders] }); + + const response = await app.inject({ + method: 'GET', + url: `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUriForCommunity(privateBirders, config.FEEDGEN_PUBLISHER_DID))}&limit=2`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ feed: [] }); + expect(verifyFeedRequesterDidMock).toHaveBeenCalledWith(undefined); + expect(redisMock.zrevrange).not.toHaveBeenCalled(); + + await app.close(); + }); }); From 3df067ce38394ab0e7db6e28dc0bfec14774bbc8 Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:38:45 -0700 Subject: [PATCH 4/6] [PROJ-1431] Share frozen corpora across demo sessions --- src/demo/store.ts | 39 +++++++++++++++++-- tests/demo-shadow-store.test.ts | 63 +++++++++++++++++++++++++++++-- tests/demo-shadow-weights.test.ts | 11 ++++++ tests/feed-skeleton-auth.test.ts | 25 ++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/src/demo/store.ts b/src/demo/store.ts index a1540b08..761609cd 100644 --- a/src/demo/store.ts +++ b/src/demo/store.ts @@ -129,7 +129,7 @@ const StoredVoteSchema = z.discriminatedUnion('actorType', [ }), ]); -const StoredSessionSchema = z.object({ +const StoredSessionHeaderSchema = z.object({ sessionId: z.string().min(1), communityId: z.enum(SHADOW_DEMO_COMMUNITY_IDS), seed: z.string().min(1), @@ -140,7 +140,6 @@ const StoredSessionSchema = z.object({ currentEpochId: z.string().min(1), epochs: z.array(StoredEpochSchema), votes: z.array(StoredVoteSchema), - corpus: StoredCorpusSchema, warnings: z.array(StoredWarningSchema), }); @@ -192,11 +191,39 @@ export class RedisDemoStore implements DemoStore { async readSession(sessionId: string): Promise { const key = sessionKey(sessionId); const raw = await this.redis.get(key); - return raw ? parseStoredRecord(StoredSessionSchema, raw, key) : null; + if (!raw) { + return null; + } + const header = parseStoredRecord>( + StoredSessionHeaderSchema, + raw, + key + ); + const storedCorpusKey = corpusKey(header.corpusId); + const rawCorpus = await this.redis.get(storedCorpusKey); + if (!rawCorpus) { + throw new DemoStoreCorruptionError( + key, + `referenced corpus ${header.corpusId} is missing at ${storedCorpusKey}` + ); + } + const corpus = parseStoredRecord(StoredCorpusSchema, rawCorpus, storedCorpusKey); + return { ...header, corpus }; } async writeSession(session: ShadowDemoSessionState, ttlSeconds: number): Promise { - await this.redis.setex(sessionKey(session.sessionId), ttlSeconds, JSON.stringify(session)); + const storedCorpusKey = corpusKey(session.corpusId); + const corpusExists = await this.redis.exists(storedCorpusKey); + if (corpusExists === 0) { + await this.redis.setex(storedCorpusKey, ttlSeconds, JSON.stringify(session.corpus)); + } else { + const refreshed = await this.redis.expire(storedCorpusKey, ttlSeconds); + if (refreshed === 0) { + await this.redis.setex(storedCorpusKey, ttlSeconds, JSON.stringify(session.corpus)); + } + } + const { corpus: _corpus, ...sessionHeader } = session; + await this.redis.setex(sessionKey(session.sessionId), ttlSeconds, JSON.stringify(sessionHeader)); } async readIdempotency(sessionId: string, key: string): Promise | null> { @@ -430,6 +457,10 @@ function sessionKey(sessionId: string): string { return `${demoSessionKeyPrefix()}${sessionId}`; } +function corpusKey(corpusId: string): string { + return `${demoCorpusKeyPrefix()}${corpusId}`; +} + function sharedCorpusKey(communityId: ShadowDemoCommunityId): string { return `${demoSharedCorpusKeyPrefix()}${communityId}`; } diff --git a/tests/demo-shadow-store.test.ts b/tests/demo-shadow-store.test.ts index c716374e..e2f4879a 100644 --- a/tests/demo-shadow-store.test.ts +++ b/tests/demo-shadow-store.test.ts @@ -4,19 +4,46 @@ import { DemoStoreCorruptionError, RedisDemoStore } from '../src/demo/store.js'; import type { ShadowDemoSessionState } from '../src/demo/types.js'; describe('Redis shadow demo store', () => { - it('writes one session record without duplicating the frozen corpus', async () => { + it('writes the frozen corpus once and stores only a corpus reference in the session', async () => { + const exists = vi.fn().mockResolvedValue(0); const setex = vi.fn().mockResolvedValue('OK'); - const redis = { setex } as unknown as Redis; + const redis = { exists, setex } as unknown as Redis; const store = new RedisDemoStore(redis); const session = storedSession(); await store.writeSession(session, 120); + expect(exists).toHaveBeenCalledWith('demo:corpus:demo-store-corpus'); + expect(setex).toHaveBeenNthCalledWith( + 1, + 'demo:corpus:demo-store-corpus', + 120, + JSON.stringify(session.corpus) + ); + const { corpus: _corpus, ...sessionHeader } = session; + expect(setex).toHaveBeenNthCalledWith( + 2, + 'demo:session:demo-store-session', + 120, + JSON.stringify(sessionHeader) + ); + }); + + it('reuses an existing corpus blob without rewriting its bytes', async () => { + const exists = vi.fn().mockResolvedValue(1); + const expire = vi.fn().mockResolvedValue(1); + const setex = vi.fn().mockResolvedValue('OK'); + const redis = { exists, expire, setex } as unknown as Redis; + const store = new RedisDemoStore(redis); + + await store.writeSession(storedSession(), 120); + + expect(expire).toHaveBeenCalledWith('demo:corpus:demo-store-corpus', 120); expect(setex).toHaveBeenCalledTimes(1); expect(setex).toHaveBeenCalledWith( 'demo:session:demo-store-session', 120, - JSON.stringify(session) + expect.not.stringContaining('"items"') ); }); @@ -33,8 +60,10 @@ describe('Redis shadow demo store', () => { }); it('surfaces a failed session write without leaving a second corpus key', async () => { + const exists = vi.fn().mockResolvedValue(1); + const expire = vi.fn().mockResolvedValue(1); const setex = vi.fn().mockRejectedValue(new Error('session write failed')); - const redis = { setex } as unknown as Redis; + const redis = { exists, expire, setex } as unknown as Redis; const store = new RedisDemoStore(redis); await expect(store.writeSession(storedSession(), 120)).rejects.toThrow( @@ -43,6 +72,32 @@ describe('Redis shadow demo store', () => { expect(setex).toHaveBeenCalledTimes(1); }); + it('hydrates a stored session header from its frozen corpus record', async () => { + const session = storedSession(); + const { corpus, ...sessionHeader } = session; + const get = vi.fn() + .mockResolvedValueOnce(JSON.stringify(sessionHeader)) + .mockResolvedValueOnce(JSON.stringify(corpus)); + const store = new RedisDemoStore({ get } as unknown as Redis); + + await expect(store.readSession(session.sessionId)).resolves.toEqual(session); + expect(get).toHaveBeenNthCalledWith(1, 'demo:session:demo-store-session'); + expect(get).toHaveBeenNthCalledWith(2, 'demo:corpus:demo-store-corpus'); + }); + + it('raises explicit corruption when a session references a missing corpus', async () => { + const session = storedSession(); + const { corpus: _corpus, ...sessionHeader } = session; + const get = vi.fn() + .mockResolvedValueOnce(JSON.stringify(sessionHeader)) + .mockResolvedValueOnce(null); + const store = new RedisDemoStore({ get } as unknown as Redis); + + await expect(store.readSession(session.sessionId)).rejects.toThrow( + /referenced corpus demo-store-corpus is missing/ + ); + }); + it('rejects malformed nested epochs, votes, and corpus items', async () => { const session = storedSession(); const get = vi.fn() diff --git a/tests/demo-shadow-weights.test.ts b/tests/demo-shadow-weights.test.ts index 506ad450..ea19af42 100644 --- a/tests/demo-shadow-weights.test.ts +++ b/tests/demo-shadow-weights.test.ts @@ -75,6 +75,17 @@ describe('shadow demo weight math', () => { expect(() => aggregateShadowVotes([])).toThrow('Cannot aggregate zero shadow demo votes'); }); + it('uses the stored relevance score when topic intent is empty', () => { + const scored = scoreFromRawWeights( + { recency: 0.5, engagement: 0.8, bridging: 0.25, source_diversity: 1, relevance: 0.4 }, + { recency: 0.2, engagement: 0.3, bridging: 0.1, source_diversity: 0.1, relevance: 0.3 }, + { 'science-research': 0.8 }, + { topicWeights: {} } + ); + + expect(scored.effectiveRawScores.relevance).toBe(0.4); + }); + it('does not trim small electorates below ten votes', () => { const summary = aggregateShadowVotes([RECENCY_ONLY, ENGAGEMENT_ONLY]); diff --git a/tests/feed-skeleton-auth.test.ts b/tests/feed-skeleton-auth.test.ts index 7a6c411c..71505c9b 100644 --- a/tests/feed-skeleton-auth.test.ts +++ b/tests/feed-skeleton-auth.test.ts @@ -180,4 +180,29 @@ describe('getFeedSkeleton auth handling', () => { await app.close(); }); + + it('does not serve an enabled private community to an unapproved caller', async () => { + const birders = resolveFeedCommunityByRkey('birders-who-code', getFeedCommunities()); + if (!birders) { + throw new Error('Birders community fixture missing from registry'); + } + const privateBirders: FeedCommunity = { ...birders, status: 'enabled', public: false }; + verifyFeedRequesterDidMock.mockResolvedValue('did:plc:unapproved'); + isParticipantApprovedMock.mockResolvedValue(false); + const app = buildTestApp(); + registerFeedSkeleton(app, { communities: [privateBirders] }); + + const response = await app.inject({ + method: 'GET', + url: `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUriForCommunity(privateBirders, config.FEEDGEN_PUBLISHER_DID))}&limit=2`, + headers: { authorization: 'Bearer valid.jwt' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ feed: [] }); + expect(isParticipantApprovedMock).toHaveBeenCalledWith('did:plc:unapproved'); + expect(redisMock.zrevrange).not.toHaveBeenCalled(); + + await app.close(); + }); }); From b56d047cbb5a16a0466be83d18b2b1426e8bf102 Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:42:27 -0700 Subject: [PATCH 5/6] [PROJ-1431] Namespace community fallback keys --- src/feed/community-registry.ts | 6 ++++++ src/feed/snapshot-cache.ts | 15 ++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/feed/community-registry.ts b/src/feed/community-registry.ts index b2fa14b6..1e38d532 100644 --- a/src/feed/community-registry.ts +++ b/src/feed/community-registry.ts @@ -44,6 +44,8 @@ export interface FeedCommunityTerms { export interface FeedCommunityRedisKeys { current: string; + lastKnownGood: string | null; + lastKnownGoodFallbackTotal: string | null; epoch: string; health: string; currentSnapshot: string; @@ -66,6 +68,8 @@ export interface FeedCommunity { export const COMMUNITY_GOV_REDIS_KEYS: FeedCommunityRedisKeys = { current: 'feed:current', + lastKnownGood: 'feed:last_known_good', + lastKnownGoodFallbackTotal: 'feed:last_known_good_fallback_total', epoch: 'feed:epoch', health: 'feed:health', currentSnapshot: 'feed:current_snapshot_id', @@ -77,6 +81,8 @@ export const BIRDERS_REDIS_PREFIX = 'feed:community:birders_who_code'; export const BIRDERS_REDIS_KEYS: FeedCommunityRedisKeys = { current: `${BIRDERS_REDIS_PREFIX}:current`, + lastKnownGood: null, + lastKnownGoodFallbackTotal: null, epoch: `${BIRDERS_REDIS_PREFIX}:epoch`, health: `${BIRDERS_REDIS_PREFIX}:health`, currentSnapshot: `${BIRDERS_REDIS_PREFIX}:current_snapshot_id`, diff --git a/src/feed/snapshot-cache.ts b/src/feed/snapshot-cache.ts index 0a996a5c..2398a7f7 100644 --- a/src/feed/snapshot-cache.ts +++ b/src/feed/snapshot-cache.ts @@ -5,8 +5,6 @@ import { logger } from '../lib/logger.js'; import { COMMUNITY_GOV_REDIS_KEYS, type FeedCommunity } from './community-registry.js'; const SNAPSHOT_TTL_SECONDS = 300; -const FEED_LAST_KNOWN_GOOD_KEY = 'feed:last_known_good'; -const FEED_LAST_KNOWN_GOOD_FALLBACK_TOTAL_KEY = 'feed:last_known_good_fallback_total'; export const CURRENT_FEED_SNAPSHOT_KEY = COMMUNITY_GOV_REDIS_KEYS.currentSnapshot; const CURRENT_FEED_GENERATION_KEY = COMMUNITY_GOV_REDIS_KEYS.snapshotGeneration; const SNAPSHOT_KEY_PREFIX = COMMUNITY_GOV_REDIS_KEYS.snapshotPrefix; @@ -52,8 +50,8 @@ interface FeedSnapshotSpec { const CURRENT_FEED_SNAPSHOT_SPEC: FeedSnapshotSpec = { sortedSetKey: COMMUNITY_GOV_REDIS_KEYS.current, - fallbackSortedSetKey: FEED_LAST_KNOWN_GOOD_KEY, - fallbackMetricKey: FEED_LAST_KNOWN_GOOD_FALLBACK_TOTAL_KEY, + fallbackSortedSetKey: COMMUNITY_GOV_REDIS_KEYS.lastKnownGood, + fallbackMetricKey: COMMUNITY_GOV_REDIS_KEYS.lastKnownGoodFallbackTotal, currentSnapshotKey: CURRENT_FEED_SNAPSHOT_KEY, generationKey: CURRENT_FEED_GENERATION_KEY, snapshotKeyPrefix: SNAPSHOT_KEY_PREFIX, @@ -343,11 +341,10 @@ async function getFeedSnapshotByIdForSpec( } function feedSnapshotSpecForCommunity(community: FeedCommunity): FeedSnapshotSpec { - const isProductionCommunity = community.communityId === 'community-gov'; return { sortedSetKey: community.redis.current, - fallbackSortedSetKey: isProductionCommunity ? FEED_LAST_KNOWN_GOOD_KEY : null, - fallbackMetricKey: isProductionCommunity ? FEED_LAST_KNOWN_GOOD_FALLBACK_TOTAL_KEY : null, + fallbackSortedSetKey: community.redis.lastKnownGood, + fallbackMetricKey: community.redis.lastKnownGoodFallbackTotal, currentSnapshotKey: community.redis.currentSnapshot, generationKey: community.redis.snapshotGeneration, snapshotKeyPrefix: community.redis.snapshotPrefix, @@ -408,8 +405,8 @@ export async function invalidateCommunityFeedSnapshot(community: FeedCommunity): export const __snapshotCacheKeysForTests = { currentFeedKey: COMMUNITY_GOV_REDIS_KEYS.current, - lastKnownGoodFeedKey: FEED_LAST_KNOWN_GOOD_KEY, - lastKnownGoodFallbackTotalKey: FEED_LAST_KNOWN_GOOD_FALLBACK_TOTAL_KEY, + lastKnownGoodFeedKey: COMMUNITY_GOV_REDIS_KEYS.lastKnownGood, + lastKnownGoodFallbackTotalKey: COMMUNITY_GOV_REDIS_KEYS.lastKnownGoodFallbackTotal, currentSnapshotKey: CURRENT_FEED_SNAPSHOT_KEY, currentGenerationKey: CURRENT_FEED_GENERATION_KEY, snapshotKeyPrefix: SNAPSHOT_KEY_PREFIX, From 8f696cc9a3ce24eee6a33c06df35764ee0d1cd0e Mon Sep 17 00:00:00 2001 From: Andrew Nordstrom <89470090+AndrewNordstrom@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:02:52 -0700 Subject: [PATCH 6/6] [PROJ-1431] Cover shadow demo review boundaries --- tests/demo-shadow-weights.test.ts | 17 +++++++++++++++++ tests/feed-skeleton-auth.test.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/tests/demo-shadow-weights.test.ts b/tests/demo-shadow-weights.test.ts index ea19af42..51e8fa6c 100644 --- a/tests/demo-shadow-weights.test.ts +++ b/tests/demo-shadow-weights.test.ts @@ -62,6 +62,15 @@ describe('shadow demo weight math', () => { relevance: 0, }) ).toThrow(/finite/); + expect(() => + validateShadowWeights({ + recency: -0.5, + engagement: 1.5, + bridging: 0, + source_diversity: 0, + relevance: 0, + }) + ).toThrow(/non-negative/); expect(() => validateShadowWeights({ recency: 0, engagement: 0, @@ -96,6 +105,14 @@ describe('shadow demo weight math', () => { expect(summary.weights.engagement).toBe(0.5); }); + it('does not trim exactly nine votes', () => { + const summary = aggregateShadowVotes(Array(9).fill(RECENCY_ONLY)); + + expect(summary.aggregateMethod).toBe('trimmed_mean_no_trim_under_10'); + expect(summary.voteCount).toBe(9); + expect(summary.trimCount).toBe(0); + }); + it('trims extremes once ten or more votes exist', () => { const middle: ShadowDemoWeights = { recency: 0.5, diff --git a/tests/feed-skeleton-auth.test.ts b/tests/feed-skeleton-auth.test.ts index 71505c9b..0474ff54 100644 --- a/tests/feed-skeleton-auth.test.ts +++ b/tests/feed-skeleton-auth.test.ts @@ -176,6 +176,7 @@ describe('getFeedSkeleton auth handling', () => { expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ feed: [] }); expect(verifyFeedRequesterDidMock).toHaveBeenCalledWith(undefined); + expect(isParticipantApprovedMock).not.toHaveBeenCalled(); expect(redisMock.zrevrange).not.toHaveBeenCalled(); await app.close();