Oshun Platform · Architecture

Search, Discovery, and Knowledge Graph

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

7sections15 minread8tables

On this page

Search and discovery are the cross-domain "find anything, then see 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, Metis) — fanning queries out to each domain's data and blending the results back into one ranked list inside the BFF. This page is candid about a split that the rest of the V1 docs glossed over: there are two search stacks in this repo, and only the simpler one ships. The live /v1/search and /v1/recommendations paths use deterministic, lexical, domain-fan-out ranking over data the system actually holds; a much richer signal/candidate/ranker stack (libs/oshun/search-discovery) exists, is fully tested, and is explicitly retired from V1 scope. Read this page as the description of what runs, with the aspirational library called out honestly wherever it appears.

Where this sits in V1#

text
member query / home rail
        │
        ▼
  apps/oshun/bff   ── GET /v1/search ──────────► search/ranking.ts (lexical)
   (Fastify)       ── GET /v1/recommendations ─► recommendations/scoring.ts
                   ── POST /v1/recommendations/feedback
                   ── POST /v1/search/offline-eval ─► @oshun/search-discovery (the one adopted piece)
        │
        ▼  domain adapters (app.domainAdapters.*)
  tara · veritas · nyx · arete · nisaba · metis

Every route is scope-gated: a query only ever fans out to the domains the caller's token authorizes (resolveAuthorizedShellDomains / resolveAuthorizedDomains over authContext.scopes). The canonical domain set is OSHUN_DOMAIN_IDS = ['tara','veritas','nyx','arete','nisaba','metis'] (libs/oshun/auth/src/types.ts), re-exported as DOMAIN_IDS through apps/oshun/bff/src/routes/feed-helpers.ts.

The honest split. libs/oshun/search-discovery/src/index.ts:1-15 opens with a banner: the library is "RETIRED FROM V1 SCOPE (audit E5, 2026-06-11)." Its ranker scores DiscoveryObject features the live candidates do not carry — persona-tone fit, evidence integrity, grounding state, per-object entitlement/region — and the repo's honesty rules forbid inventing those signals just to feed it. So the live paths "keep their own real ranking over the data they actually have." The library stays tested and intact as the V1.x target: adoption becomes real only when search candidates begin carrying genuine DiscoveryObject metadata. Wherever this page describes the rich stack (signals, collaborative filtering, concept-graph candidates, the feature ranker, A/B experimentation), assume not shipped unless it says otherwise.


Universal search is registered at both GET /search and GET /v1/search (apps/oshun/bff/src/routes/search.ts:230-243), behind the abuse-protection and auth pre-handlers. A member types into the search sheet (web surface at apps/oshun/web/src/app/search/page.tsx) and the BFF returns one flat, ranked, cross-domain result list.

Query parsing and guards#

  • The query is read from either q or query and trimmed; an empty query is allowed (it returns curated/feed/user candidates ranked by kind and intent rather than by lexical match).
  • An optional domain filter narrows results to a single domain; the value is validated by isSearchDomainFilter and a bad value returns 400 (invalid_domain_filter).
  • Pagination defaults to limit 20, max 100 (parsePagination).
  • Non-empty queries are recorded per-user via recentSearchStore.addQuery(...), which is what backs the "recent searches" affordance.
  • Missing auth → 401 (missing_auth_context); a token with no domain scope → 403 (domain_scope_missing).

Three candidate pools merge before ranking#

A point the prior architecture text omitted entirely: live search does not query a single index. It assembles three distinct candidate pools and ranks them together (search.ts:140-179):

  1. Domain feed highlights and continue items. For each authorized domain, the route calls fetchDomainContinue(app, domainId, userId, failures) and fetchDomainHighlights(app, domainId, failures) in parallel, then flattens and applies the domain filter. These are the same "continue where you left off" and "domain highlight" objects the home feed uses.

  2. Curated universal seeds (selectUniversalSearchSeeds, apps/oshun/bff/src/routes/universal-search-seeds.ts). A static UNIVERSAL_SEARCH_SEEDS catalog of representative objects — one or more per object class, filtered to the member's authorized domains and the active domain filter. The seed kind union is exactly the object taxonomy the docs promise: ritual, practice, concept, passage, claim, source, notebook, collection, program, sky-event, course, lesson, learning-artifact. Each seed carries a real targetPath (e.g. /library/passage/nisaba-passage-speech, /events/nyx-quadrantids-2026, /courses/crs-002).

  3. The member's own objects (collectUserObjectCandidates, apps/oshun/bff/src/search/user-object-candidates.ts). This is the C7 audit fix that made search find things the member actually owns. It pulls from four real per-user stores:

    • saved library items (savedLibraryItemsStore, durable/LWW-synced) → kind saved-item, one per saved object with its own domain;
    • library collections (domainStubsStore.collections, owner-scoped, surfaced under Nisaba) → kind collection;
    • Nisaba notebooks (nisabaConsumerStateStore.listNotebooks) → kind notebook;
    • Arete habits (domainStubsStore.habits.listForOwner, strictly the member's own) → kind practice.

    The module is honest about emptiness: "a member with nothing saved contributes no candidates." There is no fabricated personalization — if you own nothing, this pool is empty.

The three pools are concatenated, mapped onto a uniform SearchRankingCandidate ({ id, domain, kind, title, summary, ...passthrough }), and handed to rankSearchCandidates(candidates, query).

The live ranker: lexical match + kind boost + domain intent#

The shipping ranker (apps/oshun/bff/src/search/ranking.ts) is deterministic and purely lexical. It is not the feature-rich libs/oshun/search-discovery ranker — there is no persona, evidence-integrity, grounding-state, or entitlement-class scoring on the live path. A candidate's score is the sum of three components (rankCandidate, lines 93-116):

score = lexical(title/summary match) + kindBoost(per kind) + domainIntentBoost

Lexical weights (DEFAULT_WEIGHTS, ranking.ts:38-65):

Match Weight Applies to
titleExact 40 whole normalized query is a substring of the title
summaryExact 24 whole 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
domainIntentBoost +10 candidate's domain matches the resolved query intent
domainIntentMismatchPenalty −4 candidate's domain differs from a resolved intent

An empty query short-circuits scoreLexical to a flat 1, so kind boost and intent become the ordering signal.

Per-kind boosts (kindBoosts) reward the most actionable result types — a "continue" item ranks above a generic highlight, and learning/practice objects above passive references:

Kind Boost Kind Boost
continue 14 ritual / program / passage / claim 9
course / lesson 11 source / notebook / collection / concept 8
assessment 10 concept-thread / sky-event 8
tutoring / learning-artifact / practice 9 highlight 7

Unknown kinds fall back to the highlight boost (7).

Domain intent routing (resolveDomainIntent, lines 165-217) is a concrete, undocumented relevance feature. It scans the query tokens and infers a target domain from vocabulary, then boosts that domain by 10 and penalizes the others by 4:

If a token is… Intent resolves to
a literal domain id (tara/veritas/nyx/arete/nisaba/metis) that domain
course, lesson, learn, tutor, assessment, artifact metis
ritual, practice, breath tara
passage, notebook nisaba
claim, source veritas
sky, event, meteor nyx

So "morning ritual" tilts toward Tara, "calculus lesson" toward Metis, "verify this claim" toward Veritas — even when those tokens appear in other domains' titles.

Sort and tie-breaking (rankSearchCandidates, lines 75-90): candidates with a non-positive score are dropped, then results sort by descending score; ties break by canonical domain order (DOMAIN_IDS.indexOf), then alphabetically by title. The result is stable and reproducible for the same inputs — important for caching and for the offline-eval gate below. The route strips the internal breakdown before returning, exposing only the final score per item.

Response envelope, resilience, and caching#

Search responses carry a partial-failure envelope the prior docs omitted (SearchRoutePayload, search.ts:48-64). Because the route fans out to up to six domains, a single domain failing must not blank the page:

  • domainStatus: Record<DomainId, 'ok' | 'degraded' | 'forbidden'> — per-domain health, built by buildAggregationFailureState from the collected failures.
  • errors[]{ domain, stage, message } for each failed domain call.
  • partial / partialFailure — set when any authorized domain degraded, so the client can show a "some results may be missing" banner instead of treating an empty section as "nothing found."
  • attachPartialResponseTrace attaches a debug trace for the partial response.

Responses are memoized in SEARCH_ROUTE_CACHE, a createRouteResponseCache with a 20 000 ms TTL, keyed by user, lowercased query, domain filter, and pagination (buildAuthenticatedCacheKey). Cache hits and misses both set applyResponseCacheHeaders.

Finally, each request fires trackSearchTelemetry({ userId, query, resultCount, domainFilter, partialFailure, surface: 'results' }) (apps/oshun/bff/src/telemetry/search-telemetry.ts); the emit is fire-and-forget and a failure only logs a warning — telemetry never blocks the response.


Cross-Domain Recommendations#

Recommendations are the "what's worth your attention right now" rail, blended across every authorized domain with transparent reason labels. Routes: GET /recommendations, GET /v1/recommendations, and POST /v1/recommendations/feedback (apps/oshun/bff/src/routes/recommendations.ts:439-581). Pagination defaults to limit 12, max 50.

Candidate generation is domain-adapter fan-out only#

This corrects a real inaccuracy in V1/features.md: the live path does not do collaborative filtering, content-similarity, embedding, concept-graph, or cross-domain-bridge candidate generation. Those generators live only in the retired libs/oshun/search-discovery/src/candidates/. Live candidate generation is exactly six adapter calls, each shaping its own domain's objects into RawCandidates. The six-call fan-out lives in apps/oshun/bff/src/routes/recommendations.ts (lines 64/95/135/186/226/287); the RawCandidate shape is defined at recommendations/scoring.ts:20-32:

Domain Adapter call Produces
Tara tara.getRecommendedSessions({ limit: 6 }) breathing / meditation sessions
Veritas veritas.getTrendingArticles({ limit: 6 }) fact-check articles (verdict, source tier)
Nyx nyx.getNightlyHighlights({ limit: 6 }) sky events (importance, visibility window)
Arete arete.getActiveGoals({ userId, limit: 6 }) personal goals / streaks
Nisaba nisaba.getDailyPassage() + getWorkspaceEntries({ userId, limit: 4 }) daily passage + recent workspace entries
Metis metis.getRecommendedCourses({ limit: 6 }) course paths (topic, grounding pack)

Each fetcher assigns a reason, a human explanation, a baseScore, and a typed meta blob for rich-card rendering. The reason often depends on real state — Arete tags a goal streak_support only when goal.streakDays > 0 ("Keep your N-day streak alive"); Nyx tags a sky event time_based only when its windowStart is today ("Visible tonight — don't miss it"); Veritas chooses personalized / trending / new_content from hasInteracted and score.

The reason taxonomy is a real nine-value enum#

The shipping reason taxonomy is the machine-readable RecommendationReason union (recommendations/types.ts:21-30), not the free-text "because you saved X … fresh in your concept graph" taxonomy the features doc describes:

text
RecommendationReason =
  'trending' | 'personalized' | 'time_based' | 'goal_based' | 'popular'
| 'new_content' | 'streak_support' | 'cross_domain' | 'editorial'

Each item still carries a human explanation string for the card, but clients can also filter or group by the stable category. The RecommendationItem contract (types.ts:42-61) exposes { id, domain, itemType, title, subtitle, explanation, reason, score (0–100), meta }.

Scoring: a five-dimension weighted composite#

scoreAndRankCandidates(candidates, limit, options) (recommendations/scoring.ts:112-184) blends five normalized dimensions into a single 0–100 score:

Dimension Weight What it rewards
baseRelevance 0.34 the domain adapter's own signal (baseScore, normalized to 0–100 against the pool max)
diversityBonus 0.18 candidates from underrepresented domains (target fraction 1/6), so all six domains surface rather than one dominating
recencyBoost 0.18 time-sensitivity, via RECENCY_SCORES[reason]
engagementAlignment 0.18 fit to engagement patterns, via ENGAGEMENT_SCORES[reason]
preferenceAlignment 0.12 the member's preferred domain order

The two reason→dimension maps are concrete (scoring.ts:69-92):

reason RECENCY_SCORES ENGAGEMENT_SCORES
time_based 95 45
trending 80 50
new_content 70 40
editorial 55 55
streak_support 50 85
goal_based 45 90
personalized 40 95
popular 35 35
cross_domain 30 60

So a sky event visible tonight rides recency; a streak nudge or goal rides engagement. The diversity term is piecewise: a domain at or below the 1/6 target gets up to 100; an overrepresented domain is penalized at roughly 3× the overshoot. Ties break by preferring the underrepresented domain (scoring.ts:175-181), and the final list is capped at limit.

Preference alignment is driven by the member's profile. buildOshunRecommendationRankingProfile(profileRecord.preferences, { currentDaypart, availableDomains }) and resolveOshunPersonalizationDaypart(new Date()) come from @oshun/auth-client (defined in libs/oshun/auth/src/personalization.ts). The daypart is one of morning / midday / evening, and it produces a preferredDomainOrder. resolvePreferenceScore (scoring.ts:186-216) maps that order onto a score curve — first preferred domain 100, then 80 / 60 / 40, and 20 for anything past fourth or unranked. A member with no preferences gets a flat 50, so personalization gracefully degrades to neutral.

Response envelope and caching#

The recommendations payload (RecommendationsRoutePayload, types.ts:95-115) mirrors search's resilience model: items / results, pagination, domainBreakdown (count per domain via computeDomainBreakdown), errors[], and partial / partialFailure. A failed domain fetch pushes a { domain, message } and is excluded — the rail still renders from the domains that answered. Responses cache in RECOMMENDATIONS_CACHE at 30 000 ms TTL, keyed by user, profile revision, and pagination — so a preference change (which bumps the revision) busts the cache immediately.

Recommendation feedback#

POST /v1/recommendations/feedback (recommendations.ts:454-581) is a real, shipping endpoint the prior search/discovery docs never mentioned. The body is validated strictly:

  • itemId (or legacy recommendationId), domain, and signal are required — otherwise 400 (invalid_body).
  • signal must be one of hide | less | more (RecommendationFeedbackSignal), else 400 (invalid_signal).
  • domain must be in DOMAIN_IDS, else 400 (invalid_domain).

The handler builds a RecommendationFeedbackPayload (userId, itemId, domain, itemType, signal, reason, timestamp, plus optional source, surface, sourceDomain, targetPath, attributionId) and logs it for downstream processing. When the feedback is genuinely cross-domain — surface === 'cross-domain' from a home or hub source — it also fires trackCrossDomainRecommendationFeedback(...) (apps/oshun/bff/src/telemetry/cross-domain-recommendation-telemetry.ts), recording the source→target domain hop, signal, reason, and attribution. The endpoint is candid in its own comments that persisting feedback to a queue / event stream for model training is a downstream concern — today it validates, logs, emits telemetry, and returns { ok: true, feedback: {...} }.


The One Adopted Piece of the Retired Library: the Offline-Eval Gate#

Exactly one module of @oshun/search-discovery is wired into the running product: the offline evaluation release gate, mounted at POST /v1/search/offline-eval (apps/oshun/bff/src/search/offline-eval-route.ts, registered in apps/oshun/bff/src/server.ts:961). It exists because the V1 exit criterion asks for "offline evaluation … operational," and the rest of the library — which computes real per-slice ranking metrics — was otherwise a dead island. This route mounts the real gate so an operator or CI job can score a candidate ranker/recommender against a baseline before it ships.

The gate imports buildSearchReleaseGateSummary, OfflineEvalSlice, and DriftReading from @oshun/search-discovery and validates the request body strictly. Each slice must carry a sliceId, domain, locale, and all six metrics in SLICE_METRICS:

Metric Meaning
ndcgAt10 normalized discounted cumulative gain at 10
mapAt10 mean average precision at 10
recallAt100 recall at 100
coverage fraction of the catalog the ranker can surface
diversity spread across object classes / domains
serendipity useful-but-unexpected results

Optional driftReadings must name a known detector (ranker-quality, candidate-generator, or signal-pipeline) with baseline, observed, and an mdeRatio. The gate (buildSearchReleaseGateSummary, in libs/oshun/search-discovery/src/evals/offline-evals.ts:158) compares each candidate slice to its baseline and fails any metric whose observed drop exceeds |baseline| × mdeRatio (the minimum-detectable-effect threshold; mdeRatio defaults to 0.05). The returned summary reports ok, missingCandidateSliceIds, unbaselinedCandidateSliceIds, sliceFailures, sliceMetricFailures, and driftFailures — and ok is true only when all of those are empty:

json
{
  "summary": {
    "releaseId": "rank-v3",
    "ok": false,
    "sliceFailures": [{ "sliceId": "tara-en", "ndcgAt10": 0.71, "...": "..." }],
    "sliceMetricFailures": [
      {
        "sliceId": "tara-en",
        "metric": "ndcgAt10",
        "baseline": 0.78,
        "candidate": 0.71,
        "observedDrop": 0.07,
        "allowedDrop": 0.039
      }
    ],
    "driftFailures": []
  }
}

A malformed body returns 400; ok: false means the candidate must not ship. This is a real, non-fabricated gate — it would fail a regressing ranker. What is not mounted is the online side: the experiment metrics, significance machinery, guardrails, and canary/kill-switch logic in libs/oshun/search-discovery/src/experiments/ (ab-framework.ts, canary.ts) are not imported by any app. So where V1/features.md presents offline and online evaluation as one shipping suite, only the offline gate is live.


Knowledge Graph: Spec, Not Live Path#

The prior architecture text described search/discovery as running over a "live Neo4j concept-graph with Sophia-evaluated promotion." That is aspirational. Neither /v1/search nor /v1/recommendations touches a concept graph or Neo4j: the search ranker is lexical over feed/seed/user-object candidates, and recommendations are domain-adapter fan-out. The only concept-graph code in the repo is the retired libs/oshun/search-discovery/src/concept-graph/ (schema, queries, curation) — its candidate generator does not feed the live paths. The BFF's Sophia integration (apps/oshun/bff/src/adapters/ sophia-read-adapters.ts) is an evidence read adapter over @oshun/evidence-sophia — grounding, review, and admin read roles for claims and notebooks — not a concept-graph substrate for search. See Sophia — Grounding Substrate.

When the Nisaba concept-graph / Metis knowledge-graph and Sophia-evaluated promotion become live discovery inputs, the adoption path is the one the retired library's banner names: search candidates start carrying real DiscoveryObject metadata (signal collection and catalog enrichment). At that point, the signals / aggregation / candidates / ranker modules wire into the live routes, and this section can drop the "spec, not live path" caveat.

The retired @oshun/search-discovery library (V1.x target)#

For completeness, the unadopted-but-tested modules (libs/oshun/search-discovery/src/):

Submodule Intended role (retired in V1)
catalog per-object-class index engine (object-classes, index-engine)
signals signal taxonomy + capture
aggregation signal aggregation with decay
candidates collaborative-filtering, content-similarity, concept-graph, editorial, cross-domain-bridge generators
ranker feature ranker with reason taxonomy + coherence/cadence constraints
experiments A/B framework, ramp/canary, kill-switch
cold-start onboarding bootstrap for new members
concept-graph graph schema, traversal queries, curation
evals offline + specialty evals — evals is the only module mounted live

Treat everything in that table except evals as planned, not shipped. The V1/features.md sections on the Searchable Object Catalog (per-class lexical+embedding+facet indices), Ranker Features (persona/evidence/grounding/entitlement scoring), and Online Experimentation describe this library, not the running BFF.


Edge Cases and Rationale#

  • Why lexical and not embeddings? The honesty rule. An embedding/feature ranker needs DiscoveryObject signals (persona fit, evidence integrity, grounding state) that the live feed/seed/user-object candidates simply do not carry. Shipping the rich ranker would mean fabricating those inputs, so V1 ships the ranker whose every input is real.
  • Empty query is valid. scoreLexical returns a flat 1, so an empty query surfaces a kind/intent-ordered browse list (continue items first) rather than erroring or returning nothing.
  • Authorization shapes the candidate pool, not just the response. Unscoped domains never enter the fan-out, so a member without Veritas access never sees a Veritas seed, feed item, or recommendation — and search over their own objects skips other members' data entirely (the user-object stores are owner-scoped).
  • Partial failure is first-class. One domain timing out degrades that domain's slice (domainStatus: 'degraded', partialFailure: true) without blanking the page — the resilience pattern shared by feed, search, and recommendations.
  • Caching respects identity and freshness. Search caches per user, query, filter, and page (20 s); recommendations cache per user, profile revision, and page (30 s), so a preference change invalidates immediately while repeated scrolls stay cheap.
  • Honest staleness. Per the V1 completeness audit, the search-explore-deep-read-library-save journey is partial: the web page.tsx is thin (≈901 bytes) and the rich object-class universal search the docs list is served by the lexical BFF route, not a dedicated per-class index.

Backlog and product scope: search/discovery/recommendations/knowledge-graph is tracked under § 15 in ../TODOS.md, with product detail in V1/features.md § Search, Discovery, Recommendations, and Knowledge Graph. Search and recommendations consume member objects from Customer Curation (notebooks, collections, saved-searches, saved items, habits).