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.
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 implementationspackages/types/— layered domain-state model + analytics-provider contractpackages/engine/src/pipeline/,actions/,position-balance/,waivers/— pure analytics pipelinepackages/ui/src/cards/andpackages/ui/src/lib/— composable card framework with context-filtered relevancepackages/analytics-client/andapps/web/app/api/analytics/*— BFF proxy bridging Next.js and the Python analytics servicepackages/fantasy-analytics-provider-{http,mock,mfl}— pluggable enrichment provider
Out of scope at v1.0 (revisit in v1.1):
- The
strategy-leaguesimulation 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 Name | Core Technology | Problem Solved |
|---|---|---|---|
| P01 | Provider-Agnostic Platform Adapter with Multiple Implementations | TypeScript port + adapter classes | Same workflow surface needs to consume multiple third-party platforms (MFL, Sleeper, mock) without leaking provider quirks into UI. CROSS |
| P02 | Layered Domain-State Model (Identity/Context/Value/Risk/Opportunity) | TypeScript composed types | One canonical entity-state object that supports projections, risk, decisions, market, and strategy — without becoming a 200-field god-object. |
| P03 | Cross-Portfolio Framework Adaptation (CAMS Reuse) | Domain-neutral scoring schema | A behavioral-science framework built for people analytics turns out to fit football roles. Name the abstraction so the reuse is auditable. CROSS |
| P04 | Composable Insight Card with Context-Filtered Relevance Engine | React + meta registry + pure selector | A library of analytical card components must surface only the cards relevant to where the user is right now, sorted by priority. CROSS |
| P05 | Multi-Component Score Breakdown for Explainable Ranking | Pure TS scoring + structured breakdown | A ranking surface that returns a single score is unreviewable; reviewers need to see which components contributed. |
| P06 | Pure-Function Analytics Pipeline Orchestrator | Top-level pure orchestrator over module-level pure functions | A multi-stage analytics pipeline (mode detection → balance → market → ranking → summary) needs to be testable end-to-end without I/O. |
| P07 | Surplus/Deficit Classification with Bucketed Display Labels | Pure function + threshold table | Numeric position-balance scores read as noise; bucketed states ("strong_surplus" / "balanced" / "strong_deficit") read as action. |
| P08 | Queue Validation Before Submit — Catch Errors at the Boundary | Per-item + cross-item validators with structured issue codes | Multi-item submission queues (waiver claims, batch orders) fail in expensive ways downstream when not validated up front. |
| P09 | Greedy Queue Optimizer with Constraint Tracking | Sorted candidate iteration + used-resource sets | Pick the best N items from M candidates subject to a budget and uniqueness constraints, in a deterministic, explainable way. |
| P10 | Heuristic Scoring with Per-Reason Weight Attribution | Pure function returning value + reason list | Heuristic recommendations (bid amounts, priority scores) need to carry the why in the same payload so the UI doesn't need a second round-trip. |
| P11 | BFF Proxy with Same-Origin Toggle | Next.js API route fronting an external service | A 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. |
| P12 | Polyglot Two-Runtime Architecture (TS App + Python Analytics) | Two services, two languages, one typed contract | The UI runtime wants TypeScript ergonomics; the math runtime wants Python's scientific stack. Keep them separate; bridge with a typed contract. |
| P13 | Environment-Switched Factory Bound to Process Boot | Module-level dispatcher with cached choice | A 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 anAuthSessionobject the adapter consumes on subsequent calls. Adapters with different credential models (cookie vs. token vs. no-auth) all hide that detail behind the sameAuthSessionopaque 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,Transactionare 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
| Strengths | Weaknesses |
|---|---|
| UI is fully provider-agnostic; swap by changing one factory call | Wide port = adding a new vendor means implementing N methods, not 1 |
| Mock adapter doubles as demo / Storybook / test fixture | Optional-field-heavy types lose some compile-time enforcement |
| Adding a new vendor doesn't touch UI code | Vendor-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 AuthSession | Cross-cutting features (rate limiting, retry) need per-adapter implementation or a wrapper layer |
Citations
packages/adapters/src/contract.ts—FantasyPlatformAdapterinterface (≈15 methods covering the full GM workflow surface).packages/adapters/src/mock-adapter.ts—createMockAdapter()— deterministic in-memory implementation; first-class.packages/adapters/src/mfl-adapter.tsandsleeper-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?andmetadata?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.marketwithout touchingentity.value; a projection refresh writes toentity.valuewithout touchingentity.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
| Strengths | Weaknesses |
|---|---|
| Surfaces can ignore layers they don't need; type stays small in any given consumer | Joining 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 on | More verbose at construction time than a flat object |
Easy to extend (add a dynasty or legacy layer) without touching other layers | Consumers must handle missing optional layers explicitly |
Citations
packages/types/src/fantasy-player-state.ts—FantasyPlayerStatewith all seven layers (identity, context, value, risk, opportunity, market, metadata).packages/types/src/fantasy-analytics-provider.ts— theProjectionEnvelopeshape (a single-layer reflection of the value layer for the provider boundary).packages/engine/src/monte-carlo/simulator.ts— consumer that readsplayer.value.window,player.risk.weeklyPlayProbability,player.opportunity.trend?.directionindependently 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_ACTIVATEDbucket 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
| Strengths | Weaknesses |
|---|---|
| Framework lineage is preserved across domains; provenance is auditable | Cross-domain reuse only works if the schema is genuinely domain-neutral; forced fits break |
| Shared tier mapping = shared mental model across products | If the original framework evolves (new dimension added), all adopters must coordinate |
| UI cards are framework-aware but domain-agnostic — one card, many products | Domain 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-invention | Adopting a framework you don't fully understand risks looking like reuse but acting like cargo-culting |
Citations
packages/types/src/fantasy-analytics-provider.ts—CamsTierenum and thecamsblock onProjectionEnvelope.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.ts—camsToTier()function; shared tier mapping.- Originally extracted from CAMS in
people-analytics-toolbox(canonical lineage in Performix'scams-diagnosticcapability per PerformixREUSABLE_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
cardMetais 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)returnsCardMeta[]. Trivially unit-testable. The page wraps the meta with React rendering separately. requiresSegmentis 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.priorityis per-card global, not per-context. A card that's priority-1 indecision_surfaceis also priority-1 inentity_detail. Lets the sort logic stay simple; per-context priority is an explicit "expand if needed" path, not the default.sizesandthemeare presentation concerns;contexts,priority,requiresSegment,keywordsare relevance concerns. The split keeps the selector from caring about layout.- The same card meta drives the library / search surface.
keywordsand full metadata mean the library page can render the same list as the page-specific selector, just unfiltered by context.
Tradeoffs
| Strengths | Weaknesses |
|---|---|
| New cards "just appear" in relevant contexts via metadata, no per-page edits | Metadata-driven means a card with wrong metadata silently goes missing |
| The relevance engine is one pure function; trivially testable | The metadata schema becomes a versioning surface — adding fields is easy, removing is hard |
| Same cards work across page contexts; no duplication | Per-context customization (different priority on different pages) needs an extension, not in the v1 |
| Library / search surface uses the same registry | Hand-maintained registry imports can drift if a new card is added without updating |
Citations
packages/ui/src/lib/card-meta.ts—CardMetashape +CardContextenum (15-context taxonomy).packages/ui/src/lib/card-registry.ts— hand-maintainedALL_CARD_METAarray; 15 card types registered.packages/ui/src/lib/card-selector.ts— pureselectCardsForContext()function.packages/ui/src/cards/light/cams-alignment-card.tsx,prism-trend-card.tsx, et al. — co-locatedcardMetaexports.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. scoreCandidateandbuildScoringBreakdownshare 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_CONFIGarray 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.
notesis for free-form context about the scoring run (e.g., "Heuristic-first v1 scoring model"). Not per-component; for the whole breakdown.
Tradeoffs
| Strengths | Weaknesses |
|---|---|
| Every score is auditable; no orphan recommendations | Scoring formula must be a sum/weighted-sum of named components; nonlinear scoring needs adaptation |
| Score and breakdown can't drift — same inputs, same components | More verbose than returning a single number |
| UI can show or hide the breakdown without a backend change | Component count is fixed in code; per-run dynamic components need a different shape |
| Explanations are pluggable per component | Multi-step scoring (score the components, then transform) needs a separate pattern |
Citations
packages/engine/src/actions/scoreAction.ts—scoreAction()— five-component sum.packages/engine/src/actions/buildScoringBreakdown.ts—buildScoringBreakdown()— same five components, labeled.packages/engine/src/actions/rankStrategyActions.ts— call site; every ranked action carries bothscoreandscoring(breakdown).packages/engine/src/waivers/suggestWaiverBid.ts— variant of the same pattern:reasons: WaiverBidReason[]array withtype / label / explanation / weightfor 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
PipelineInputby reading from the DB / API / cache; the orchestrator never reads. This makes the orchestrator deterministic given input. - Each stage takes only what it needs.
rankActionstakes 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
summaryor drill intomarketState. 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
| Strengths | Weaknesses |
|---|---|
| End-to-end testable with no mocking; one call, deterministic output | Caller must assemble a potentially large input object |
| Each stage is independently testable + replaceable | Pipeline shape is rigid; runtime dynamic stage selection needs a different pattern |
| No surprise I/O; pipeline is reasoning, not orchestration | Heavy inputs may cost memory; streaming pipelines need a different shape |
| Easy to add a new stage between two existing ones | Refactoring 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 = 8reads as a tunable parameter;> 8reads as a forgotten threshold. - Inclusive vs exclusive bounds are intentional.
>= SURPLUS_STRONGand> SURPLUS_MODERATEdifferentiate 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
formatDisplayLabelwithout 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
| Strengths | Weaknesses |
|---|---|
| Bucketed state reads as action; raw number still available for power users | Threshold tuning is per-domain — initial values are educated guesses |
| Pure functions; trivially testable | Five fixed buckets don't fit every scoring domain |
| Display label is decoupled from classification | Bucket-edge values feel arbitrary to users near the boundary |
| UI consumers handle every state via exhaustive switch | Adding a sixth bucket later is a breaking change for consumers |
Citations
packages/engine/src/balance-visual.ts—classifyBalanceState(),buildTeamBalanceVisual(), threshold constants.packages/engine/src/types.ts—BalanceStatetyped enum.packages/engine/src/position-balance/buildTeamBalanceMatrix.ts— upstream producer of the numericsurplusDeficitvalue 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.
errorblocks submit;warninglets 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].issuesis what's wrong with itemi;queueIssuesis 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.
maxPossibleCostreflects this realistic ceiling, not the naivesum(items.cost). validis 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
onChangefor live feedback.
Tradeoffs
| Strengths | Weaknesses |
|---|---|
| Submit-time errors caught at form boundary; no expensive downstream surprises | Validator must mirror upstream service's rules; drift is possible |
| Structured issue codes let UI render targeted hints | Adding 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 naive | maxPossibleCost 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.ts—WaiverValidationCodeenum,WaiverValidationIssue,WaiverClaimValidation,WaiverQueueValidationSummarytypes.
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 oncemaxPicks > 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. maxPicksis a separate ceiling, defaults to 5. Some surfaces want one pick; some want a full queue. The pattern accommodates both.
Tradeoffs
| Strengths | Weaknesses |
|---|---|
| Deterministic + fast; explainable output | Greedy is locally optimal, not globally; can miss combos a solver would find |
| Constraint tracking via Sets keeps the inner loop tight | First-fit on swaps may not be globally optimal |
| Output includes both picks and the budget context | No 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.ts—ClaimCandidate,OptimizedClaim,ClaimQueueOptimizationResulttypes.
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
| Strengths | Weaknesses |
|---|---|
| Every suggestion is reviewable — value + range + reasons + confidence in one payload | Heuristic; not optimal, not learned |
| Negative weights mean reasons are honest, not just promotional | Reason-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-friendly | Drifts 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.ts—WaiverBidReason,WaiverBidSuggestiontypes.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
useAppProxyis the default for browser code;baseUrlfor 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_KEYis 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
| Strengths | Weaknesses |
|---|---|
| Browser never sees upstream secrets or URL | Every 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 BFF | BFF becomes a maintenance surface — must stay in sync with upstream changes |
| Same client library works in browser, server, and tests | Mocking strategy must account for the proxy toggle |
Citations
packages/analytics-client/src/index.ts—apiFetch()withuseAppProxytoggle; one typed function per upstream endpoint.apps/web/app/api/analytics/rankings/[category]/route.ts— BFF route that proxies the Python analytics service, addsX-API-Key, normalizes response shape.apps/web/app/api/analytics/decisions/start-sit/route.tsand ~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
pandasinto 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 athttps://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
| Strengths | Weaknesses |
|---|---|
| Each language plays to its native strengths | Two deploy pipelines, two CI configs, two dependency systems |
| Analytical service can scale independently of the UI app | Network boundary adds latency to every call |
| Type contracts at the seam catch drift before it ships | Contract evolution requires coordinated PRs across two repos |
| Polyglot reality (Python for ML, TS for UI) instead of forcing one stack | More moving parts; bigger operational surface |
| Easy to swap or A/B the Python service without touching the UI | Local 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 (FantasyAnalyticsProviderinterface,ProjectionEnvelopetype).packages/fantasy-analytics-provider-http/src/index.ts—HttpAnalyticsProviderclass 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=custombut 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 —
cachedis 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
| Strengths | Weaknesses |
|---|---|
| One factory call returns a typed provider; call sites don't care which | Adding a source = factory branch + new provider implementation |
| Sandbox-mode override lets demos run real-source data on demand | Multi-source matrix can grow; needs a versioning convention |
| Per-source defaults for endpoint + ID system mean call sites don't need source-specific config | Drift 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"
- 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).
- Provider contract (P12 seam, P13 factory): if the data needs new computation, extend the analytics-provider contract — both TS interface and Python router.
- BFF proxy (P11): add the Next.js route that forwards to the analytics service.
- Card meta (P04): if the surface is a card, add a
cardMetaexport naming its contexts + priority + segment requirements. - Scoring + breakdown (P05) if a ranking is involved; use named components, never opaque scores.
- Bucketed states + display labels (P07) if the surface needs to render numeric values as actionable categories.
Recipe: "Consume a new third-party platform"
- Platform adapter (P01): implement the full
PlatformAdapterport; don't skip methods even if you stub them. - Mock adapter parity (P01): keep the mock adapter implementing every new method too — tests rely on it.
- Normalization at the adapter boundary: vendor-specific payloads translate to the domain types in
packages/types; UI never sees vendor shapes. - Environment-switched factory (P13): add the new adapter to the factory; pick by env var.
Recipe: "Add a new pre-submit form"
- Queue validation (P08): structured issue codes, two passes (per-item + cross-item), aggregate constraints last.
- Heuristic suggestion (P10) for any defaults the form auto-fills; carry reasons in the payload.
- 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.