# Search, Discovery, Recommendations, and Knowledge Graph

Search and discovery are the cross-domain "find anything, then decide what to do
next" surfaces of V1. They serve every member who opens the universal search
sheet or scrolls a mixed-domain recommendation rail, and they sit one layer
above the six customer domains — [Tara](./domain-tara.md),
[Veritas](./domain-veritas.md), [Nyx](./domain-nyx.md),
[Arete](./domain-arete.md), [Nisaba](./domain-nisaba.md), and
[Metis](./domain-metis.md) — fanning a query or a request for recommendations
out to each domain and blending the results back into one ranked list inside the
BFF (`apps/oshun/bff`). This page is deliberately candid about a split the rest
of the V1 docs glossed over: **there are two search-and-discovery stacks in this
repository, and only the simpler one is on the live path.** The live
`/v1/search` and `/v1/recommendations` routes do deterministic, lexical,
domain-fan-out ranking over data the system actually holds; a much richer
signal/candidate/ranker/experiment/concept-graph stack lives in
`libs/oshun/search-discovery`, is fully tested, and is **explicitly retired from
V1 scope**. Read everything below as a description of what runs today, with the
aspirational library called out honestly wherever it appears.

## The two stacks, and why only one ships

The original feature spec (this section's source) described a single, unified
discovery platform: a searchable object catalog with per-class embedding
indices, a rich signal taxonomy with decay, six families of candidate generators
(collaborative filtering, content similarity, concept-graph traversal,
editorial, recency, cross-domain bridges), a feature-rich ranker that scores
persona fit / evidence integrity / grounding state / entitlement class and emits
a free-text reason taxonomy ("because you saved X… fresh in your concept
graph"), an online A/B experimentation framework with ramp and canary,
cold-start onboarding, a Neo4j-backed concept graph, and an offline + online
evaluation suite. Almost all of that was _built_ — it exists in
`libs/oshun/search-discovery/src/` with tests — but it was **not adopted** for
V1.

The library's own entry file says so in plain terms. The banner at the top of
`libs/oshun/search-discovery/src/index.ts` (lines 1–15) reads:

```ts
/**
 * @oshun/search-discovery — RETIRED FROM V1 SCOPE (audit E5, 2026-06-11).
 *
 * Decision: the live `/v1/search` + `/v1/recommendations` paths do NOT adopt
 * this library for V1. The ranker scores DiscoveryObject features the live
 * candidates do not carry (persona-tone fit, evidence integrity, grounding
 * state, per-object entitlement/region) — adopting it would have required
 * inventing those signals, which the repo's honesty rules forbid. The live
 * paths keep their own real ranking over the data they actually have.
 * ...
 */
```

The reason is the same honesty rule that governs the rest of this codebase: the
retired ranker scores `DiscoveryObject` features (persona/tone fit, Veritas
evidence integrity, Sophia grounding state, per-object entitlement and region)
that the _live_ candidates simply do not carry. Wiring it in would have meant
fabricating those signals at the call site. Rather than fake the inputs, V1
keeps a smaller ranker that operates honestly on the data the BFF actually has.
The library remains intact and tested as the **V1.x target**: adoption becomes
real when search candidates start carrying genuine `DiscoveryObject` metadata
(real signal collection plus catalog enrichment), at which point the retirement
notice is removed and the modules wire into the live paths.

Exactly **one** piece of the retired library is adopted today: the offline
evaluation release gate, mounted as `POST /v1/search/offline-eval` (covered
below). Everything else in the library is dormant — `apps/oshun/bff/src` imports
`@oshun/search-discovery` from a single file, `search/offline-eval-route.ts`,
and nowhere else.

> **Naming note.** Do not confuse `libs/oshun/search-discovery` (the retired
> discovery library) with the web app's _Studio_ "search-discovery" workspace
> (`apps/oshun/web/src/app/studio/search-discovery/page.tsx`). The latter is a
> creator-tooling design surface that happens to share the phrase; it does not
> import or activate the retired library's experiments/ranker modules.

### Library map: what exists vs. what is live

| `libs/oshun/search-discovery` submodule      | Spec promise                                                                    | V1 status                                                   |
| -------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `catalog/` (object-classes, index-engine)    | Per-class lexical + embedding + facet + freshness indices                       | Retired — not on live path                                  |
| `signals/` (taxonomy, capture)               | Implicit/explicit/memory-derived/negative signals                               | Retired                                                     |
| `aggregation/` (decay)                       | Per-signal decay, per-content freshness windows                                 | Retired                                                     |
| `candidates/` (generators)                   | CF, content-similarity, concept-graph, editorial, recency, cross-domain bridges | Retired                                                     |
| `ranker/` (features, reasons, cadence)       | Feature-rich ranker + free-text reason taxonomy + coherence constraints         | Retired                                                     |
| `experiments/` (ab-framework, canary)        | Online A/B framework, ramp, canary, kill-switch                                 | Retired — **not imported by any app**                       |
| `cold-start/` (onboarding)                   | Onboarding declarations, demographic-light defaults, re-cold-start              | Retired                                                     |
| `concept-graph/` (schema, queries, curation) | Neo4j-backed node/edge graph with provenance                                    | Retired                                                     |
| `evals/` (offline-evals, specialty-evals)    | NDCG/MAP/recall + drift detection + release gate                                | **Offline gate is LIVE** via `POST /v1/search/offline-eval` |

The remainder of this page documents the **live** search and recommendation
surfaces in detail — their real routes, weights, candidate pools, reason enum,
feedback endpoint, resilience envelopes, and the one adopted eval gate — and
then describes the retired library's design as the V1.x roadmap, clearly
labeled.

## Live universal search

### Routes, auth, and request shape

Universal search is served by two route registrations that share one handler,
registered in `apps/oshun/bff/src/routes/search.ts` (lines 230–243):

- `GET /search`
- `GET /v1/search`

Both run behind two pre-handlers — `createAbuseProtectionPreHandler()` (rate /
abuse protection) and `createAuthPreHandler()` (authentication). The handler
requires a populated `request.authContext`; a missing context returns `401`
(`missing_auth_context`). It then resolves the member's authorized domains from
their scopes via `resolveAuthorizedShellDomains(authContext.scopes)`; a member
with no `domain:*` scope at all gets `403` (`domain_scope_missing`).

Query parameters:

| Param          | Meaning                     | Default / bounds                                                       |
| -------------- | --------------------------- | ---------------------------------------------------------------------- |
| `q` or `query` | The search string (trimmed) | empty string ⇒ everything ranks on boosts only                         |
| `domain`       | Domain filter               | `all`; must pass `isSearchDomainFilter` or `400 invalid_domain_filter` |
| `limit`        | Page size                   | default **20**, max **100** (`parsePagination`)                        |
| `cursor`       | Pagination cursor           | —                                                                      |

The accepted `domain` filter values are validated against the canonical six
domains via `isSearchDomainFilter` (`routes/search-domain-filter.ts`); an
unrecognized filter is rejected with `SEARCH_DOMAIN_FILTER_ERROR_MESSAGE`. When
a non-empty query arrives, it is recorded in the per-user recent-search store
(`recentSearchStore.addQuery(authContext.userId, query)`) so the explore surface
can offer recent queries back to the member.

Responses are cached per authenticated key for **20 seconds**
(`SEARCH_ROUTE_CACHE = createRouteResponseCache<SearchRoutePayload>(20_000)`);
the cache key blends the user id, the lowercased query, the domain filter, and
the serialized pagination value (`buildAuthenticatedCacheKey`).

### Three candidate pools, merged before ranking

A point the original spec never made: live search does not query a single
universal index. It assembles **three** candidate pools and ranks them together
(`search.ts` lines 155–179):

1. **Domain feed highlights and continue items.** For each authorized domain,
   the handler fans out in parallel and fetches both `fetchDomainContinue(...)`
   (the member's resumable items) and `fetchDomainHighlights(...)` (the domain's
   curated highlights). These are flattened, then filtered to the requested
   `domain` filter. Each becomes a ranking candidate whose `summary` is the feed
   item's `meta` string.
2. **Curated universal-search seeds.**
   `selectUniversalSearchSeeds({ authorizedDomains, domainFilter })` returns a
   static, hand-authored catalog of cross-domain anchor objects
   (`routes/universal-search-seeds.ts`) so that even a brand-new member finds
   meaningful results. The `UNIVERSAL_SEARCH_SEEDS` array spans the object
   classes the spec promises — `ritual`, `practice`, `program`, `concept`,
   `passage`, `claim`, `source`, `notebook`, `collection`, `sky-event`,
   `course`, `lesson`, `learning-artifact` — each with a real `targetPath` (e.g.
   `/library/passage/nisaba-passage-speech`, `/courses/crs-002`,
   `/events/nyx-quadrantids-2026`) and rich card metadata. Seeds are filtered so
   that only authorized domains and the active `domain` filter survive.
3. **The member's own real objects.**
   `collectUserObjectCandidates(userId, authorizedDomainSet)`
   (`search/user-object-candidates.ts`, added under audit C7) pulls the member's
   genuine, owner-scoped objects into the ranking pool so that things the member
   actually owns are findable:
   - **Saved library items** from `savedLibraryItemsStore` (durable,
     LWW-synced), each tagged with its own domain ⇒ `kind: 'saved-item'`.
   - **Library collections** from `domainStubsStore.collections.list(userId)`
     (owner-scoped, surfaced under the Nisaba scholarship IA) ⇒
     `kind: 'collection'`.
   - **Nisaba notebooks** from `nisabaConsumerStateStore.listNotebooks(userId)`
     ⇒ `kind: 'notebook'`.
   - **Arete habits** from `domainStubsStore.habits.listForOwner(userId)` ⇒
     `kind: 'practice'`, carrying `streakDays`.

   This pool is _honestly empty_: a member with nothing saved contributes no
   candidates, and because every store is owner-scoped, another member's habit
   text or notebook can never rank in this member's search. See
   [Customer Curation, Notebooks, Collections, and Sharing](./customer-curation-notebooks.md)
   for those underlying objects.

The merged list is handed to `rankSearchCandidates(...)`, paginated with
`paginateCollection`, and wrapped in a partial-failure envelope (below).

> **Reality vs. spec — the catalog.** The source spec's "Searchable Object
> Catalog" (per-class lexical index + per-class embedding index + per-class
> facet index + freshness pipeline) describes the `catalog/index-engine` in the
> retired library. The live search has **no per-class embedding index** — it is
> lexical ranking over the three aggregated pools above. The catalog spec is the
> V1.x target, not a description of what ships.

### The live lexical ranker

The live ranker is `rankSearchCandidates` in
`apps/oshun/bff/src/search/ranking.ts`. It is fully deterministic — no
randomness, no model call — which makes results stable across requests and easy
to test. A candidate's score is the sum of three components, never below zero:

```
score = lexical(title, summary, query)
      + kindBoost(candidate.kind)
      + domainIntent(candidate.domain, queryIntent)
```

**Lexical match weights** (`DEFAULT_WEIGHTS`, lines 38–65):

| Match          | Weight | When it fires                                            |
| -------------- | ------ | -------------------------------------------------------- |
| `titleExact`   | **40** | The whole normalized query is a substring of the title   |
| `summaryExact` | **24** | The whole normalized query is a substring of the summary |
| `titleToken`   | **8**  | Per query token found in the title                       |
| `summaryToken` | **3**  | Per query token found in the summary                     |

An empty query short-circuits `scoreLexical` to a flat `1`, so an empty search
becomes a pure "browse by kind and intent" ranking rather than returning
nothing.

**Per-kind boosts** (`kindBoosts`) reward the kinds members most often act on.
Resumable `continue` items lead, learning content ranks high, and a fallback of
`highlight` (7) applies to any unrecognized kind:

| Kind                | Boost |     | Kind             | Boost                 |
| ------------------- | ----- | --- | ---------------- | --------------------- |
| `continue`          | 14    |     | `passage`        | 9                     |
| `course`            | 11    |     | `claim`          | 9                     |
| `lesson`            | 11    |     | `source`         | 8                     |
| `assessment`        | 10    |     | `notebook`       | 8                     |
| `tutoring`          | 9     |     | `collection`     | 8                     |
| `learning-artifact` | 9     |     | `concept`        | 8                     |
| `practice`          | 9     |     | `concept-thread` | 8                     |
| `ritual`            | 9     |     | `sky-event`      | 8                     |
| `program`           | 9     |     | `highlight`      | 7 (also the fallback) |

**Domain-intent routing** is a concrete relevance feature the spec omits.
`resolveDomainIntent(tokens)` (lines 165–217) inspects the query tokens and, if
it sees a domain name (`tara`, `veritas`, `nyx`, `arete`, `nisaba`, `metis`) or
a domain-flavored keyword, it picks a single intended domain:

| Query token(s)                                                                                                | Inferred domain |
| ------------------------------------------------------------------------------------------------------------- | --------------- |
| `course`, `courses`, `learn`, `learning`, `lesson`, `lessons`, `tutor`, `assessment`, `artifact`, `artifacts` | `metis`         |
| `ritual`, `rituals`, `practice`, `practices`, `breath`                                                        | `tara`          |
| `passage`, `passages`, `notebook`                                                                             | `nisaba`        |
| `claim`, `claims`, `source`, `sources`                                                                        | `veritas`       |
| `sky`, `event`, `events`, `meteor`                                                                            | `nyx`           |

When an intent is resolved, candidates in that domain get `domainIntentBoost`
(**+10**) and candidates in any other domain get `domainIntentMismatchPenalty`
(**−4**). So a search for "ritual" lifts Tara results and gently pushes the rest
down, without hiding them. With no recognized intent token, this component
contributes `0`.

Candidates that score `0` after summation are filtered out. Ties are broken
deterministically: first by the canonical domain order
`['tara','veritas','nyx','arete','nisaba','metis']`, then by
`title.localeCompare`. Internally each `RankedSearchCandidate` carries a
`breakdown` (`{ lexical, kindBoost, domainIntentBoost, total }`) for debugging
and the Studio search-ranking workspace, but the breakdown is stripped before
the result leaves the route.

> **Reality vs. spec — the ranker.** The source spec's "Ranker Features and
> Coherence" lists signal scores, persona/tone fit, Veritas evidence integrity,
> Sophia grounding state, entitlement class, and a free-text reason taxonomy
> ("because you saved X," "fresh in your concept graph"), plus coherence
> constraints and cadence rules. **None of that is in the live ranker.** Those
> features live in `libs/oshun/search-discovery/src/ranker/` and are retired
> (see the banner above). The live ranker scores only lexical title/summary
> match, per-kind boosts, and the six-domain intent boost/penalty.

### Search response envelope and resilience

The handler builds a `SearchRoutePayload` (typed in `search.ts` lines 48–64)
that carries far more than a result list:

```jsonc
{
  "generatedAt": "2026-06-24T…Z",
  "userId": "…",
  "query": "ritual",
  "domainFilter": "all",
  "authorizedDomains": ["tara", "veritas", "nyx", "arete", "nisaba", "metis"],
  "results": [
    {
      "id": "…",
      "domain": "tara",
      "kind": "ritual",
      "title": "…",
      "summary": "…",
      "score": 59,
    },
  ],
  "pagination": {
    "limit": 20,
    "cursor": null,
    "nextCursor": null,
    "hasMore": false,
    "total": 7,
  },
  "domainStatus": {
    "tara": "ok",
    "veritas": "degraded",
    "nyx": "ok",
    "arete": "ok",
    "nisaba": "ok",
    "metis": "ok",
  },
  "errors": [{ "domain": "veritas", "stage": "highlights", "message": "…" }],
  "partial": true,
  "partialFailure": true,
}
```

This **partial-failure envelope** is a real resilience feature the docs omitted.
Because search fans out to every authorized domain in parallel, one domain
failing should not blank the whole sheet. The handler collects per-domain
failures, computes a `buildAggregationFailureState` that maps each domain to a
status — `ok | degraded | forbidden` — and threads them through
`buildRoutePartialFailureEnvelope`. When any domain failed, `partial` /
`partialFailure` flip to `true` and the offending domains are listed under
`errors` (with the failing `stage`, e.g. `continue` vs. `highlights`), while the
results from the healthy domains still render. `attachPartialResponseTrace`
attaches an optional trace for observability. See
[Analytics, Observability, Testing, and Security](./analytics-and-testing.md).

Finally, search emits telemetry on every request via `trackSearchTelemetry(...)`
(fire-and-forget; a telemetry failure logs a warning but never fails the
response). The emitted record carries the user id, the query, the result count,
the domain filter, the `partialFailure` flag, and the surface (`'results'`).

### The web surface

The universal search page is `apps/oshun/web/src/app/search/page.tsx` — a thin
(901-byte) Next.js page that wraps `<SearchResultsView />` in the shell layout
and marks the route as `active: 'explore'`. Per the V1 completeness audit, the
end-to-end **search-explore-deep-read-library-save** journey is rated _partial_:
the page is intentionally thin, and the rich, per-object-class universal search
the spec lists is served by the lexical BFF route, not by a dedicated per-class
front-end index. See [Product Surfaces](./product-surfaces.md) and the companion
[Search, Discovery, and Knowledge Graph](../architecture/search-discovery-knowledge-graph.md)
architecture page.

## Live cross-domain recommendations

Recommendations are the proactive counterpart to search: instead of a query, the
member gets a blended rail of "what to do next" pulled from all six domains. The
route lives in `apps/oshun/bff/src/routes/recommendations.ts`.

### Routes

- `GET /recommendations`
- `GET /v1/recommendations`
- `POST /v1/recommendations/feedback`

All three sit behind the same abuse-protection + auth pre-handlers as search.
The GET handler returns `401` without an auth context and `403`
(`domain_scope_missing`) when the member has no authorized domain. Pagination
defaults to **12** items, max **50** (`parsePagination` with
`limitParam: 'limit'`, `cursorParam: 'cursor'`). Responses are cached for **30
seconds** (`RECOMMENDATIONS_CACHE = createRouteResponseCache(30_000)`), keyed by
user id, the consumer-profile **revision** (so editing your preferences busts
the cache), and the pagination value.

### Candidate generation = the six domain adapters

The single most important reality check: live recommendation candidates are
generated **only** by calling each authorized domain adapter's own
recommendation method. There is no collaborative filtering, no embedding
nearest-neighbor, no concept-graph traversal, and no cross-domain-bridge
generator on the live path — those generators are in the retired
`libs/oshun/search-discovery/src/candidates/`. Live generation fans out over
`DOMAIN_IDS`, skipping any domain the member is not authorized for, and
dispatches per domain:

| Domain  | Adapter call                                                                    | Candidate shape                                                                                                                  |
| ------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Tara    | `tara.getRecommendedSessions({ limit: 6 })`                                     | breathing / meditation sessions; reason `personalized` (top) else `popular`; `baseScore = 80 − index·8`                          |
| Veritas | `veritas.getTrendingArticles({ limit: 6 })`                                     | articles; reason `personalized` if interacted, else `trending` (score>70) else `new_content`; `baseScore = article.score`        |
| Nyx     | `nyx.getNightlyHighlights({ limit: 6 })`                                        | sky events; reason `time_based` if visible tonight else `editorial`; `baseScore` 90/65/40 by `major`/`moderate`/minor importance |
| Arete   | `arete.getActiveGoals({ userId, limit: 6 })`                                    | goals; reason `streak_support` if streak>0 else `goal_based`; `baseScore = 70 + min(streak,30)` or `55`                          |
| Nisaba  | `nisaba.getDailyPassage()` + `nisaba.getWorkspaceEntries({ userId, limit: 4 })` | a daily passage (reason `editorial`, score 82) + workspace entries (reason `personalized`/`new_content`)                         |
| Metis   | `metis.getRecommendedCourses({ limit: 6 })`                                     | courses; reason `goal_based` (top) else `editorial`; `baseScore = 78 − index·6`                                                  |

Each fetch is wrapped in a try/catch that pushes a `{ domain, message }` error
on failure and returns `[]`, so one failing domain degrades gracefully into the
partial-failure envelope rather than failing the whole request. The Arete and
Nisaba fetchers require `userId` because goals, daily passages, and workspace
entries are personal records pulled through the member context.

> **Reality vs. spec — candidate generation.** The source spec's "Candidate
> Generation" section (collaborative-filtering, content-similarity,
> concept-graph traversal `passage → concept → ritual → teacher`, cross-domain
> bridges, tenant-scoped candidates) describes the retired library. The live
> path generates candidates purely from the six adapters' own recommendation
> methods.

### The live recommendation reason taxonomy

The live reason taxonomy is a real, machine-readable enum —
`RecommendationReason` in `apps/oshun/bff/src/recommendations/types.ts` (lines
21–30) — narrower and more structured than the spec's free-text labels:

```ts
type RecommendationReason =
  | 'trending'
  | 'personalized'
  | 'time_based'
  | 'goal_based'
  | 'popular'
  | 'new_content'
  | 'streak_support'
  | 'cross_domain'
  | 'editorial';
```

Every `RecommendationItem` carries both this machine reason and a human-readable
`explanation` string the client renders for transparency (e.g. "Visible tonight
— don't miss it," "Keep your 7-day streak alive," "Daily source passage for
close reading"). This is the live system's answer to the spec's "reason
attribution" promise — concrete and enumerated, not the free-text "because you
saved X… fresh in your concept graph" the spec described.

### Scoring and ranking

`scoreAndRankCandidates` (`apps/oshun/bff/src/recommendations/scoring.ts`) turns
the flat candidate pool into a ranked, diversified, explained list. It is a
deterministic weighted composite over five dimensions (`WEIGHTS`, lines 50–56):

| Dimension             | Weight   | What it measures                                                            |
| --------------------- | -------- | --------------------------------------------------------------------------- |
| `baseRelevance`       | **0.34** | The domain-supplied signal, normalized to 0–100 against the pool max        |
| `diversityBonus`      | **0.18** | A lift for domains underrepresented in the pool, targeting `1/6` per domain |
| `recencyBoost`        | **0.18** | `RECENCY_SCORES[reason]` — `time_based` 95 … `cross_domain` 30              |
| `engagementAlignment` | **0.18** | `ENGAGEMENT_SCORES[reason]` — `personalized` 95 … `popular` 35              |
| `preferenceAlignment` | **0.12** | How well the candidate's domain matches the member's preferred domain order |

The reason → dimension maps are concrete (`scoring.ts` lines 69–92). Recency
scores rank
`time_based: 95, trending: 80, new_content: 70, editorial: 55, streak_support: 50, goal_based: 45, personalized: 40, popular: 35, cross_domain: 30`.
Engagement scores rank
`personalized: 95, goal_based: 90, streak_support: 85, cross_domain: 60, editorial: 55, trending: 50, time_based: 45, new_content: 40, popular: 35`.
The **diversity bonus** uses a target fraction of `1/6` (uniform across six
domains): a candidate from an underrepresented domain scores up toward 100,
while a domain already over-represented decays linearly. Ties break in favor of
the _less_ represented domain (`scoring.ts` lines 175–181), which actively
spreads the final rail across domains rather than letting one domain dominate.

**Preference alignment** is wired to the member's real consumer profile. The
route reads `consumerProfileStateStore.getRecord(userId)` and builds a ranking
profile with
`buildOshunRecommendationRankingProfile(profile.preferences, { currentDaypart, availableDomains })`
from `@oshun/auth-client`, where `currentDaypart` comes from
`resolveOshunPersonalizationDaypart(new Date())`. The profile's
`preferredDomainOrder` (filtered to the six valid domains) feeds
`resolvePreferenceScore`: the top preferred domain scores 100, then 80 / 60 /
40, and anything beyond the top four (or absent) scores 20; with no declared
order at all, every domain gets a neutral 50. The daypart input is what lets the
rail tilt toward, e.g., evening practice without inventing any signal the
profile doesn't already carry.

The candidate pool is over-fetched to `limit · 2` before pagination, then
sliced. The response is a `RecommendationsRoutePayload` (`types.ts` lines
95–115) carrying `items`, the partial-failure
`results`/`errors`/`partial`/`partialFailure`, the pagination envelope, and a
`domainBreakdown` (count per domain) computed by `computeDomainBreakdown` so
clients and analytics can verify the blend.

### Recommendation feedback

`POST /v1/recommendations/feedback` is a real, shipping endpoint the original
search/discovery section never documented. It lets the member tune the rail. The
handler (`recommendations.ts` lines 455–581) validates:

- An item identifier — `itemId` (or legacy `recommendationId`) — must be a
  string.
- `signal` ∈ `{ hide, less, more }` (validated against a `Set`; otherwise
  `400 invalid_signal`).
- `domain` ∈ the canonical six `DOMAIN_IDS`
  (`tara, veritas, nyx, arete, nisaba, metis`; otherwise `400 invalid_domain`).

The three signals mean exactly what they say (`types.ts` lines 67–74): `hide`
removes that specific item, `less` shows fewer like it, `more` shows more like
it. The handler assembles a `RecommendationFeedbackPayload` with the user id,
item, domain, item type, signal, machine reason, timestamp, and optional
attribution fields (`source` ∈ `home|hub|bff`, `surface` ∈
`cross-domain|carousel`, `sourceDomain`, `targetPath`, `attributionId`), logs it
for downstream training, and returns
`{ ok: true, feedback: { itemId, domain, signal, recordedAt } }`.

When the feedback is **cross-domain** (`surface === 'cross-domain'` and `source`
is `home` or `hub`) the handler additionally emits
`trackCrossDomainRecommendationFeedback(...)` — telemetry that records the
source→target domain hop, the signal, the reason, and any attribution id — so
the product can measure cross-domain navigation. That call is fire-and-forget
and a telemetry failure only logs a warning. The feedback log is honest about
its maturity: it records the signal and emits telemetry, with persisted model
retraining noted as downstream/future work rather than claimed as shipped.

## The one adopted piece: the offline evaluation release gate

The single module of the retired library that **is** live is its offline
evaluation release gate, mounted as `POST /v1/search/offline-eval`
(`apps/oshun/bff/src/search/offline-eval-route.ts`, registered in
`apps/oshun/bff/src/server.ts`). It satisfies the V1 exit criterion that
"offline evaluation … [is] operational," and it is a genuine, non-fabricated
gate: it imports `buildSearchReleaseGateSummary`, `OfflineEvalSlice`, and
`DriftReading` from `@oshun/search-discovery` and scores a candidate
ranker/recommender against a baseline.

The gate operates over per-slice metrics —
`SLICE_METRICS = ['ndcgAt10', 'mapAt10', 'recallAt100', 'coverage', 'diversity', 'serendipity']`
— computed per `domain × locale` fairness slice. Request body:

```jsonc
POST /v1/search/offline-eval
{
  "releaseId": "search-ranker-2026.06.24",
  "baselineSlices":  [ { "sliceId": "tara-en", "domain": "tara", "locale": "en", "ndcgAt10": 0.71, "mapAt10": 0.63, "recallAt100": 0.88, "coverage": 0.42, "diversity": 0.55, "serendipity": 0.19 } ],
  "candidateSlices": [ { "sliceId": "tara-en", "domain": "tara", "locale": "en", "ndcgAt10": 0.69, "mapAt10": 0.62, "recallAt100": 0.88, "coverage": 0.41, "diversity": 0.55, "serendipity": 0.18 } ],
  "driftReadings": [ { "detector": "ranker-quality", "baseline": 0.71, "observed": 0.70, "mdeRatio": 0.05 } ],
  "mdeRatio": 0.05
}
```

The route strictly validates each slice (`isSlice`: requires `sliceId`,
`domain`, `locale`, and all six metrics as finite numbers) and each drift
reading (`isDriftReading`: `detector` ∈
`{ranker-quality, candidate-generator, signal-pipeline}` plus numeric
`baseline`/`observed`/`mdeRatio`). Malformed input returns
`400 invalid_request`. Valid input calls `buildSearchReleaseGateSummary`, which
(in `libs/oshun/search-discovery/src/evals/offline-evals.ts`) does the real
math:

- **Per-metric drop check.** For each metric on each matched slice,
  `observedDrop = baseline − candidate`, `allowedDrop = |baseline| · mdeRatio`.
  Any metric whose drop exceeds the allowed band (the minimum-detectable-effect
  ratio, default `0.05`) produces a `SearchReleaseGateMetricFailure` and marks
  the slice failed.
- **Missing / unbaselined slices.** Baseline slices with no candidate
  counterpart (`missingCandidateSliceIds`) and candidate slices with no baseline
  (`unbaselinedCandidateSliceIds`) are both gate failures.
- **Drift detection.** Each `DriftReading` is evaluated by
  `evaluateDriftReading`: a regression beyond `|baseline| · mdeRatio` produces a
  failing `DriftVerdict`.

The summary's `ok` is `true` only when there are no missing/unbaselined slices,
no per-metric failures, and no drift failures. **`ok: false` means the candidate
must not ship** — this is the gate that enforces the spec's "any eval drop > MDE
blocks release" promise. The companion math (`computeNdcg`, `computeMap`,
`computeRecallAtK`, `computeCoverage`, `computeDiversity`, `computeSerendipity`)
is real and unit-tested in the same module.

> **Reality vs. spec — evaluation.** The source spec presents offline _and_
> online evaluation as one shipping suite. Only the **offline** gate above is
> wired live. The online experiment metrics, guardrails, significance machinery
> (sequential-test correction, sample-size guards), ramp/canary, and kill-switch
> live in `libs/oshun/search-discovery/src/experiments/` and are **not imported
> by any app** — there is no live A/B framework on `/v1/search` or
> `/v1/recommendations`.

## The retired library as the V1.x roadmap (aspirational)

The remaining sections of the source spec describe the retired
`libs/oshun/search-discovery` library. It is real, tested code and the explicit
V1.x adoption target — but **not on the V1 live path.** It is documented here so
the roadmap is legible, clearly labeled aspirational throughout.

### Signal taxonomy, aggregation, and decay (retired)

The library's `signals/` and `aggregation/` modules model the full taxonomy the
spec lists: implicit signals (impression, dwell, completion, save, follow, skip,
dismiss, hide, snooze, share, return-within-window, scroll depth, time-of-day),
explicit signals (rate, "more like this," follow user/topic/teacher,
regret-flag, domain-pin, persona-pin), memory-derived signals (declared
lineage/pace/goals/sensitivities, current [Arete](./domain-arete.md) focus,
recent [Veritas](./domain-veritas.md) reading, recent
[Nisaba](./domain-nisaba.md) study), domain-specific signals, and negative
signals — with privacy filtering keyed to [Iris](./iris-memory-identity.md)
suppression rules and cross-tenant isolation. Aggregation runs per-user /
per-cohort / per-tenant / per-domain / per-time-of-day / per-locale, with
per-signal-class decay (impressions fast, completions slow, declarations not at
all) and per-content-class freshness windows. **None of these signals is
collected on the live path today** — which is precisely why the retired ranker
cannot be adopted yet.

### Candidate generators (retired)

`candidates/generators.ts` implements the six generator families the spec
describes — collaborative filtering (cohorts from declared interests plus
observed behavior), content similarity (embedding nearest-neighbor in per-class
spaces), concept-graph traversal (`passage → concept → ritual → teacher`),
editorial slates (per-locale / per-season / per-event), recency, and
cross-domain bridges (Tara → Nisaba passage companion; Veritas → Arete next
action; Nyx → Tara perspective practice) — plus tenant-scoped Metis candidates
that never blend across tenants. The live recommendation path uses none of
these; it calls the six domain adapters directly (above).

### Feature-rich ranker, coherence, and cold-start (retired)

`ranker/` scores the rich feature set (signal scores, content metadata,
persona/tone fit, language/locale match, Veritas evidence integrity, Sophia
grounding state, entitlement class, reason-taxonomy fit), enforces coherence
constraints (Tara-centered ordering on home, max-N-from-same-source per page,
anti-monoculture, sensitive-content/rights/consent gating), suppression rules,
and per-domain cadence tuning. `cold-start/` models onboarding declarations,
demographic-light defaults (never protected-class demographics for ranking),
editorial seeds, content-similarity from a single consumed item, and
re-cold-start on long-absence return. All retired.

### Concept-graph substrate (retired; not a live Neo4j search backend)

`concept-graph/` defines the node schema (stable id, type, name, description,
language, locale variants, provenance, evidence references), the edge taxonomy
(prerequisite, refers-to, contradicts, supports, is-a, part-of, lineage-of,
observed-by, taught-by, derives-from, comparative-to), per-edge provenance with
confidence and reviewer, model-assisted enrichment with mandatory expert review
for high-stakes edges, and quality metrics (cycle-freedom, orphan rate,
prerequisite completeness, drift).

> **Reality vs. spec — the knowledge graph.** The architecture doc described a
> live, Neo4j-backed concept graph with Sophia-evaluated promotion paths feeding
> search/discovery. In the live BFF, **no search or recommendation route touches
> a concept graph or Neo4j.** The only place "concept" types appear in the BFF
> is `apps/oshun/bff/src/adapters/sophia-read-adapters.ts` — the
> [Sophia](./sophia-grounding.md) evidence _read_ adapter — which is not part of
> the search or recommendation path. The concept-graph candidate generator lives
> only in the retired library and does not feed live discovery.

## Honest summary: what ships in V1

| Capability                                                                                                     | Status                                               |
| -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `GET /search`, `GET /v1/search` (lexical, 3-pool, deterministic)                                               | **Live**                                             |
| Lexical ranker (title/summary weights, kind boosts, domain-intent)                                             | **Live**                                             |
| Universal search seeds + member's real saved objects in the pool                                               | **Live**                                             |
| `GET /recommendations`, `GET /v1/recommendations` (6-adapter fan-out)                                          | **Live**                                             |
| 9-value `RecommendationReason` enum + recency/engagement maps + diversity + preference alignment               | **Live**                                             |
| `POST /v1/recommendations/feedback` (hide/less/more + cross-domain telemetry)                                  | **Live**                                             |
| Search + cross-domain telemetry                                                                                | **Live**                                             |
| Partial-failure envelopes + per-domain `ok/degraded/forbidden` status                                          | **Live**                                             |
| `POST /v1/search/offline-eval` release gate (NDCG@10/MAP@10/recall@100/coverage/diversity/serendipity + drift) | **Live** (only adopted piece of the retired lib)     |
| Signal taxonomy, aggregation, decay                                                                            | Retired — V1.x target                                |
| CF / content-similarity / concept-graph / editorial / cross-domain-bridge candidate generators                 | Retired — V1.x target                                |
| Feature-rich ranker, coherence constraints, cadence tuning                                                     | Retired — V1.x target                                |
| Online A/B experimentation (ramp / canary / kill-switch)                                                       | Retired — **not imported by any app**                |
| Cold-start onboarding                                                                                          | Retired — V1.x target                                |
| Neo4j-backed concept-graph substrate in the live path                                                          | Not present — concept-graph code is retired-lib-only |

Adoption of the retired library becomes real only when search candidates start
carrying genuine `DiscoveryObject` metadata (real signal collection plus catalog
enrichment). Until then, V1 ships honest, deterministic ranking over the data it
actually holds. The discovery-enrichment line items live in the backlog, and the
signal-collection prerequisites in [V1/DEPENDENCIES.md](../DEPENDENCIES.md); the
feature hub is [../features.md](../features.md).

## Related

- [Search, Discovery, and Knowledge Graph](../architecture/search-discovery-knowledge-graph.md)
  — the companion architecture page with the same two-stack framing
- [Product Surfaces](./product-surfaces.md) — where the search sheet and
  recommendation rails appear
- [Customer Curation, Notebooks, Collections, and Sharing](./customer-curation-notebooks.md)
  — the member objects that join the search ranking pool
- [Sophia Grounding](./sophia-grounding.md) — the evidence read adapter where
  the BFF's only "concept" types live
- [Analytics, Observability, Testing, and Security](./analytics-and-testing.md)
  — search/recommendation telemetry and partial-failure traces
- [Iris Memory and Identity](./iris-memory-identity.md) — the suppression rules
  the retired signal taxonomy would honor
- [../features.md](../features.md) — the V1 features hub
