Oshun Platform · Features

Search, Discovery, Recommendations, and Knowledge Graph

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

7sections21 minread8tables

On this page

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, Veritas, Nyx, Arete, Nisaba, and Metis — 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.

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 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:

text
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.

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 and the companion Search, Discovery, and Knowledge Graph 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 (sourcehome|hub|bff, surfacecross-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 focus, recent Veritas reading, recent Nisaba study), domain-specific signals, and negative signals — with privacy filtering keyed to Iris 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 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; the feature hub is ../features.md.