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:
/**
* @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 /searchGET /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):
-
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) andfetchDomainHighlights(...)(the domain's curated highlights). These are flattened, then filtered to the requesteddomainfilter. Each becomes a ranking candidate whosesummaryis the feed item'smetastring. -
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. TheUNIVERSAL_SEARCH_SEEDSarray 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 realtargetPath(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 activedomainfilter survive. -
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', carryingstreakDays.
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.
- Saved library items from
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-enginein 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:
{
"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 /recommendationsGET /v1/recommendationsPOST /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:
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 legacyrecommendationId) — must be a string. signal∈{ hide, less, more }(validated against aSet; otherwise400 invalid_signal).domain∈ the canonical sixDOMAIN_IDS(tara, veritas, nyx, arete, nisaba, metis; otherwise400 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:
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, default0.05) produces aSearchReleaseGateMetricFailureand 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
DriftReadingis evaluated byevaluateDriftReading: a regression beyond|baseline| · mdeRatioproduces a failingDriftVerdict.
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/searchor/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.
Related#
- Search, Discovery, and Knowledge Graph — the companion architecture page with the same two-stack framing
- Product Surfaces — where the search sheet and recommendation rails appear
- Customer Curation, Notebooks, Collections, and Sharing — the member objects that join the search ranking pool
- Sophia Grounding — the evidence read adapter where the BFF's only "concept" types live
- Analytics, Observability, Testing, and Security — search/recommendation telemetry and partial-failure traces
- Iris Memory and Identity — the suppression rules the retired signal taxonomy would honor
- ../features.md — the V1 features hub