peopleanalyst

parts / fourth-and-two

Fourth & Two — reusable patterns

Four converging efforts on one fantasy-football substrate — GM workflow app, Python analytics service, composable Insight Cards, and a coaching-strategy simulation game. The first thirteen patterns extract the stable subsystems: the platform-adapter port, the layered domain-state model, the CAMS framework adaptation, the card-relevance engine, and the polyglot two-runtime architecture.

13 patterns·source: people-analyst/mfl-command-center/docs/REUSABLE_PATTERNS.md

Fourth & Two — Reusable Engineering Patterns

Production-validated patterns from the Fourth & Two (MFL Command Center) codebase, stripped of fantasy-football specifics and written to be dropped into any new system.

Each entry has the same shape: Problem → The Pattern (TS sketch) → Design decisions → Tradeoffs → Citations. Pick by the index. Patterns marked CROSS appear in 2+ portfolio repos and reflect architectural convictions that hardened across products.

Extraction scope (v1.0, 2026-05-24)

Fourth & Two is a Turborepo monorepo with four converging efforts on one substrate: a Next.js GM workflow app (apps/web), a Python analytics service (analytics-api), a composable Insight Card library (packages/ui), and a coaching-strategy simulation game (packages/engine/src/strategy-league). Not every subsystem was extraction-ready at v1.0. The selection below is deliberately bounded.

Extracted from (stable subsystems):

  • packages/adapters/ — provider-agnostic adapter contract + Mock / MFL / Sleeper implementations
  • packages/types/ — layered domain-state model + analytics-provider contract
  • packages/engine/src/pipeline/, actions/, position-balance/, waivers/ — pure analytics pipeline
  • packages/ui/src/cards/ and packages/ui/src/lib/ — composable card framework with context-filtered relevance
  • packages/analytics-client/ and apps/web/app/api/analytics/* — BFF proxy bridging Next.js and the Python analytics service
  • packages/fantasy-analytics-provider-{http,mock,mfl} — pluggable enrichment provider

Out of scope at v1.0 (revisit in v1.1):

  • The strategy-league simulation engine (drive simulator, fourth-down gambit, momentum, fatigue) — internally consistent but game-specific shapes that don't generalize cleanly without more work.
  • Admin / imputation / enrichments routes — substantial commit activity in the last 7 weeks; extract once the seams settle.
  • Onboarding flow + environment switcher (very recent additions; let them harden first).

Two patterns below — P02 Layered Domain-State Model and P03 Cross-Portfolio Framework Adaptation (CAMS) — are the load-bearing ones a future agent should re-evaluate first; both are sharp shapes worth promoting if they survive another round of use.


Pattern Index

#Pattern NameCore TechnologyProblem Solved
P01Provider-Agnostic Platform Adapter with Multiple ImplementationsTypeScript port + adapter classesSame workflow surface needs to consume multiple third-party platforms (MFL, Sleeper, mock) without leaking provider quirks into UI. CROSS
P02Layered Domain-State Model (Identity/Context/Value/Risk/Opportunity)TypeScript composed typesOne canonical entity-state object that supports projections, risk, decisions, market, and strategy — without becoming a 200-field god-object.
P03Cross-Portfolio Framework Adaptation (CAMS Reuse)Domain-neutral scoring schemaA behavioral-science framework built for people analytics turns out to fit football roles. Name the abstraction so the reuse is auditable. CROSS
P04Composable Insight Card with Context-Filtered Relevance EngineReact + meta registry + pure selectorA library of analytical card components must surface only the cards relevant to where the user is right now, sorted by priority. CROSS
P05Multi-Component Score Breakdown for Explainable RankingPure TS scoring + structured breakdownA ranking surface that returns a single score is unreviewable; reviewers need to see which components contributed.
P06Pure-Function Analytics Pipeline OrchestratorTop-level pure orchestrator over module-level pure functionsA multi-stage analytics pipeline (mode detection → balance → market → ranking → summary) needs to be testable end-to-end without I/O.
P07Surplus/Deficit Classification with Bucketed Display LabelsPure function + threshold tableNumeric position-balance scores read as noise; bucketed states ("strong_surplus" / "balanced" / "strong_deficit") read as action.
P08Queue Validation Before Submit — Catch Errors at the BoundaryPer-item + cross-item validators with structured issue codesMulti-item submission queues (waiver claims, batch orders) fail in expensive ways downstream when not validated up front.
P09Greedy Queue Optimizer with Constraint TrackingSorted candidate iteration + used-resource setsPick the best N items from M candidates subject to a budget and uniqueness constraints, in a deterministic, explainable way.
P10Heuristic Scoring with Per-Reason Weight AttributionPure function returning value + reason listHeuristic recommendations (bid amounts, priority scores) need to carry the why in the same payload so the UI doesn't need a second round-trip.
P11BFF Proxy with Same-Origin ToggleNext.js API route fronting an external serviceA browser-facing app needs to call an external service that has secrets, CORS quirks, or schema-drift risk — pull it through your own BFF and toggle.
P12Polyglot Two-Runtime Architecture (TS App + Python Analytics)Two services, two languages, one typed contractThe UI runtime wants TypeScript ergonomics; the math runtime wants Python's scientific stack. Keep them separate; bridge with a typed contract.
P13Environment-Switched Factory Bound to Process BootModule-level dispatcher with cached choiceA service implementation should be selectable by environment (mock / local HTTP / live) without per-call parameters or runtime flipping. CROSS

P01. Provider-Agnostic Platform Adapter with Multiple Implementations

CROSS — generalizes from Performix #2 (Three-way adapter factory) and DevPlane P09 (Runtime Provider Registry). Fourth & Two pushes the same shape one level out: the adapter contract describes a whole workflow surface, not a single capability.

Problem

A product is a workflow shell over a third-party platform's data — leagues, accounts, transactions, rosters, schedules. Three things change independently: (a) the third-party platform (MFL, Sleeper, Yahoo), (b) the credential model (cookie auth, OAuth, no-auth public), (c) whether you're running against real infrastructure or a deterministic mock for tests / dev / demos. Hard-coding the platform's API into UI code couples the entire product to one vendor and one credential model.

The Pattern

// 1. One port defines the entire workflow surface.
//    Every method takes a session + a request; every method returns a normalized type.
export interface PlatformAdapter {
  login(input: LoginInput): Promise<AuthSession>;
  getUserAccounts(session: AuthSession): Promise<AccountSummary[]>;
  getAccountConfig(session: AuthSession, input: AccountRef): Promise<AccountConfig>;
  getTeamState(session: AuthSession, input: TeamStateRequest): Promise<TeamState>;
  submitOrder(session: AuthSession, input: OrderRequest): Promise<OrderResult>;
  getTransactions(session: AuthSession, input: TransactionsRequest): Promise<Transaction[]>;
  // ...one method per workflow verb
}

// 2. Three concrete adapters implementing the same port.
//    Each adapter encapsulates its own auth model + API shape + error translation.
export function createMockAdapter(opts?: MockAdapterOptions): PlatformAdapter { /* in-memory, deterministic */ }
export function createPrimaryAdapter(): PlatformAdapter { /* cookie auth, vendor A */ }
export function createSecondaryAdapter(): PlatformAdapter { /* public API, vendor B */ }

// 3. UI never imports the concrete classes — only the port and the factory.
import type { PlatformAdapter } from "@org/adapters";
import { createMockAdapter, createPrimaryAdapter } from "@org/adapters";

const adapter: PlatformAdapter =
  process.env.USE_MOCK === "true"
    ? createMockAdapter()
    : createPrimaryAdapter();

// 4. Workflow code is identical regardless of provider.
const accounts = await adapter.getUserAccounts(session);
const team = await adapter.getTeamState(session, { accountId, week });

Design decisions

  • The port is the whole workflow surface, not one method. Each verb the product needs to express becomes one method on the interface. This forces the abstraction to be wide enough to be useful but specific enough to be honest — "we need login + read + write at every level."
  • Auth lives inside the adapter, not on top of it. login() returns an AuthSession object the adapter consumes on subsequent calls. Adapters with different credential models (cookie vs. token vs. no-auth) all hide that detail behind the same AuthSession opaque type.
  • The mock adapter is a first-class implementation, not a test fixture. Same shape, same factory style, same return types. It enables UI development without infrastructure, demo modes, and reproducible tests — all from the same code path as production.
  • Normalized response types live in a separate package. Account, TeamState, Transaction are domain types the UI knows; each adapter is responsible for translating vendor-specific payloads into these. UI never sees a vendor payload.
  • Optional fields > discriminated unions. Different providers populate different optional fields (waiverPriority?, faabRemaining?) on the same shape. Forcing every adapter to fill every field would either lie or proliferate types.

Tradeoffs

StrengthsWeaknesses
UI is fully provider-agnostic; swap by changing one factory callWide port = adding a new vendor means implementing N methods, not 1
Mock adapter doubles as demo / Storybook / test fixtureOptional-field-heavy types lose some compile-time enforcement
Adding a new vendor doesn't touch UI codeVendor-specific features (e.g. one provider's unique trade types) get pushed into adapter-internal handling or dropped
Auth model is encapsulated; consumers see only AuthSessionCross-cutting features (rate limiting, retry) need per-adapter implementation or a wrapper layer

Citations

  • packages/adapters/src/contract.tsFantasyPlatformAdapter interface (≈15 methods covering the full GM workflow surface).
  • packages/adapters/src/mock-adapter.tscreateMockAdapter() — deterministic in-memory implementation; first-class.
  • packages/adapters/src/mfl-adapter.ts and sleeper-adapter.ts — two concrete vendor implementations sharing the port.
  • packages/types/src/index.ts — normalized domain types (RosterPlayer, Transaction, WaiverClaim) the adapters return.

P02. Layered Domain-State Model (Identity/Context/Value/Risk/Opportunity)

Problem

A central domain object (player, employee, candidate, account) needs to support many analytical surfaces — projections, risk scoring, opportunity detection, market signals, decision support, search. A flat 200-field type becomes unscannable; aggressive normalization into many small types forces consumers to join everything themselves. Both extremes lose: the type is either too crowded to read or too fragmented to use.

The Pattern

// Split the domain object into orthogonal layers.
// Each layer answers one question; each can be populated independently.

// 1. Identity — stable identifiers and descriptors. Doesn't change between snapshots.
export type EntityIdentity = {
  canonicalId: string;
  externalIds?: { systemA?: string; systemB?: string; systemC?: string };
  name: string;
  category: "primary" | "secondary" | "tertiary";
  // age / tenure / first-seen etc.
};

// 2. Context — situational, point-in-time. Where they are right now.
export type EntityContext = {
  period: number;
  currentWindow?: number;
  opponent?: string;
  scheduleStrength?: { window?: number; shortTerm?: number; longTerm?: number };
  depthRole?: "primary" | "support" | "backup" | "stash";
};

// 3. Value — what they're worth, over multiple horizons, with confidence intervals.
export type EntityValue = {
  scoringFormat?: string;
  riskPreference?: "conservative" | "balanced" | "aggressive";
  window: ValueEnvelope;          // { expected, p25, p50, p75, confidence }
  shortTerm: ValueEnvelope;
  longTerm: ValueEnvelope;
  replacementAdjusted?: { window?: number; shortTerm?: number; longTerm?: number };
};

// 4. Risk — what could go wrong, with tier + probability.
export type EntityRisk = {
  availabilityProbability?: number;
  riskScore?: number;
  riskTier?: "SAFE" | "MANAGED" | "ELEVATED" | "HIGH" | "CRITICAL";
  volatility?: { window?: number; shortTerm?: number; longTerm?: number };
  fragilityTags?: string[];
};

// 5. Opportunity — windows, trends, action-by dates.
export type EntityOpportunity = {
  window?: { start: number; end: number; strength: "low" | "medium" | "high"; actionBy?: number };
  trend?: { direction: "rising" | "flat" | "falling"; strength: "low" | "medium" | "high" };
  longTermOutlook?: { strength: "low" | "medium" | "high" };
};

// 6. Optional market layer for surfaces that need pricing / demand.
export type EntityMarket = {
  marketPressure?: "low" | "medium" | "high";
  expectedBid?: { suggested: number; min?: number; max?: number };
  acquisitionDifficulty?: "easy" | "moderate" | "hard";
};

// 7. Metadata — provenance, freshness, notes.
export type EntityMetadata = {
  updatedAt?: string;
  sources?: string[];
  notes?: string[];
};

// Compose into the canonical type.
export type EntityState = {
  identity: EntityIdentity;
  context: EntityContext;
  value: EntityValue;
  risk: EntityRisk;
  opportunity: EntityOpportunity;
  market?: EntityMarket;
  metadata?: EntityMetadata;
};

Design decisions

  • Layers are orthogonal, not nested by hierarchy. Identity doesn't change; context changes weekly; value changes per-projection; risk changes per-event. Splitting along change rate (and along question answered) is the discipline.
  • Optional layers for partial data. market? and metadata? are optional because not every surface populates them. The required layers (identity, context, value, risk, opportunity) are the always-present floor; everything else is bring-your-own.
  • Each layer has its own update path. A market refresh writes to entity.market without touching entity.value; a projection refresh writes to entity.value without touching entity.risk. Independent change paths prevent the "I updated one field and three others got stale" problem.
  • Envelopes (expected, p10, p25, p50, p75, p90, confidence) are the value primitive. Single-point values pretend to a precision the model doesn't have. Every value-layer field that matters is an envelope with explicit uncertainty.
  • No setters; states are computed. This shape is meant to be the output of an upstream computation, not a mutable record. Consumers read; writes happen via re-computation of the whole layer.

Tradeoffs

StrengthsWeaknesses
Surfaces can ignore layers they don't need; type stays small in any given consumerJoining all layers requires plumbing — not a flat record
Update paths are independent; partial refresh doesn't risk stale composition"Where does X go?" requires a clear layer convention (documented)
Confidence/uncertainty is baked into the type, not bolted onMore verbose at construction time than a flat object
Easy to extend (add a dynasty or legacy layer) without touching other layersConsumers must handle missing optional layers explicitly

Citations

  • packages/types/src/fantasy-player-state.tsFantasyPlayerState with all seven layers (identity, context, value, risk, opportunity, market, metadata).
  • packages/types/src/fantasy-analytics-provider.ts — the ProjectionEnvelope shape (a single-layer reflection of the value layer for the provider boundary).
  • packages/engine/src/monte-carlo/simulator.ts — consumer that reads player.value.window, player.risk.weeklyPlayProbability, player.opportunity.trend?.direction independently to build a simulation.

P03. Cross-Portfolio Framework Adaptation (CAMS Reuse)

CROSS — CAMS (Capability / Alignment / Motivation / Support) originated in the people-analytics-toolbox for diagnosing organizational performance; Fourth & Two adopts the same four-dimension schema for evaluating fantasy-football players' weekly role activation. The fact that it transferred at all is the engineering point — the schema is domain-neutral.

Problem

A behavioral or analytical framework built for one domain (workforce performance, student outcomes, athlete development) often turns out to fit another (player roles, candidate evaluation, content quality). Re-inventing a parallel framework for the new domain duplicates work; copying the framework without naming the lineage hides where it came from and how to revise it.

The Pattern

// 1. The original framework is domain-neutral by construction.
//    Each dimension is a 0-100 score with a tier mapping.
export type FrameworkDimension = "capability" | "alignment" | "motivation" | "support";
export type FrameworkTier = "INACTIVE" | "PARTIAL" | "ACTIVE" | "FULLY_ACTIVATED";

export type FrameworkScore = {
  subjectId: string;
  period: number;
  overall: number;             // composite, 0-100
  tier: FrameworkTier;
  dimensions: {
    capability: number;        // can they?
    alignment: number;         // is it aimed at the right thing?
    motivation: number;        // do they want to?
    support: number;           // does context enable it?
  };
  signal?: string;             // human-readable rationale
};

// 2. Domain adaptation is a thin re-interpretation layer.
//    The schema doesn't change; the *meaning* of each dimension is domain-specific.
//
// Domain A (people analytics): capability = skill, alignment = goal-fit,
//                              motivation = drive, support = enablement.
// Domain B (football roles):   capability = talent, alignment = scheme-fit,
//                              motivation = role-clarity, support = situation.

// 3. The tier mapping is shared.
function camsTier(overall: number): FrameworkTier {
  if (overall <= 54) return "INACTIVE";
  if (overall <= 65) return "PARTIAL";
  if (overall <= 75) return "ACTIVE";
  return "FULLY_ACTIVATED";
}

// 4. Surfaces that render the score don't know which domain they're in;
//    they take the FrameworkScore and render the four dimensions + tier.
//    The same card UI works across domains.
function renderFrameworkScore(score: FrameworkScore) {
  return {
    overall: score.overall,
    tier: score.tier,
    breakdown: Object.entries(score.dimensions).map(([k, v]) => ({
      label: titleCase(k),
      score: v,
      delta: 0, // computed elsewhere
    })),
    rationale: score.signal,
  };
}

Design decisions

  • The schema is domain-neutral by design, not by accident. "Capability / alignment / motivation / support" names abstract conditions for any role-activated entity. Football roles, organizational roles, student roles all have these four conditions; the schema asserts that.
  • Name the lineage explicitly. A comment at the top of the consuming type — // CAMS framework, originally in people-analytics-toolbox for workforce diagnostics; adapted here for football role activation — keeps the reuse auditable. Future contributors know where the framework lives canonically.
  • The tier mapping is shared, not re-derived. Both domains use the same INACTIVE / PARTIAL / ACTIVE / FULLY_ACTIVATED bucket cutoffs. The score → tier function is one function, not two.
  • Domain-specific interpretation lives in the score-producer, not the schema. The football scorer knows "capability = talent grade × matchup × scheme fit"; the workforce scorer knows "capability = skill assessment × tenure × certifications." The schema doesn't.
  • UI consumers are oblivious to domain. A score card that renders four dimensions + overall + tier + signal works identically across products. The card asks "what's the score?", not "what domain are you?"
  • This is the cross-portfolio reuse story the catalog is designed to surface. Most patterns transfer at the engineering-shape level (factories, validators). CAMS transfers at the analytical-framework level — a sharper kind of reuse, easier to lose track of without a deliberate naming convention.

Tradeoffs

StrengthsWeaknesses
Framework lineage is preserved across domains; provenance is auditableCross-domain reuse only works if the schema is genuinely domain-neutral; forced fits break
Shared tier mapping = shared mental model across productsIf the original framework evolves (new dimension added), all adopters must coordinate
UI cards are framework-aware but domain-agnostic — one card, many productsDomain experts may want to add domain-specific dimensions; the discipline is to push those into a separate layer, not bloat the shared schema
Naming the lineage prevents silent re-inventionAdopting a framework you don't fully understand risks looking like reuse but acting like cargo-culting

Citations

  • packages/types/src/fantasy-analytics-provider.tsCamsTier enum and the cams block on ProjectionEnvelope.
  • packages/ui/src/cards/light/cams-alignment-card.tsx — the four-dimension breakdown card; renders identically to a workforce-CAMS card with different labels.
  • packages/fantasy-analytics-provider-mock/src/index.tscamsToTier() function; shared tier mapping.
  • Originally extracted from CAMS in people-analytics-toolbox (canonical lineage in Performix's cams-diagnostic capability per Performix REUSABLE_PATTERNS.md §10 "binding-constraint diagnostic"). Adapted here for football roles; same schema, different domain interpretation.

P04. Composable Insight Card with Context-Filtered Relevance Engine

CROSS — sibling to Performix #1 (Precompute-and-playback). Fourth & Two pushes the same idea into the UI library: cards are first-class registered components with metadata that drives where they appear.

Problem

A product accumulates dozens of analytical "card" surfaces — start-sit, risk profile, market signal, trend, projection range, head-to-head, opportunity treemap. The hard problem is not building any one card; it's choosing which cards to show on a given page. Hardcoding "on the rankings page, show cards A, B, C" couples the page to the card list and breaks when a new card lands. Showing all cards everywhere is noise.

The Pattern

// 1. Every card exports a `cardMeta` describing where + when + why it's relevant.
export type CardContext =
  | "main_view" | "entity_detail" | "decision_surface"
  | "draft" | "rankings" | "market" | "search" | "library";

export interface CardMeta {
  concept: string;              // "risk" | "value" | "opportunity"
  metric: string;               // specific calculation name
  segment: "entity" | "group" | "position" | "global";
  period: "live" | "window" | "season";
  trigger: "scheduled" | "event" | "threshold" | "user";
  sizes: Array<"square" | "portrait" | "wide" | "story">;
  theme: "light" | "dark" | "both";

  // ── Relevance engine fields ──
  /** Surfaces where this card is eligible to appear. */
  contexts?: CardContext[];
  /** Sort priority; 1 = highest. */
  priority?: 1 | 2 | 3 | 4 | 5;
  /** If true, needs entity/group context; cannot show in generic view. */
  requiresSegment?: boolean;
  /** Free-text search hooks for library / search. */
  keywords?: string[];
}

// 2. Each card file co-locates the component + its meta.
// File: cards/risk/risk-trend-card.tsx
export function RiskTrendCard(props: RiskTrendCardProps) { /* JSX */ }
export const cardMeta: CardMeta = {
  concept: "risk", metric: "trend",
  segment: "entity", period: "window",
  trigger: "event",
  sizes: ["portrait", "wide"], theme: "both",
  contexts: ["entity_detail", "decision_surface", "library"],
  priority: 2,
  requiresSegment: true,
};

// 3. A registry imports every cardMeta. The list is the catalog.
// File: card-registry.ts
import { cardMeta as riskTrendMeta } from "../cards/risk/risk-trend-card";
import { cardMeta as startSitMeta } from "../cards/start-sit-card";
// ... one import per card
export const ALL_CARD_META: CardMeta[] = [riskTrendMeta, startSitMeta /* ... */];

// 4. A pure selector returns the cards relevant to a context.
//    No I/O. Trivially testable.
export function selectCardsForContext(
  context: CardContext,
  options: { segment?: "entity" | "group" | "position"; limit?: number } = {},
): CardMeta[] {
  const { segment, limit } = options;
  const filtered = ALL_CARD_META.filter((meta) => {
    if (!meta.contexts?.includes(context)) return false;
    if (meta.requiresSegment && !segment) return false;
    return true;
  });
  const sorted = [...filtered].sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
  return limit != null ? sorted.slice(0, limit) : sorted;
}

// 5. Pages query the selector. No hardcoded card lists.
const cardsForRankings = selectCardsForContext("rankings", { segment: "entity", limit: 6 });

Design decisions

  • cardMeta is co-located with the component, not in a central config. When a developer adds a card, the meta ships with it; impossible to add a card and forget to register it (the registry import will fail if the meta export is missing).
  • The registry is hand-maintained, not auto-derived. One-line import per card. Auto-discovery sounds nice but obscures what's actually in the catalog. Grepping for ALL_CARD_META.push(...) would also work; a single-file list is easier to review.
  • The selector is a pure function. No fetch, no React. selectCardsForContext(context, opts) returns CardMeta[]. Trivially unit-testable. The page wraps the meta with React rendering separately.
  • requiresSegment is structural, not stylistic. A card that needs entity context (e.g., a player-specific risk profile) cannot render on a generic view. Encoded in metadata, enforced by the selector, no runtime "oh wait, no player" crash.
  • priority is per-card global, not per-context. A card that's priority-1 in decision_surface is also priority-1 in entity_detail. Lets the sort logic stay simple; per-context priority is an explicit "expand if needed" path, not the default.
  • sizes and theme are presentation concerns; contexts, priority, requiresSegment, keywords are relevance concerns. The split keeps the selector from caring about layout.
  • The same card meta drives the library / search surface. keywords and full metadata mean the library page can render the same list as the page-specific selector, just unfiltered by context.

Tradeoffs

StrengthsWeaknesses
New cards "just appear" in relevant contexts via metadata, no per-page editsMetadata-driven means a card with wrong metadata silently goes missing
The relevance engine is one pure function; trivially testableThe metadata schema becomes a versioning surface — adding fields is easy, removing is hard
Same cards work across page contexts; no duplicationPer-context customization (different priority on different pages) needs an extension, not in the v1
Library / search surface uses the same registryHand-maintained registry imports can drift if a new card is added without updating

Citations

  • packages/ui/src/lib/card-meta.tsCardMeta shape + CardContext enum (15-context taxonomy).
  • packages/ui/src/lib/card-registry.ts — hand-maintained ALL_CARD_META array; 15 card types registered.
  • packages/ui/src/lib/card-selector.ts — pure selectCardsForContext() function.
  • packages/ui/src/cards/light/cams-alignment-card.tsx, prism-trend-card.tsx, et al. — co-located cardMeta exports.
  • packages/ui/src/cards/shared/insight-card.tsx — the shared shell component all cards compose into (consistent layout, sizes, share targets).

P05. Multi-Component Score Breakdown for Explainable Ranking

Problem

A ranking surface that returns score: 7 is unreviewable. A user, an auditor, or a future maintainer who sees a recommendation can't ask "why?" without an answer that decomposes the score. Bolting on an explanation field after the fact ("score 7 because reasons") drifts from the actual computation. The explanation has to be derived from the same numbers that produced the score.

The Pattern

// 1. The score is a sum (or product) of named components.
//    Naming is the load-bearing move — each component is one auditable input.
export type ScoreComponent =
  | "positional_need"
  | "schedule_opportunity"
  | "market_scarcity"
  | "risk_reduction"
  | "league_behavior";

export type ScoringInput = {
  positionalNeedScore: number;
  scheduleOpportunityScore: number;
  marketScarcityScore: number;
  riskReductionScore: number;
  leagueBehaviorScore: number;
};

export function scoreCandidate(input: ScoringInput): number {
  return (
    input.positionalNeedScore +
    input.scheduleOpportunityScore +
    input.marketScarcityScore +
    input.riskReductionScore +
    input.leagueBehaviorScore
  );
}

// 2. The breakdown structure mirrors the score — same components, same numbers, with labels.
export type ScoringBreakdown = {
  total: number;
  components: Array<{
    key: ScoreComponent;
    label: string;          // human-readable
    score: number;
    explanation?: string;   // optional rationale per component
  }>;
  notes?: string[];         // free-form notes about the scoring run
};

// 3. A single config maps internal keys to display labels — single source of truth.
const COMPONENT_CONFIG: Array<{
  key: ScoreComponent;
  label: string;
  inputKey: keyof ScoringInput;
}> = [
  { key: "positional_need", label: "Positional Need", inputKey: "positionalNeedScore" },
  { key: "schedule_opportunity", label: "Schedule Opportunity", inputKey: "scheduleOpportunityScore" },
  { key: "market_scarcity", label: "Market Scarcity", inputKey: "marketScarcityScore" },
  { key: "risk_reduction", label: "Risk Reduction", inputKey: "riskReductionScore" },
  { key: "league_behavior", label: "League Behavior", inputKey: "leagueBehaviorScore" },
];

// 4. The breakdown builder runs the config; the score function uses the same inputs.
//    Drift between the two is structurally impossible — both consume the same ScoringInput.
export function buildScoringBreakdown(
  input: ScoringInput & {
    notes?: string[];
    explanations?: Partial<Record<ScoreComponent, string>>;
  },
): ScoringBreakdown {
  const components = COMPONENT_CONFIG.map(({ key, label, inputKey }) => ({
    key,
    label,
    score: input[inputKey],
    explanation: input.explanations?.[key],
  }));
  const total = components.reduce((sum, c) => sum + c.score, 0);
  return { total, components, notes: input.notes };
}

// 5. The ranker returns both the score and the breakdown on every candidate.
export type RankedCandidate = {
  id: string;
  score: number;
  scoring: ScoringBreakdown;
  // ... candidate-specific fields
};

Design decisions

  • Components are named in code, not as strings. "positional_need" is a typed literal; misspelling fails to compile. The component identity is part of the type system.
  • scoreCandidate and buildScoringBreakdown share the same input type. A change to the score formula (adding a component) forces a change to the breakdown shape. Drift between "what the score is" and "what the breakdown says" is structurally prevented.
  • The breakdown is always built, even when not shown. Always produce the explanation; let the UI decide whether to surface it. A surface that wants to add an "explain this" affordance later doesn't need a backend change.
  • Component labels are config, not strings sprinkled in code. One COMPONENT_CONFIG array maps internal keys to display labels. Renaming a label is a one-line change.
  • Explanations are per-component, optional, and pluggable. Default explanations live with the ranker; specific runs can override. Useful for A/B copy testing or per-domain phrasing without forking the scorer.
  • notes is for free-form context about the scoring run (e.g., "Heuristic-first v1 scoring model"). Not per-component; for the whole breakdown.

Tradeoffs

StrengthsWeaknesses
Every score is auditable; no orphan recommendationsScoring formula must be a sum/weighted-sum of named components; nonlinear scoring needs adaptation
Score and breakdown can't drift — same inputs, same componentsMore verbose than returning a single number
UI can show or hide the breakdown without a backend changeComponent count is fixed in code; per-run dynamic components need a different shape
Explanations are pluggable per componentMulti-step scoring (score the components, then transform) needs a separate pattern

Citations

  • packages/engine/src/actions/scoreAction.tsscoreAction() — five-component sum.
  • packages/engine/src/actions/buildScoringBreakdown.tsbuildScoringBreakdown() — same five components, labeled.
  • packages/engine/src/actions/rankStrategyActions.ts — call site; every ranked action carries both score and scoring (breakdown).
  • packages/engine/src/waivers/suggestWaiverBid.ts — variant of the same pattern: reasons: WaiverBidReason[] array with type / label / explanation / weight for each adjustment to a suggested bid.

P06. Pure-Function Analytics Pipeline Orchestrator

Problem

A multi-stage analytics pipeline — detect mode → compute balance matrix → score market → rank actions → build summaries — needs to be testable end-to-end against fixture data, debuggable when one stage produces unexpected output, and replaceable per stage without rewriting the orchestrator. Tangling I/O (DB reads, HTTP calls) into the pipeline makes all three hard.

The Pattern

// 1. The orchestrator is a pure function: input → output. No I/O.
//    Every step is a separate pure function imported from a stage module.

import { detectMode } from "../mode/detectMode";
import { buildBalanceMatrix } from "../balance/buildBalanceMatrix";
import { computeMarketState } from "../market/computeMarketState";
import { detectOpportunities } from "../opportunity/detectOpportunities";
import { detectRisks } from "../risk/detectRisks";
import { buildBehaviorModel } from "../behavior/buildBehaviorModel";
import { computeMarketPressure } from "../market/computeMarketPressure";
import { rankActions } from "../actions/rankActions";
import { buildSummary } from "../summary/buildSummary";

export function runAnalyticsPipeline(input: PipelineInput): PipelineOutput {
  const mode = detectMode({
    currentPeriod: input.currentPeriod,
    record: input.record,
    historicalOdds: input.historicalOdds,
  });

  const allCells = buildBalanceMatrix({ allEntityStates: input.allEntityStates });
  const myMatrix = allCells.filter((c) => c.entityId === input.targetEntityId);

  const marketState = computeMarketState(allCells);
  const opportunities = detectOpportunities({
    items: input.targetEntity.items,
    currentPeriod: input.currentPeriod,
    scheduleStrengthByItem: input.scheduleStrengthByItem,
  });
  const risks = detectRisks({ items: input.targetEntity.items });

  const behavior = buildBehaviorModel({ transactions: input.transactions });
  const pressure = computeMarketPressure({ marketState, transactions: input.transactions, behavior });

  const rankedActions = rankActions({
    myMatrix, marketState, opportunities, risks, pressure, mode,
  });
  const summary = buildSummary(rankedActions);

  return {
    mode,
    summary,
    rankedActions,
    opportunities,
    risks,
    balance: myMatrix,
    marketState,
    pressure,
    behavior,
  };
}

Design decisions

  • Orchestrator is pure; stages are pure. The whole pipeline is one giant pure function. Fixture in, snapshot out. Tests compare snapshots.
  • Stages are imported from sibling modules, one stage per directory. balance/, market/, risk/, opportunity/, behavior/, actions/, summary/. Easy to find; easy to swap.
  • I/O happens before the orchestrator runs. The caller assembles PipelineInput by reading from the DB / API / cache; the orchestrator never reads. This makes the orchestrator deterministic given input.
  • Each stage takes only what it needs. rankActions takes 6 args, all derived from earlier stages. No implicit dependency on a giant context object.
  • Output is a single typed object with all intermediate results. Consumers can pluck summary or drill into marketState. Useful for debugging and for surfaces that want to render different layers.
  • Heuristic-first scoring (per P05) inside stages. Each stage's internal scoring is also pure + breakdown-shaped. The orchestrator doesn't know or care.
  • Test setup matches production setup. The test calls runAnalyticsPipeline(fixtureInput) and checks output. Production code wraps the same call with a DB read on the input side and a response serialization on the output side.

Tradeoffs

StrengthsWeaknesses
End-to-end testable with no mocking; one call, deterministic outputCaller must assemble a potentially large input object
Each stage is independently testable + replaceablePipeline shape is rigid; runtime dynamic stage selection needs a different pattern
No surprise I/O; pipeline is reasoning, not orchestrationHeavy inputs may cost memory; streaming pipelines need a different shape
Easy to add a new stage between two existing onesRefactoring stage signatures cascades to the orchestrator

Citations

  • packages/engine/src/pipeline/runStrategyEngine.ts — the orchestrator; ~70 lines, pure, no I/O, calls 9 stage functions.
  • packages/engine/src/__tests__/runStrategyEngine.test.ts — test that builds a fixture input and asserts on the output shape; no mocking required.
  • packages/engine/src/actions/, position-balance/, waivers/, summary/, market/, behavior/, opportunity/, risk/, strategy-mode/ — one directory per pipeline stage, each containing pure functions.

P07. Surplus/Deficit Classification with Bucketed Display Labels

Problem

An analytics surface that displays "your team has +3.4 at this position" reads as noise. The reader has to mentally translate "+3.4" into "is that good?" Bucketed states — strong surplus, surplus, balanced, deficit, strong deficit — read as action. But the buckets have to be derived from the same numbers the analytics produced, not editorialized after the fact.

The Pattern

// 1. A typed enum for the bucketed states.
//    The state space is closed; the UI knows every possible value.
export type BalanceState =
  | "strong_surplus" | "surplus" | "balanced" | "deficit" | "strong_deficit";

// 2. Thresholds are constants at the top of the file.
//    Easy to find, easy to tune, single source of truth.
const SURPLUS_STRONG = 8;
const SURPLUS_MODERATE = 2;
const DEFICIT_MODERATE = -2;
const DEFICIT_STRONG = -8;

// 3. The classifier is a pure function.
export function classifyBalance(surplusDeficit: number): BalanceState {
  if (surplusDeficit >= SURPLUS_STRONG) return "strong_surplus";
  if (surplusDeficit > SURPLUS_MODERATE) return "surplus";
  if (surplusDeficit >= DEFICIT_MODERATE) return "balanced";
  if (surplusDeficit > DEFICIT_STRONG) return "deficit";
  return "strong_deficit";
}

// 4. A separate function formats the display label.
//    Separating numeric classification from display lets the UI override
//    or localize without re-classifying.
function formatDisplayLabel(surplusDeficit: number): string {
  if (surplusDeficit >= 0) return `Surplus +${surplusDeficit.toFixed(1)}`;
  return `Deficit ${surplusDeficit.toFixed(1)}`;
}

// 5. The visual builder composes both.
export type BalanceVisual = {
  numericValue: number;
  state: BalanceState;
  displayLabel: string;
};

export function buildBalanceVisual(numericValue: number): BalanceVisual {
  return {
    numericValue,
    state: classifyBalance(numericValue),
    displayLabel: formatDisplayLabel(numericValue),
  };
}

Design decisions

  • Both the numeric value and the bucketed state are in the output. Consumers can show the bucket ("strong deficit") or the raw number ("-9.2") or both. The pattern doesn't choose for them.
  • Five buckets, not three or seven. Three is too coarse (every team looks "balanced"); seven is too many to act on. Five is the sweet spot for "what should I do?" — two action zones (surplus, deficit), two strong-action zones, one no-action zone.
  • Thresholds are at the top of the file as named constants, not magic numbers inline. SURPLUS_STRONG = 8 reads as a tunable parameter; > 8 reads as a forgotten threshold.
  • Inclusive vs exclusive bounds are intentional. >= SURPLUS_STRONG and > SURPLUS_MODERATE differentiate edge cases; tuning them shifts which bucket boundary values fall into. Defend the choices.
  • Display label is separate from state classification. A future i18n pass or design change can touch formatDisplayLabel without risking state-classification drift.
  • State enum is a closed type union, not strings. BalanceState = "strong_surplus" | ... forces switch-statements to handle every case (or use exhaustiveness checks).

Tradeoffs

StrengthsWeaknesses
Bucketed state reads as action; raw number still available for power usersThreshold tuning is per-domain — initial values are educated guesses
Pure functions; trivially testableFive fixed buckets don't fit every scoring domain
Display label is decoupled from classificationBucket-edge values feel arbitrary to users near the boundary
UI consumers handle every state via exhaustive switchAdding a sixth bucket later is a breaking change for consumers

Citations

  • packages/engine/src/balance-visual.tsclassifyBalanceState(), buildTeamBalanceVisual(), threshold constants.
  • packages/engine/src/types.tsBalanceState typed enum.
  • packages/engine/src/position-balance/buildTeamBalanceMatrix.ts — upstream producer of the numeric surplusDeficit value the classifier consumes.

P08. Queue Validation Before Submit — Catch Errors at the Boundary

Problem

A user composes a queue of items to submit — waiver claims, batch orders, bulk imports. Some items have intrinsic errors (missing required fields, invalid values); some items conflict with each other (two claims drop the same resource, two priorities collide); the queue as a whole has aggregate constraints (total cost vs budget). Submitting and discovering errors downstream is expensive — the upstream service may partially apply, charge fees, or return cryptic errors. Pre-validation catches these at the form boundary with structured, actionable feedback.

The Pattern

// 1. Structured issue codes — typed enums, not free-text strings.
export type ValidationSeverity = "error" | "warning";
export type ValidationCode =
  | "missing_required_field"
  | "invalid_value"
  | "same_item_dual_use"
  | "duplicate_priority"
  | "duplicate_resource_conflict"
  | "budget_exceeded"
  | "budget_exposure";

export type ValidationIssue = {
  code: ValidationCode;
  severity: ValidationSeverity;
  message: string;             // human-readable
  itemIds?: string[];           // which items are involved
  relatedResourceIds?: string[];// what resources are at stake
};

export type ItemValidation = { itemId: string; valid: boolean; issues: ValidationIssue[] };
export type QueueValidationSummary = {
  valid: boolean;
  itemValidations: ItemValidation[];
  queueIssues: ValidationIssue[];
  totalCost: number;
  maxPossibleCost: number;
  remainingBudget: number;
  budgetRiskLevel: "low" | "medium" | "high";
};

// 2. Two passes: per-item then cross-item.
export function validateQueue(input: {
  items: QueueItem[];
  currentState: { resources: Array<{ resourceId: string }> };
  remainingBudget: number;
}): QueueValidationSummary {
  const itemValidations: ItemValidation[] = [];
  const queueIssues: ValidationIssue[] = [];
  let totalCost = 0;

  // Pass 1 — per-item checks.
  for (const item of input.items) {
    const issues: ValidationIssue[] = [];
    totalCost += item.cost;

    if (!item.addResourceId?.trim()) {
      issues.push({
        code: "missing_required_field",
        severity: "error",
        message: "Item is missing a resource to add.",
        itemIds: [item.itemId],
      });
    }
    if (item.cost < 0 || !Number.isFinite(item.cost)) {
      issues.push({
        code: "invalid_value",
        severity: "error",
        message: "Item has an invalid cost.",
        itemIds: [item.itemId],
      });
    }
    if (item.addResourceId && item.dropResourceId === item.addResourceId) {
      issues.push({
        code: "same_item_dual_use",
        severity: "error",
        message: "An item cannot add and drop the same resource.",
        itemIds: [item.itemId],
        relatedResourceIds: [item.addResourceId],
      });
    }

    itemValidations.push({
      itemId: item.itemId,
      valid: !issues.some((i) => i.severity === "error"),
      issues,
    });
  }

  // Pass 2 — cross-item checks (duplicate drops, duplicate priorities, etc.).
  const dropMap = new Map<string, string[]>();
  for (const item of input.items) {
    if (!item.dropResourceId) continue;
    const list = dropMap.get(item.dropResourceId) ?? [];
    list.push(item.itemId);
    dropMap.set(item.dropResourceId, list);
  }
  for (const [resourceId, itemIds] of dropMap.entries()) {
    if (itemIds.length > 1) {
      queueIssues.push({
        code: "duplicate_resource_conflict",
        severity: "warning",
        message: "Multiple items depend on dropping the same resource.",
        itemIds,
        relatedResourceIds: [resourceId],
      });
    }
  }

  // Pass 3 — aggregate budget check (max-possible spend, accounting for conflicts).
  const uniqueDropItems = new Map<string, number>();
  for (const item of input.items) {
    if (!item.dropResourceId) continue;
    const existing = uniqueDropItems.get(item.dropResourceId);
    if (existing == null || item.cost > existing) {
      uniqueDropItems.set(item.dropResourceId, item.cost);
    }
  }
  const maxPossibleCost = [...uniqueDropItems.values()].reduce((s, v) => s + v, 0);

  if (maxPossibleCost > input.remainingBudget) {
    queueIssues.push({
      code: "budget_exceeded",
      severity: "error",
      message: "Your compatible item set could exceed your remaining budget.",
    });
  } else if (maxPossibleCost > input.remainingBudget * 0.8 && input.remainingBudget > 0) {
    queueIssues.push({
      code: "budget_exposure",
      severity: "warning",
      message: "Your compatible items could use most of your remaining budget.",
    });
  }

  const valid =
    itemValidations.every((c) => c.valid) &&
    !queueIssues.some((i) => i.severity === "error");

  return {
    valid,
    itemValidations,
    queueIssues,
    totalCost,
    maxPossibleCost,
    remainingBudget: input.remainingBudget,
    budgetRiskLevel:
      maxPossibleCost > input.remainingBudget ? "high"
        : maxPossibleCost > input.remainingBudget * 0.8 ? "medium"
        : "low",
  };
}

Design decisions

  • Issue codes are typed enums. "duplicate_resource_conflict" is a typed literal; UI can switch on it for special treatment. Free-text-only messages would lose this affordance.
  • Two severities, not five. error blocks submit; warning lets it through with user acknowledgment. Three or more severities become bikeshed bait without operational value.
  • Per-item and cross-item issues are separated. itemValidations[i].issues is what's wrong with item i; queueIssues is what's wrong with the queue as a whole. UI renders them differently.
  • Max-possible spend accounts for conflicts. When two items drop the same resource, only one can win — the higher cost. maxPossibleCost reflects this realistic ceiling, not the naive sum(items.cost).
  • valid is a derived field. valid === itemValidations.every(valid) && !queueIssues.some(error). Consumers check one bool to know whether to enable Submit; they have detailed issues if they want to render them.
  • Pure function, no side effects. Same inputs always produce the same output. Trivially testable; trivially used in form onChange for live feedback.

Tradeoffs

StrengthsWeaknesses
Submit-time errors caught at form boundary; no expensive downstream surprisesValidator must mirror upstream service's rules; drift is possible
Structured issue codes let UI render targeted hintsAdding a new validation rule = enum extension + UI handler
Multi-pass design separates "item is bad" from "items conflict"Cross-item passes are O(n²) in the worst case; large queues need batching
Aggregate constraint (max-possible) is honest, not naivemaxPossibleCost heuristic is conservative; some "errors" may be unreachable

Citations

  • packages/engine/src/waivers/validateWaiverClaimQueue.ts — full implementation; per-claim + cross-claim + aggregate-budget validation.
  • packages/engine/src/types.tsWaiverValidationCode enum, WaiverValidationIssue, WaiverClaimValidation, WaiverQueueValidationSummary types.

P09. Greedy Queue Optimizer with Constraint Tracking

Problem

Pick the best N items from M candidates, where each candidate has a score and a cost, subject to: (a) a total budget, (b) each candidate consumes a resource that can only be used once, (c) max number of selections. The full optimization problem (knapsack with side constraints) is NP-hard at scale; for small N, a greedy algorithm produces good-enough results in O(M log M) with explainable output.

The Pattern

// 1. Each candidate carries everything the optimizer needs.
export type Candidate = {
  itemId: string;
  itemName: string;
  targetScore: number;          // higher = better
  suggestedCost: number;
  recommendedSwaps: Array<{ swapId: string; swapName: string }>;
};

// 2. The result includes the picks + the constraints that bound them.
export type OptimizedPick = {
  pickId: string;
  addItemId: string;
  addItemName: string;
  swapItemId: string;
  swapItemName: string;
  cost: number;
  priority: number;
  scoreContribution: number;
  reasoning: string[];
};

export type OptimizationResult = {
  recommendedPicks: OptimizedPick[];
  projectedTotalCost: number;
  maxPossibleCost: number;
  remainingBudget: number;
  summary: string;
  warnings?: string[];
};

// 3. The greedy algorithm — sort, iterate, track used resources.
export function optimizeQueue(input: {
  candidates: Candidate[];
  remainingBudget: number;
  maxPicks?: number;
}): OptimizationResult {
  const maxPicks = input.maxPicks ?? 5;
  const recommendedPicks: OptimizedPick[] = [];
  const warnings: string[] = [];

  let runningCost = 0;
  const usedSwapIds = new Set<string>();
  const usedAddIds = new Set<string>();

  // Sort descending by score — best first.
  const sorted = [...input.candidates].sort(
    (a, b) => b.targetScore - a.targetScore,
  );

  for (const candidate of sorted) {
    if (recommendedPicks.length >= maxPicks) break;
    if (usedAddIds.has(candidate.itemId)) continue;
    if (candidate.recommendedSwaps.length === 0) continue;

    // Find a swap that hasn't been used by an earlier pick.
    const swap = candidate.recommendedSwaps.find((s) => !usedSwapIds.has(s.swapId));
    if (!swap) continue;

    // Budget check.
    if (runningCost + candidate.suggestedCost > input.remainingBudget) continue;

    const pick: OptimizedPick = {
      pickId: `pick-${recommendedPicks.length + 1}`,
      addItemId: candidate.itemId,
      addItemName: candidate.itemName,
      swapItemId: swap.swapId,
      swapItemName: swap.swapName,
      cost: candidate.suggestedCost,
      priority: recommendedPicks.length + 1,
      scoreContribution: candidate.targetScore,
      reasoning: [
        "High target score",
        "Compatible swap candidate available",
        "Budget constraint satisfied",
      ],
    };

    recommendedPicks.push(pick);
    runningCost += candidate.suggestedCost;
    usedSwapIds.add(swap.swapId);
    usedAddIds.add(candidate.itemId);
  }

  if (runningCost > input.remainingBudget * 0.8 && runningCost > 0) {
    warnings.push("Projected spend uses a large share of your remaining budget.");
  }

  return {
    recommendedPicks,
    projectedTotalCost: runningCost,
    maxPossibleCost: runningCost,
    remainingBudget: input.remainingBudget,
    summary:
      recommendedPicks.length > 0
        ? `Generated ${recommendedPicks.length} recommended picks.`
        : "No viable pick set generated under current constraints.",
    warnings: warnings.length > 0 ? warnings : undefined,
  };
}

Design decisions

  • Greedy by descending score. Take the best candidate first; track used resources; move on. O(M log M) for the sort, O(M) for the iteration. Good enough for queues under ~100 items; degrades to "merely reasonable" beyond.
  • Used-resource sets, not list scans. usedSwapIds.has(s.swapId) is O(1); scanning a list of picks is O(N) per check. Sets matter once maxPicks > 3.
  • First-fit swap, not best-fit. When a candidate has multiple recommended swaps, take the first not-yet-used one. Optimizing over swap selection too is exponential; the greedy first-fit gives consistent results.
  • Reasoning is hardcoded per pick. Three boilerplate reasons per pick; future work would derive them from the score breakdown (per P05) for more specificity.
  • Budget exhaustion is a continue, not a break. A high-cost candidate that doesn't fit might be skipped over for a lower-cost candidate that does. Maximizes pick count within budget rather than maximizing score with fewer picks.
  • maxPicks is a separate ceiling, defaults to 5. Some surfaces want one pick; some want a full queue. The pattern accommodates both.

Tradeoffs

StrengthsWeaknesses
Deterministic + fast; explainable outputGreedy is locally optimal, not globally; can miss combos a solver would find
Constraint tracking via Sets keeps the inner loop tightFirst-fit on swaps may not be globally optimal
Output includes both picks and the budget contextNo backtracking — once a pick is made, it stays
Easy to test (pure function, fixture in / fixture out)Adding new constraints (e.g., quotas per category) requires careful state-tracking additions

Citations

  • packages/engine/src/waivers/optimizeClaimQueue.ts — full implementation, ~90 lines.
  • packages/engine/src/waivers/rankWaiverTargets.ts — upstream that produces the scored candidates this optimizer consumes.
  • packages/engine/src/types.tsClaimCandidate, OptimizedClaim, ClaimQueueOptimizationResult types.

P10. Heuristic Scoring with Per-Reason Weight Attribution

Problem

Heuristic recommendations (suggested bid amount, ranking score, confidence level) are easy to compute but easy to mistrust. A recommendation that says "bid $14" with no explanation is hard to argue with — and hard to act on. A recommendation that says "bid $14 because: +6 for positional need, +4 for high market pressure, +3 for strong opportunity window, -1 for low playoff value" is reviewable. The weight attribution has to come from the same computation that produced the recommendation.

The Pattern

// 1. Reasons are typed; each carries a label, an explanation, and a weight (which can be negative).
export type ReasonType =
  | "primary_need"
  | "market_pressure"
  | "opportunity_window"
  | "long_term_value"
  | "behavioral_adjustment"
  | "budget_context";

export type SuggestionReason = {
  type: ReasonType;
  label: string;
  explanation: string;
  weight: number;          // contribution to the final value; negative reduces
};

export type Suggestion = {
  itemId: string;
  suggestedValue: number;
  suggestedRange: { min: number; max: number };
  budgetPercent: number;
  confidence: number;       // 0-100, derived from reason count + reliability
  aggressiveness: "low" | "medium" | "high";
  summary: string;
  reasoning: SuggestionReason[];
  warnings?: string[];
};

// 2. The function builds the suggestion by accumulating into both
//    the running value and the reasons array — every adjustment becomes both.
export function suggestValue(input: SuggestionInput): Suggestion {
  const reasons: SuggestionReason[] = [];
  let suggestedValue = input.baseValue;

  const needAdjust =
    input.needScore >= 8 ? 6 : input.needScore >= 4 ? 3 : input.needScore <= -4 ? -3 : 0;
  if (needAdjust !== 0) {
    reasons.push({
      type: "primary_need",
      label: "Primary Need",
      explanation:
        needAdjust > 0
          ? "Your portfolio shows meaningful need at this slot."
          : "Your portfolio is relatively strong at this slot.",
      weight: needAdjust,
    });
    suggestedValue += needAdjust;
  }

  const marketAdjust =
    input.marketLevel === "high" ? 4 : input.marketLevel === "medium" ? 1 : -1;
  reasons.push({
    type: "market_pressure",
    label: "Market Pressure",
    explanation:
      input.marketLevel === "high"
        ? "Market activity is likely to be aggressive."
        : input.marketLevel === "medium"
        ? "Market demand looks moderate."
        : "Market pressure looks relatively soft.",
    weight: marketAdjust,
  });
  suggestedValue += marketAdjust;

  // ... repeat for opportunity, long-term-value, behavior adjustments

  // 3. Confidence is derived from the *count* of reasons that fired.
  //    More signals = higher confidence (not strictly true, but a useful heuristic).
  const confidence =
    reasons.length >= 4 ? 82 : reasons.length === 3 ? 76 : reasons.length === 2 ? 68 : 60;

  // 4. Budget-context reason is always added last, with weight 0 — it's framing, not adjustment.
  const budgetPercent = input.remainingBudget > 0
    ? Math.round((suggestedValue / input.remainingBudget) * 100)
    : 0;
  reasons.push({
    type: "budget_context",
    label: "Budget Context",
    explanation: `This value would use about ${budgetPercent}% of your remaining budget.`,
    weight: 0,
  });

  // 5. Bound + finalize.
  suggestedValue = Math.max(0, Math.round(suggestedValue));
  return {
    itemId: input.itemId,
    suggestedValue,
    suggestedRange: { min: Math.max(0, suggestedValue - 3), max: suggestedValue + 4 },
    budgetPercent,
    confidence,
    aggressiveness: budgetPercent >= 25 ? "high" : budgetPercent >= 12 ? "medium" : "low",
    summary: `Suggested value: ${suggestedValue} (range ${suggestedValue - 3}–${suggestedValue + 4}).`,
    reasoning: reasons,
  };
}

Design decisions

  • Every adjustment writes to both the running value and the reasons array. No "compute first, narrate later" — narration would drift. The two are produced together.
  • Weights can be negative. A reason can reduce the suggested value. "Your portfolio is strong here" → -3. Forcing all weights positive would force the narrator to lie about the math.
  • Reasons can have weight 0 (context-only). The final "Budget Context" reason explains framing without changing the number. UI can render it as a footer note.
  • Confidence is derived from reason count, not asserted. Four signals firing means more information went into the suggestion; reflected as higher confidence. Crude but honest.
  • Suggested range is computed deterministically from the suggested value. No fudging — [v-3, v+4] is the operational range users should consider. Range width could be domain-specific.
  • Final value is bounded and rounded. Math.max(0, Math.round(v)) keeps the output user-facing-clean. Negative values would be nonsensical; non-integer cents would be noise.
  • Aggressiveness bucket is derived from budget percentage, not raw value. Spending $14 means different things on a $30 budget vs. a $100 budget; the bucket reflects portfolio context.

Tradeoffs

StrengthsWeaknesses
Every suggestion is reviewable — value + range + reasons + confidence in one payloadHeuristic; not optimal, not learned
Negative weights mean reasons are honest, not just promotionalReason-count confidence is a proxy, not a true probability
Easy to extend (add a new reason type + adjustment)Adjustment magnitudes are domain-tuned; need calibration per product
Pure function — testable, fixture-friendlyDrifts from optimum as input distributions shift; needs periodic re-tuning

Citations

  • packages/engine/src/waivers/suggestWaiverBid.ts — full implementation; six reason types, each with conditional adjustment.
  • packages/engine/src/types.tsWaiverBidReason, WaiverBidSuggestion types.
  • packages/engine/src/waivers/rankWaiverTargets.ts — related pattern: ranking with structured target reasons.

P11. BFF Proxy with Same-Origin Toggle

Problem

A browser-facing app needs to call a service that has secrets (API keys), CORS quirks (cross-origin restrictions), or schema-drift risk (the upstream's shape isn't stable). Calling the service directly from the browser leaks secrets, fights CORS, and tightly couples the client to upstream shape changes. A BFF (Backend-For-Frontend) proxy fronts the service: the browser calls same-origin routes; the server forwards to the real service, optionally normalizing.

The Pattern

// 1. The client takes a config: same-origin proxy or direct?
export interface ClientConfig {
  useAppProxy?: boolean;        // route through Next.js BFF (default)
  baseUrl?: string;             // direct base URL (for tests, server-side calls)
}

// 2. Generic fetch function that respects the toggle.
async function apiFetch<T>(
  path: string,
  params: Record<string, unknown> = {},
  config: ClientConfig = {},
): Promise<T> {
  // useAppProxy=true → base="" → calls /api/... on same origin.
  // useAppProxy=false → base=baseUrl → calls upstream directly.
  const base = config.useAppProxy ? "" : (config.baseUrl ?? "");
  const query = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null) query.set(k, String(v));
  }
  const qs = query.toString();
  const url = `${base}${path}${qs ? `?${qs}` : ""}`;
  const res = await fetch(url, { cache: "no-store" });
  if (!res.ok) throw new Error(`API error: ${res.status} ${url}`);
  return res.json() as Promise<T>;
}

// 3. Typed wrappers per endpoint — one function per upstream path.
export function getRankings(category: string, params = {}, config = {}) {
  return apiFetch(`/api/analytics/rankings/${category}`, params, config);
}

// 4. Server-side BFF route forwards + adds auth + normalizes errors.
// app/api/analytics/rankings/[category]/route.ts
export async function GET(req: NextRequest, ctx: { params: { category: string } }) {
  const url = new URL(req.url);
  const upstream = `${process.env.UPSTREAM_API_URL}/v1/rankings/${ctx.params.category}${url.search}`;
  const headers: HeadersInit = { Accept: "application/json" };
  const key = process.env.UPSTREAM_API_KEY?.trim();
  if (key) headers["X-API-Key"] = key;     // secret stays server-side

  const res = await fetch(upstream, { headers });
  if (!res.ok) {
    return NextResponse.json(
      { error: "upstream_error", status: res.status, path: upstream },
      { status: res.status },
    );
  }
  const data = await res.json();
  // Optional: normalize / re-shape here before returning to browser.
  return NextResponse.json(data);
}

Design decisions

  • useAppProxy is the default for browser code; baseUrl for server / test code. Same client library used in three contexts: browser (proxy), Next.js route handlers (direct), Vitest tests (mock or test base URL).
  • Secrets only on the server. UPSTREAM_API_KEY is a server env var, never exposed to the browser bundle. The BFF route adds the header; the browser never sees it.
  • The BFF route mirrors the upstream path 1:1 where possible. /api/analytics/rankings/[category] proxies /v1/rankings/{category}. Easy to grep: "is this proxied?" → look for the matching route file.
  • Schema normalization is optional. Most routes pass through; some normalize (rename fields, drop nulls, add envelope). Normalization at the BFF is the right level — every browser caller benefits without each one re-implementing.
  • Errors are translated, not thrown. Upstream 5xx becomes { error, status, path } JSON the client can render. The client doesn't get a generic "fetch failed."
  • cache: "no-store" by default on the browser side. Analytics data is point-in-time; stale data is worse than slow data. Caller can override for stable endpoints.

Tradeoffs

StrengthsWeaknesses
Browser never sees upstream secrets or URLEvery endpoint needs a BFF route; not free
CORS becomes a non-issue (all calls are same-origin)One extra hop adds latency; ~10-50ms typical
Schema drift can be absorbed at the BFFBFF becomes a maintenance surface — must stay in sync with upstream changes
Same client library works in browser, server, and testsMocking strategy must account for the proxy toggle

Citations

  • packages/analytics-client/src/index.tsapiFetch() with useAppProxy toggle; one typed function per upstream endpoint.
  • apps/web/app/api/analytics/rankings/[category]/route.ts — BFF route that proxies the Python analytics service, adds X-API-Key, normalizes response shape.
  • apps/web/app/api/analytics/decisions/start-sit/route.ts and ~20 sibling routes — same pattern across the analytics surface.

P12. Polyglot Two-Runtime Architecture (TS App + Python Analytics)

Problem

A product has two distinct runtime needs: (a) a user-facing app with strong TypeScript ergonomics for component composition, type safety across the API boundary, and Next.js's server-component model; (b) an analytical service that wants Python's scientific stack (NumPy, scikit-learn, statsmodels, SQLAlchemy with async drivers, pandas for data ops). Forcing one language to do both means either fighting Python's web ecosystem from TS or fighting JS's numerical ecosystem from Python. Splitting them is the lesser pain — but only if the seam is disciplined.

The Pattern

repo/
├── apps/web/                    # Next.js + TypeScript
│   ├── app/                     # routes, server components
│   ├── components/              # UI
│   ├── lib/                     # data access, BFF clients
│   └── app/api/analytics/       # BFF proxy routes (see P11)
│
├── analytics-api/               # FastAPI + Python
│   ├── api/
│   │   ├── main.py              # app factory, middleware, routers, lifespan
│   │   ├── cache.py             # Redis client
│   │   └── routers/             # one router per capability
│   ├── core/                    # pure analytical code (no I/O)
│   ├── data/                    # SQLAlchemy models, adapters
│   ├── tests/
│   └── config.py
│
├── packages/                    # shared TS packages (monorepo)
│   ├── types/                   # canonical domain types
│   ├── adapters/                # platform adapters
│   └── analytics-client/        # typed client for analytics-api
│
└── docs/
    ├── ARCHITECTURE.md
    └── FANTASY_ANALYTICS_PROVIDER_CONTRACT.md  # the seam contract
// The TS side: a typed provider interface in the shared package.
// Implementations: HTTP (production), mock (tests + dev), in-app (when migration is needed).
export interface AnalyticsProvider {
  getBulkProjections(input: { ids: string[]; period: number }): Promise<Envelope[]>;
  getStartSit(input: { ids: string[]; period: number }): Promise<Ranking>;
  getRiskProfile(input: { id: string; period: number }): Promise<RiskProfile>;
  // ...
}

export class HttpAnalyticsProvider implements AnalyticsProvider {
  constructor(private readonly options: { baseUrl: string; apiKey?: string }) {}

  async getBulkProjections(input: { ids: string[]; period: number }) {
    return this.post<Envelope[]>("/v1/players/bulk/enrich", input);
  }

  private async post<T>(path: string, body: object): Promise<T> {
    const res = await fetch(`${this.options.baseUrl}${path}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...(this.options.apiKey ? { "X-API-Key": this.options.apiKey } : {}),
      },
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`Analytics API ${res.status}`);
    return res.json() as Promise<T>;
  }
}
# The Python side: a FastAPI app with structured logging,
# Redis caching, an adapter registry, and per-capability routers.

import contextlib, os, structlog
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

from api.cache import get_redis, close_redis
from data.storage.database import AsyncSessionLocal, create_all_tables
from config import get_settings
from core.scheduler import start_scheduler, stop_scheduler

settings = get_settings()
logger = structlog.get_logger()


@contextlib.asynccontextmanager
async def app_lifespan(app: FastAPI):
    if settings.is_development:
        await create_all_tables()
    await start_scheduler()
    r = await get_redis()
    if r:
        logger.info("Redis cache connected")
    yield
    await stop_scheduler()
    await close_redis()


app = FastAPI(lifespan=app_lifespan)
app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True)

# Per-capability routers.
from api.routers import projections, risk, decisions, rankings
app.include_router(projections.router, prefix="/v1", tags=["projections"])
app.include_router(risk.router, prefix="/v1", tags=["risk"])
app.include_router(decisions.router, prefix="/v1", tags=["decisions"])
app.include_router(rankings.router, prefix="/v1", tags=["rankings"])

Design decisions

  • One language per runtime, picked for its native strengths. Don't bring pandas into Node, don't bring React into Python. The seam is a network boundary, not a language-of-the-month exercise.
  • The seam is a typed contract, versioned and documented. The TS client validates responses against the contract; drift becomes a runtime error, not a silent bug.
  • The TS side owns the user-facing model; the Python side owns the analytical model. They overlap at the boundary types but evolve independently.
  • The Python service is stateless behind a cache. Redis fronts the heavy computations; the service itself is horizontally scalable.
  • Both services are deployed independently. TS to Vercel, Python to Railway / Fly / GCP. No shared deployment pipeline; loosely coupled.
  • The BFF (per P11) hides the polyglot split from the browser. Browser thinks it's calling /api/analytics/rankings/QB; Next.js routes it to Python at https://analytics-api.example.com/v1/rankings/QB. Same-origin to the user, two services to ops.
  • Schema validation on both sides. Pydantic on Python; Zod / TypeScript types on TS. The contract is enforced twice; drift is caught at both ends.
  • One contract doc, mirrored in both repos. Source of truth lives in the consumer (TS side, since the consumer feels drift first); Python implements to spec.

Tradeoffs

StrengthsWeaknesses
Each language plays to its native strengthsTwo deploy pipelines, two CI configs, two dependency systems
Analytical service can scale independently of the UI appNetwork boundary adds latency to every call
Type contracts at the seam catch drift before it shipsContract evolution requires coordinated PRs across two repos
Polyglot reality (Python for ML, TS for UI) instead of forcing one stackMore moving parts; bigger operational surface
Easy to swap or A/B the Python service without touching the UILocal dev requires running two processes (or proxy-to-prod)

Citations

  • analytics-api/api/main.py — FastAPI bootstrap with lifespan, structured logging, Redis cache wiring, per-capability routers.
  • analytics-api/api/cache.py, analytics-api/core/scheduler.py — service infrastructure.
  • packages/types/src/fantasy-analytics-provider.ts — the TS-side contract (FantasyAnalyticsProvider interface, ProjectionEnvelope type).
  • packages/fantasy-analytics-provider-http/src/index.tsHttpAnalyticsProvider class that consumes the Python service over HTTP.
  • apps/web/lib/analytics-provider.ts — factory that selects mock vs. HTTP-to-Python based on environment.
  • docs/FANTASY_ANALYTICS_PROVIDER_CONTRACT.md (canonical) — the seam contract; both sides reference it.

P13. Environment-Switched Factory Bound to Process Boot

CROSS — convergent with Performix #2 (Three-way adapter factory with env switch) and DevPlane P09 (Runtime Provider Registry). Fourth & Two's variant adds a third dimension: provider source (mock / console-backend / direct-analytics-api / custom-url) on top of the mock/real toggle, with sensible defaults per source.

Problem

A service implementation should be picked once per process boot based on environment, not threaded through call sites as a parameter. Tests want a deterministic mock; local dev wants a local HTTP server; staging wants the staging-deployed service; production wants the production service. The selection logic should live in one place, run once, and cache the result.

The Pattern

// 1. The factory reads from env, validates, and returns the implementation.
//    Module-level cache means the choice is made once per process.
import type { AnalyticsProvider } from "./types";
import { createMockProvider } from "./providers/mock";
import { createHttpProvider } from "./providers/http";

let cached: AnalyticsProvider | null = null;

export function createAnalyticsProvider(options?: { sandbox?: boolean }): AnalyticsProvider {
  // Sandbox call sites can force a specific source for local dev.
  const useMock = process.env.NEXT_PUBLIC_USE_MOCK === "true";
  const envSource = useMock ? "mock" : (process.env.NEXT_PUBLIC_PROVIDER_SOURCE || "primary");
  const source = options?.sandbox ? (useMock ? "mock" : "console") : envSource;
  const s = source.toLowerCase();

  if (s === "mock") return createMockProvider();

  if (s === "console") {
    const baseUrl = process.env.NEXT_PUBLIC_API_URL || DEFAULT_CONSOLE_URL;
    return createHttpProvider({
      baseUrl,
      endpoint: "/api/enrich",
      useConsoleBackend: true,
      idSource: "vendor-id",
    });
  }

  if (s === "custom") {
    const baseUrl = process.env.NEXT_PUBLIC_CUSTOM_URL;
    if (!baseUrl) {
      console.warn("[Factory] source=custom but NEXT_PUBLIC_CUSTOM_URL not set; falling back to mock");
      return createMockProvider();
    }
    return createHttpProvider({
      baseUrl,
      endpoint: "/v1/bulk/enrich",
      idSource: "canonical-id",
    });
  }

  // Default — primary HTTP provider.
  return createHttpProvider({
    baseUrl: process.env.NEXT_PUBLIC_API_URL || DEFAULT_PRIMARY_URL,
    endpoint: "/v1/bulk/enrich",
    idSource: "canonical-id",
  });
}

Design decisions

  • Multi-source selection, not just mock/real. Three real sources (primary, console-backend, custom-url) plus mock. Each source has its own URL, endpoint convention, and ID system.
  • Sandbox-mode call sites can override. A "demo this in sandbox mode" surface passes { sandbox: true } and gets console-backed real data without changing env vars. Useful for live demos.
  • Fallback to mock on misconfiguration, with a warning. source=custom but no URL set? Warn and fall back. Production paths fail loudly elsewhere; the factory's job is to be operational.
  • Per-source defaults for endpoint + ID convention. Each source has its own quirks (console uses /api/enrich + vendor-id; direct API uses /v1/bulk/enrich + canonical-id). The factory encodes these as defaults instead of forcing callers to know.
  • Read env once per call, cache externally if needed. This factory doesn't cache — cached is reserved for if call-site profiling shows env-read overhead. Module-level static caching is the typical extension.
  • The factory is the only place that knows about source names. Source-string parsing lives in one switch; call sites get a typed AnalyticsProvider. Adding a new source is a single-file change.

Tradeoffs

StrengthsWeaknesses
One factory call returns a typed provider; call sites don't care whichAdding a source = factory branch + new provider implementation
Sandbox-mode override lets demos run real-source data on demandMulti-source matrix can grow; needs a versioning convention
Per-source defaults for endpoint + ID system mean call sites don't need source-specific configDrift between source-specific defaults and reality must be caught at the contract layer
Misconfiguration fails gracefully (warn + fallback)"Fell back to mock" warnings can be missed if logs aren't watched

Citations

  • apps/web/lib/analytics-provider.ts — full factory, ~50 lines; four sources (mock / console / custom / primary).
  • packages/fantasy-analytics-provider-mock/src/index.ts — mock implementation.
  • packages/fantasy-analytics-provider-http/src/index.ts — HTTP implementation with two endpoint conventions (console-backend and direct-analytics-api).
  • packages/fantasy-analytics-provider-mfl/src/index.ts — vendor-specific MFL implementation.

Recipes — compose patterns into systems

These are the standing recipes that combine patterns above. Add new ones here as they crystallize.

Recipe: "Add a new analytical surface"

  1. Domain state (P02): if the surface needs new fields on the central entity, add them to the appropriate layer (don't create a parallel entity type).
  2. Provider contract (P12 seam, P13 factory): if the data needs new computation, extend the analytics-provider contract — both TS interface and Python router.
  3. BFF proxy (P11): add the Next.js route that forwards to the analytics service.
  4. Card meta (P04): if the surface is a card, add a cardMeta export naming its contexts + priority + segment requirements.
  5. Scoring + breakdown (P05) if a ranking is involved; use named components, never opaque scores.
  6. Bucketed states + display labels (P07) if the surface needs to render numeric values as actionable categories.

Recipe: "Consume a new third-party platform"

  1. Platform adapter (P01): implement the full PlatformAdapter port; don't skip methods even if you stub them.
  2. Mock adapter parity (P01): keep the mock adapter implementing every new method too — tests rely on it.
  3. Normalization at the adapter boundary: vendor-specific payloads translate to the domain types in packages/types; UI never sees vendor shapes.
  4. Environment-switched factory (P13): add the new adapter to the factory; pick by env var.

Recipe: "Add a new pre-submit form"

  1. Queue validation (P08): structured issue codes, two passes (per-item + cross-item), aggregate constraints last.
  2. Heuristic suggestion (P10) for any defaults the form auto-fills; carry reasons in the payload.
  3. Queue optimization (P09) for batch surfaces that suggest a multi-item set.

This file is the source-graded record of engineering patterns that have hardened in production code. New patterns join the list by crystallizing in actual code first, then getting written up here. Speculative patterns belong in docs/build-out/, not in this file.