Outcome Context — query-layer convergence gap analysis¶
Date: 2026-05-04 (original); last updated: 2026-05-05 (status banner).
Status: Implemented. All four decisions (D1–D4) and the AmericanFootball catalogue migration shipped on branch LBS-1183 (PR #1389). See the build plan's implementation status for the per-decision summary. The body below is preserved as the snapshot of pre-implementation thinking; treat it as historical context, not the current truth.
Scope: point-in-time gap analysis between the GraphQL query-layer prototype and the Outcome Context storage experiment.
Outputs: four agreed convergence decisions (D1–D4), three open sub-designs, a list of cleanups required on each side.
This document is the source of record for what the two sides assumed about each other at the time of writing. It fed the convergence reference document and the code-convergence plan that followed.
Decisions were not one-way doors. The four resolutions were committed enough to start coding against, and each remained revisitable through integration. The principle held — TOTAL_TDS_GAME ended up needing three position-cohort templates rather than a single skill-player one (PASSING_TDS doesn't exist for non-QBs), and the catalogue retirement happened earlier than scoped because the templates covered the same surface 1:1.
Path note (post-rename, 2026-05-05). LBS.OutcomeContext.Contracts and LBS.OutcomeContext.Query live under src/OutcomeContext/. IOutcomeCatalogue and AmericanFootballOutcomeCatalogue have been retired - their entire ID surface materialises from AmericanFootballOutcomeTemplates × SimulationCatalog. References to those types below are historical only.
TL;DR¶
| Decision | Resolution | Effect |
|---|---|---|
D1 Run identity / worldSetRef |
Drop Rule 3. worldSetRef becomes informational metadata. Rule 2 (worldCount match) is the only structural alignment check. |
Removes CONTEXT_VERSION_MISMATCH from the error taxonomy; retires the two misalignment fixtures; storage doesn't grow a run_id column. |
| D2 Derived outcomes | Extend OutcomeDefinition with a Definition field. Storage form is RPN/postfix; authors write infix and a build step canonicalises. The field is a template with named placeholders (e.g. {participantId}). |
Storage's read-side C# derivations move into declarative catalog data. Prototype's ExpressionCanonical.ExpandDerived consumes templates directly. |
| D3 Catalog discovery | Templated catalog at the API surface, paired with a pre-simulation catalog of fixtures + players passed in at sim start. Concrete IDs are materialised by combining templates with that catalog. | Eliminates the prototype's static OutcomeCatalog. Adds a new Foundry concept (the pre-sim catalog) that needs structure, persistence, and a read API — open sub-design. |
| D4 Read API | Storage exposes a typed IOutcomeContextStore read interface, designed as part of the broader storage-layer rework from experiment to production. Method shape is driven by what ContextRepository actually consumes. |
Query layer never sees ClickHouse. Method surface deferred to design — listed at the end of this doc. |
What we're converging¶
| Side | Path | What it does today |
|---|---|---|
| Prototype | FoundryQueryProto.cs |
Single-file HotChocolate 14 GraphQL service. In-memory ContextRepository of NFL fixtures. Implements §5 (context-first surface), §10 (Wave 1 contract), partial §11.1 (discovery), §11.8 (raw-vs-derived two-tier). Five hard rules enforced. No real storage; rows are constructed in-memory in BuildFixtures. |
| Storage experiment | src/Models/AmericanFootball/LBS.Model.AmericanFootball.Accumulation/ (contracts) and LBS.Model.AmericanFootball.StorageExperiment/ (ClickHouse paths) |
Validated end-to-end at 100K worlds on ClickHouse Cloud Production 3×16 in 2h 58m. Three tables (game_outcome_context, season_outcome_context, play_by_play) populated by IOutcomeAccumulator + ISeasonAccumulator. No GraphQL surface. Read paths are ad-hoc (ClickHouseBackend.ReadGameOutcomeContextAsync) used only by experiments. |
These two halves are designed to dock — the prototype's CLAUDE.md says it is "aligned with LBS.Model.AmericanFootball.Accumulation contracts" and most of the type shapes match. The drift is in four specific places, listed in §"Decisions taken" below.
Vocabulary alignment¶
The two sides occasionally use different words for the same thing. When writing code, comments, schema fields, or this document, use the left column and avoid the right.
| Use | Synonyms / drift to avoid |
|---|---|
| Outcome Context | Matrix, outcome probability matrix |
OutcomeRow |
row, value array |
| Outcome | OutcomeDefinition (definition is the catalog entry; outcome is the named thing) |
| Raw outcome | measurement, stored outcome |
| Derived outcome | computed outcome, expression outcome |
| OutcomeExpression | bet expression |
| outcome_id | trading_id |
worldSetRef |
run id, run version, run tag (worldSetRef is now informational only — see D1) |
ContextVersion |
catalog version (catalog versioning is a separate dimension; see §"Open sub-designs") |
| Pre-simulation catalog | run manifest, fixture catalog, sim catalog (name TBD — D3 follow-up) |
ScopeId |
game id / season id (ScopeId is the prototype's abstract identity covering both) |
IOutcomeContextStore |
"the storage read interface" (D4 — this name is provisional) |
Contract-by-contract comparison¶
OutcomeRow¶
Match: exact.
Storage (src/Models/AmericanFootball/LBS.Model.AmericanFootball.Accumulation/OutcomeRow.cs):
Prototype (FoundryQueryProto.cs:140):
Same fields, same semantics: Values[i] is the outcome's value in world i. Both sides agree the array length equals worldCount and is consistent across every row in a given context.
No action required.
OutcomeDefinition¶
Match: partial. Drift: storage has no Definition field.
Storage (OutcomeDefinition.cs):
public record OutcomeDefinition(string OutcomeId, string Category, string ValueType);
public static class OutcomeValueType
{
public const string Numeric = "Numeric";
public const string Boolean = "Boolean";
public const string Ordinal = "Ordinal";
public const string Temporal = "Temporal";
}
Prototype (FoundryQueryProto.cs:155):
public sealed record OutcomeDefinition(
string OutcomeId,
string Category,
string ValueType,
ExpressionInput? Definition = null);
The prototype carries a fourth field — Definition — that the storage type does not. The prototype uses null for raw outcomes and an ExpressionInput tree for derived ones (e.g. ANYTIME_TD_GAME_{pid} carries a tree representing TOTAL_TDS_GAME_{pid} >= 1). Storage today computes those derivations imperatively in C# (see comments at AmericanFootballOutcomeCatalogue.cs:419-421):
"ANYTIME_TD, TWO_PLUS_TDS, THREE_PLUS_TDS, TOTAL_TDS_* are pure functions of the PASSING_TDS / RUSHING_TDS / RECEIVING_TDS counters and are computed read-side."
This is D2: storage gains a Definition field, and the field's content is a postfix template. Detailed shape in §"Decisions taken — D2".
Note the spec is slightly inconsistent on this point already: §9.1 shows the type without Definition, while §11.8.2 shows it with. §11.8 is the authoritative direction ([DRAFT], "Architecture landed 2026-04-27"). Closing this drift is part of D2.
ValueType enum constants match exactly. Category is a free-form string on both sides.
GameOutcomeContext and SeasonOutcomeContext¶
Match: aligned. Drift: prototype derives extras (WorldSetRef, WorldCount, ScopeId) that storage does not store.
Storage:
public class GameOutcomeContext
{
public required string GameId { get; init; }
public required string SeasonId { get; init; }
public required int ContextVersion { get; init; }
public required IReadOnlyList<OutcomeRow> Rows { get; init; }
}
public class SeasonOutcomeContext
{
public required string SeasonId { get; init; }
public required int ContextVersion { get; init; }
public required IReadOnlyList<OutcomeRow> Rows { get; init; }
}
Prototype (FoundryQueryProto.cs:174):
public sealed class GameOutcomeContext : IOutcomeContext
{
public required string GameId { get; init; }
public required string SeasonId { get; init; }
public required int ContextVersion { get; init; }
public required IReadOnlyList<OutcomeRow> Rows { get; init; }
public string ScopeType => "GAME";
public string ScopeId => GameId;
public string WorldSetRef => $"{SeasonId}#{ContextVersion}"; // derived — see spec §9.3
public int WorldCount => Rows.Count > 0 ? Rows[0].Values.Length : 0;
// ...
}
Stored fields are identical. The prototype layers four computed properties on top via IOutcomeContext:
ScopeType—"GAME"or"SEASON". Implicit from the C# type but useful at the GraphQL layer.ScopeId— abstractsGameIdandSeasonIdto a single key. Used byContextRepositoryas the dictionary key.WorldSetRef— derived$"{SeasonId}#{ContextVersion}". Was the Rule 3 alignment key; under D1, it remains computed but is no longer an enforced invariant.WorldCount— derived fromRows[0].Values.Length. The spec assumes (and the engine relies on) all rows in a context having the same length.
These are adapter properties on the prototype side, not extra storage fields. No storage change required.
IOutcomeContext (prototype only)¶
public interface IOutcomeContext
{
string ScopeType { get; }
string ScopeId { get; }
string WorldSetRef { get; }
int ContextVersion { get; }
int WorldCount { get; }
bool HasOutcome(string outcomeId);
double[] Values(string outcomeId);
IEnumerable<string> OutcomeIds { get; }
}
This is the contract the inner ring (PostfixEvaluator) consumes. It deliberately abstracts game vs season so the evaluator stays scope-agnostic. Storage doesn't need to implement this — the prototype's concrete GameOutcomeContext / SeasonOutcomeContext already do, and ContextRepository materialises them from whatever read API D4 produces.
No storage change. Worth keeping in mind only because the read interface (D4) needs to return things shaped like this.
OutcomeCatalog vs IOutcomeCatalogue¶
Match: divergent shapes serving similar purposes.
Prototype (FoundryQueryProto.cs:217):
public sealed class OutcomeCatalog
{
private readonly Dictionary<string, OutcomeDefinition> _byId;
public OutcomeCatalog(IEnumerable<OutcomeDefinition> defs) { ... }
public OutcomeDefinition? TryGet(string outcomeId) => ...;
public IEnumerable<OutcomeDefinition> All => _byId.Values;
}
Flat dictionary keyed by full concrete outcome_id. Built once at startup with hard-coded definitions for two players (patrick_mahomes, travis_kelce).
Storage (IOutcomeCatalogue.cs):
public interface IOutcomeCatalogue
{
IReadOnlyList<OutcomeDefinition> GetGameOutcomeDefinitions(Team home, Team away);
IReadOnlyList<OutcomeDefinition> GetSeasonOutcomeDefinitions(IReadOnlyList<Team> teams);
}
Roster-driven: emits ~500 game-level definitions per (home, away) pair, ~1,728 season-level definitions per 32-team season. Concrete IDs (PASSING_YARDS_HALF1_mahomes_patrick_1) are materialised inline by reading team.OffensePackages[…].Qb.Id etc.
The two shapes solve compatible but different problems: - The prototype's flat catalog is a consumer-facing index of outcomes for discovery / lookup. - Storage's roster-driven catalogue is a materialisation engine that emits concrete IDs from rosters.
This is D3. The convergence introduces a templated catalog at the API surface, paired with a pre-simulation catalog of fixtures + players. See §"Decisions taken — D3".
ClickHouse schema¶
Storage (Infrastructure/ClickHouse/ClickHouseSchemas.cs):
CREATE TABLE IF NOT EXISTS game_outcome_context
(
game_id LowCardinality(String),
season_id LowCardinality(String),
context_version UInt32,
outcome_id LowCardinality(String),
values Array(Float64) CODEC(Delta, ZSTD)
)
ENGINE = ReplacingMergeTree(context_version)
ORDER BY (game_id, outcome_id)
PARTITION BY season_id;
CREATE TABLE IF NOT EXISTS season_outcome_context
(
season_id LowCardinality(String),
context_version UInt32,
outcome_id LowCardinality(String),
values Array(Float64) CODEC(Delta, ZSTD)
)
ENGINE = ReplacingMergeTree(context_version)
ORDER BY (season_id, outcome_id);
Spec §9.2 sketches a unified outcome_context table with scope_type discriminator. Storage implemented two physical tables instead. The unified shape was a sketch; the two-table shape is what's deployed and validated. The prototype's ContextRepository doesn't care which physical layout exists — it consumes typed GameOutcomeContext / SeasonOutcomeContext instances. No drift to resolve.
What does need attention is that the storage schema today has no column for derivation expressions and no column for run identity. D1 keeps it that way. D2 adds a definition column to the catalog table (not the OC tables — derived outcomes have no rows).
The catalog table sketched in spec §9.2:
CREATE TABLE outcome_catalog (
outcome_id LowCardinality(String),
category LowCardinality(String),
value_type LowCardinality(String),
PRIMARY KEY (outcome_id)
);
AmericanFootballOutcomeCatalogue. D2 + D3 together push toward a persisted, queryable catalog (templated form). Where it lives — ClickHouse vs Marten document store vs in-memory loaded at startup from a config source — is part of the §"Open sub-designs".
outcome_id format¶
Match: exact. Both sides use {TYPE}_{TIMEPERIOD}_{PARTICIPANTID} where TYPE and PARTICIPANTID may contain underscores and TIMEPERIOD anchors the split. TIMEPERIOD ∈ {GAME, HALF1, HALF2, Q1, Q2, Q3, Q4, OT, SEASON}.
The prototype has a parser; storage does not (treats outcome_id as opaque). The parser lives at FoundryQueryProto.cs:353:
public static class OutcomeIdParser
{
private static readonly string[] KnownTimePeriods =
{ "GAME", "SEASON", "HALF1", "HALF2", "Q1", "Q2", "Q3", "Q4", "OT" };
public static (string Type, string TimePeriod, string ParticipantId) Parse(string outcomeId)
{ /* anchors on time period, splits */ }
}
Storage ought to share this parser — it's the only place the id format is enforced. Move to a shared location during code convergence (likely the Accumulation project).
TimePeriod¶
Match: aligned. Storage's TimePeriod.cs and the prototype's TimePeriod enum (FoundryQueryProto.cs:248) cover the same set: Game, Half1, Half2, Q1, Q2, Q3, Q4, Ot, Season.
The spec briefly mentions REMAINING as a placeholder; neither side uses it. Drop from the spec text or mark it as a deferred extension.
The five hard rules against current storage¶
| Rule | What it enforces | Storage support today | Action |
|---|---|---|---|
Rule 1 — Canonical wire format is infix ExpressionInput |
Schema validation. Lives in HotChocolate. | N/A — query-side concern. | None. |
Rule 2 — Cross-context worldCount must match |
The values arrays for two contexts in the same expression must have the same length. | Implicit. Storage writes Array(Float64) with one entry per world; the array length is worldCount. Two contexts from the same run will have arrays of the same length; from different runs they may not. |
Prototype already enforces this in Query.SeasonContext. Read API (D4) must surface array length so the check can run before evaluation. |
Rule 3 — Cross-context worldSetRef must match |
Two contexts in the same expression must come from the same coordinated sim run. | Cannot enforce — storage has no semantic run identity. ContextVersion is a ReplacingMergeTree(context_version) upsert key, not a run label; same (season, version) re-run silently overwrites. |
Drop the rule (D1). Retire CONTEXT_VERSION_MISMATCH. worldSetRef is computed for display but not checked. |
Rule 4 — Unknown outcome_id is a hard fail |
Every leaf in an expression must resolve against the loaded contexts and the catalog. | N/A — query-side concern. Storage just stores rows; resolution is the query layer's job. | Catalog source must be loaded at the query layer (templated form per D3). Read failures on storage side propagate as UNKNOWN_OUTCOME_ID. |
| Rule 5 — Malformed expression is a hard fail | Structural well-formedness beyond schema. | N/A — query-side concern. | None. |
Net: of the five rules, only Rule 3 is materially affected by what storage looks like, and the resolution is to drop it.
Decisions taken¶
D1 — Run identity¶
Resolution: Drop Rule 3. worldSetRef becomes informational metadata, computed as before ($"{SeasonId}#{ContextVersion}") but not enforced cross-context. Rule 2 (worldCount match) becomes the only structural alignment check. The error code CONTEXT_VERSION_MISMATCH retires from the §10.3 taxonomy.
Why this and not its alternatives.
- Adding a run_id column to storage would give Rule 3 real teeth, but storage's existing ReplacingMergeTree(context_version) semantics already provide the property the system actually relies on (idempotent re-runs, monotonic versioning). Adding a UUID column doubles up on that and complicates the merge/replace semantics for marginal benefit.
- Promoting ContextVersion to a strict run label by convention is fragile — the schema doesn't force it, the discipline is invisible at read time, and a re-used version silently corrupts cross-context evaluation.
- Dropping Rule 3 is honest: the orchestrator is the source of correlation, and the query layer trusts what it loads. If a downstream consumer pairs misaligned contexts they'll get garbage out, but they'll have done so by explicit choice. Rule 2 catches the most common shape of misalignment (different run sizes).
Consequences.
- Prototype removes the Rule 3 check from Query.SeasonContext.
- The two test fixtures designed to trigger CONTEXT_VERSION_MISMATCH (kc_lar_NEXT, kc_den_w3) lose their reason to exist and should be retired.
- Spec sections affected: §4 Rule 3 (delete or restate as informational), §9.3 (recast — derivation still happens, enforcement does not), §10.3 (remove CONTEXT_VERSION_MISMATCH), prototype's CLAUDE.md (update Rule table).
- Storage schema: unchanged.
D2 — Derived outcomes¶
Resolution: Storage's OutcomeDefinition gains a Definition field. The field is a postfix template string with named placeholders. Authors may write derivations in infix; a build step canonicalises to postfix before populating storage. The query layer's ExpressionCanonical.ExpandDerived consumes templates directly: it substitutes placeholders with the consumer's leaf parameters, then evaluates as ordinary postfix.
Why postfix template, not infix tree.
- The query layer already canonicalises every expression to postfix internally. The prototype's CLAUDE.md calls postfix "the public-facing canonical postfix string … the user-readable wire/storage form." Storing definitions in postfix means Definition is its own canonical form — no parser, no canonicaliser, no precedence ambiguity at expansion time.
- Postfix is unambiguous by construction: two semantically identical infix expressions can be written multiple ways; the canonical postfix has one representation per canonicalised tree. That matters when Definition doubles as a stable identity — it lets the cache key (§11.8.5) be computed without reparsing.
- The current prototype field type ExpressionInput? (typed infix tree) loses to postfix on serialisability — it's a structured object with discriminated unions, awkward to store as a single text column; postfix is just a string.
Why a template, not a concrete expression.
- Storage emits concrete outcome IDs by combining a structural pattern with rosters. The catalog entry for ANYTIME_TD_GAME_{participantId} should describe the derivation generically (TOTAL_TDS_GAME_{participantId} 1 GTE) rather than enumerating one entry per concrete player.
- Templates keep catalog cardinality bounded (~tens of templates, not thousands of concretes).
- Concrete IDs are still resolvable: when a consumer references ANYTIME_TD_GAME_mahomes, the middle ring matches the template, substitutes {participantId} := mahomes, expands to TOTAL_TDS_GAME_mahomes 1 GTE, and evaluates.
Schema sketch. A first cut at the catalog table:
CREATE TABLE outcome_catalog (
outcome_id_template LowCardinality(String), -- e.g. "ANYTIME_TD_GAME_{participantId}"
category LowCardinality(String),
value_type LowCardinality(String), -- Numeric | Boolean | Ordinal | Temporal
definition String, -- postfix template; empty/null for raw
PRIMARY KEY (outcome_id_template)
)
ENGINE = ReplacingMergeTree();
Slot model and how slots bind to the pre-simulation catalog: see §"Open sub-designs".
Consequences.
- Storage migrates derived outcomes from imperative C# in AmericanFootballOutcomeCatalogue (and TdOrdinalDeriver etc.) to declarative templates in catalog data. Several derivations enumerated in §11.8.1 of the spec already have natural postfix forms:
- ANYTIME_TD_GAME_{p} → TOTAL_TDS_GAME_{p} 1 GTE
- TWO_PLUS_TDS_GAME_{p} → TOTAL_TDS_GAME_{p} 2 GTE
- THREE_PLUS_TDS_GAME_{p} → TOTAL_TDS_GAME_{p} 3 GTE
- TOP_SCORER_SEASON_{p} → TOTAL_TDS_SEASON_{p} TOTAL_TDS_SEASON_{otherP} GT (cross-participant; needs the {otherP} slot, see Q in §"Open sub-designs")
- Storage's existing C# computation paths (COMPLETION_PCT, ordinal rank fields like SCORING_RANK_GAME_* produced by TdOrdinalDeriver) need a path-by-path call: the simple ones become declarative; ones that genuinely need cross-participant ranking or temporal-first-event semantics are escape-hatch cases (§11.8.6 already names this — "sim-produced booleans"). Treat as a separate inventory pass during Phase 3.
- Prototype: OutcomeDefinition.Definition field type changes from ExpressionInput? to string? (or a wrapper type carrying string Postfix plus parsed metadata). ExpressionCanonical.ExpandDerived updated to consume postfix templates. The infix authoring path lives outside the catalog itself — a tool, not a runtime concern.
- Spec §11.8.2 needs an update reflecting the postfix form. Spec §11.8.3 expansion diagram still applies; only the leaf form changes.
D3 — Catalog discovery¶
Resolution: Templated catalog at the API surface, paired with a pre-simulation catalog of fixtures + players passed in at sim start.
The two catalogues, separated.
- Outcome template catalog (storage). Roster-agnostic. Lists outcome templates like PASSING_YARDS_{period}_{qbId} with metadata describing each placeholder (its role, what it ranges over). Finite, small, stable across runs. Drives D2's Definition field for derived templates.
- Pre-simulation catalog (new concept in Foundry). The set of teams, rosters, and fixtures that will be simulated in a given run. Established at sim start, persisted alongside the run, referenced by every context the run produces.
Concrete outcome IDs are produced by combining the two: each template plus the relevant participants from the pre-simulation catalog yields one concrete ID. The query layer's discovery surface (outcomes(filter) on a context, outcomeDefinitions(filter) at root) materialises concretes by referencing both — a context's discovery field uses the run's pre-sim catalog; the root field can either return templates (default) or concretes scoped to a specified run.
Why two catalogues and not one.
- Templates change at the cadence of the model design (rare). The pre-sim catalog changes at the cadence of run setup (every sim). Combining them couples those cadences and forces a full template re-emission per run.
- Templates are introspectable as schema documentation — clients can ask "what kinds of outcomes exist." Concretes are introspectable as data — clients can ask "what outcomes did this run produce." Different audiences, different surfaces.
- Storage's existing IOutcomeCatalogue collapses both: it takes Team objects and emits concretes. After convergence, it splits — a template registry on one side, a roster catalogue on the other.
Open sub-design. See §"Open sub-designs — D3 follow-ups" for the questions that still need answers (canonical name, persistence, how slots declare their roles, how it ties to run identity even though Rule 3 is gone).
Consequences.
- Prototype: replace the static OutcomeCatalog with a templated version. ContextRepository references both the template catalog and the run's pre-sim catalog when materialising contexts.
- Storage: new persistence target for the pre-sim catalog. Existing AmericanFootballOutcomeCatalogue refactors into a template catalog (no Team arguments) plus a separate fixture/player catalog producer.
- Spec §11.1 (discovery) needs a rewrite to describe the two-catalogue model. §11.2 (entity resolution) likely simplifies — entities are first-class in the pre-sim catalog.
D4 — Read API¶
Resolution: Storage exposes a typed IOutcomeContextStore read interface. The query layer depends on the abstraction; it does not see ClickHouse. The interface is designed as part of the broader storage-layer rework (the experiment code is moving to production-quality design before the contract locks in), so the timing is good — the read shape can be set without retrofitting.
Why a typed interface, not direct ClickHouse calls.
- The query layer's correctness is contract-shaped. A typed boundary lets storage enforce read patterns, add cross-cutting concerns (caching, observability, auth) at one seam, and swap backends without breaking the query layer.
- Direct ClickHouse calls couple the query layer to storage's backend choice. Today it's ClickHouse; the §11.5 cache layer in the spec talks about (expression_hash, worldSetRef) keys that may live elsewhere. A typed boundary keeps that flexibility on the storage team's side of the wall.
- Performance worries (e.g. server-side projection of a single outcome's values via ClickHouse arrayMap) are addressable inside the implementation rather than by exposing storage semantics outward. If specific read patterns need a tighter path, they get added as methods on the interface.
Method shape — driven by ContextRepository's real consumption. The prototype's repository today does three things:
1. Loads a single context by scopeId (game or season).
2. Loads multiple contexts by scopeId list (the seasonContext(games:) field).
3. Lists what's available for discovery.
That maps to roughly:
public interface IOutcomeContextStore
{
Task<IOutcomeContext?> GetByScopeIdAsync(string scopeId, int? contextVersion = null, CancellationToken ct = default);
Task<IReadOnlyList<IOutcomeContext>> GetManyByScopeIdAsync(IReadOnlyList<string> scopeIds, int? contextVersion = null, CancellationToken ct = default);
Task<IOutcomeCatalog> GetCatalogAsync(string runId, CancellationToken ct = default);
// Pre-simulation catalog accessors land here too — see D3 sub-design.
}
The exact signatures, the run-identity parameter (whether it's runId vs latest-by-default), and projection options (e.g. "only fetch values for these outcome ids") are part of the storage rework.
Consequences.
- Storage: extracts IOutcomeContextStore and an implementation backed by ClickHouse. The existing ad-hoc ClickHouseBackend.ReadGameOutcomeContextAsync becomes the implementation detail.
- Prototype: ContextRepository gains a constructor dependency on IOutcomeContextStore. The hard-coded fixture data in BuildFixtures retires — fixtures now come from the store.
- The interface design is a deliverable in its own right and gets its own short doc once shapes are firm.
Open sub-designs¶
These are the items the four decisions surface as needing more thought before code lands. They're scoped here, not solved.
D2 follow-up — Definition-field authoring and build pipeline¶
- Authoring source-of-truth. Where do template authors edit derivations? Options: a
derived-outcomes.yamlchecked intolbs.foundry/; a code-generator that emits postfix from a small DSL; hand-written postfix directly in the catalog table. The first is most ergonomic for domain authors; the third has the lowest tooling overhead. - Validation at build time. Does the build verify each definition typechecks (per
TypeChecker.Check)? That every referenced raw template exists? Probably yes — without it, a typo silently produces anUNKNOWN_OUTCOME_IDat evaluation. - Cross-template references. A derivation that references another derivation (
KC_LIKELY_WINS = KC_WIN AND KC_FAVORED) needs cycle detection (§11.8.6 #2). Bound the recursion depth and refuse cycles at build time. - Catalog versioning. §11.8.6 #3 already flags this — when a derivation changes, the cache invalidation surface needs a
catalogVersiondimension. Worth a separate small spec.
D3 follow-up — Pre-simulation catalog¶
- Canonical name.
SimulationCatalog?RunManifest?FixtureCatalog? Each implies different framing. Pick before code lands. - Persistence. Marten document store, ClickHouse table, or a flat JSON document per run? It's read frequently during a run, queryable for discovery, and small (one document per run per sport).
- Identification. Even with Rule 3 dropped, the pre-sim catalog itself needs a stable id so contexts can reference "the catalog for this run." Likely
(seasonId, contextVersion)or a separate run id. - Slot/role declaration. How does a template declare what its placeholders bind to?
{participantId}is fine for "any participant in the context"; cross-participant templates (TOP_SCORER_SEASON_{p}referencing{otherP}) need richer slot semantics. A small grammar —{slotName: roleType}whereroleTypeis one ofparticipant,team,opposingParticipant,season— covers most cases. - Lifecycle. Is the pre-sim catalog mutable mid-run (e.g. injury substitutions producing new player rosters)? Probably not — locking it at sim start matches the run-as-coordinated-unit model.
D4 follow-up — IOutcomeContextStore method shape¶
- Run filtering semantics. When a caller doesn't specify
contextVersion, what gets returned — the latest, the latest before some cutoff, all?ReplacingMergeTree(context_version)resolves to MAX on the storage side, but the read path needs to expose this as policy, not an accident of merge state. - Bulk read shape. Loading 17 contexts (1 season + 16 games) for a
seasonContext(games:)query should be a single round-trip, not 17. The interface needs a method that does this without exposing batching as a caller concern. - Projection. A query that only references three outcomes shouldn't fetch all ~500 rows. ClickHouse can serve
WHERE outcome_id IN (...). Whether that's exposed as an optional projection parameter or handled invisibly via lazy materialisation is an implementation choice — but it has to be possible. - Pre-sim catalog accessors. The same store interface should serve the pre-sim catalog for the run, not a separate parallel store.
Minor gaps¶
These are real but small enough to handle inline during code convergence.
| # | Gap | Resolution |
|---|---|---|
| 1 | Closed OutcomeType enum in prototype is far narrower than storage's emitted types. Prototype enumerates ~11 types (ANYTIME_TD, TOTAL_TDS, SCORING_RANK, FANTASY_POINTS_*, etc.). Storage produces hundreds (POINTS, PASSING_YARDS, RUSHING_TDS, COMPLETIONS, ATTEMPTS, etc.). |
Either drop the enum (treat type as an open string with catalog-validated values) or expand it to cover storage's full range. The closed enum was useful during prototype development but is a maintenance burden against the live catalog. Recommend: open string + catalog membership check at parse time. |
| 2 | OutcomeIdParser lives only in the prototype. Storage treats outcome_id as opaque. |
Move parser to a shared utility - now landed in LBS.OutcomeContext.Contracts. Both sides reference one parser. |
| 3 | TimePeriod enum vs spec drift. Spec section 8 mentions REMAINING; neither side implements it. |
Drop from the spec or mark it [DEFERRED]. |
| 4 | Schema sketch in spec §9.2 is a unified outcome_context table; storage built two physical tables. Prototype consumes typed objects, doesn't care. |
Update spec §9.2 to reflect the two-table reality. |
| 5 | ContextVersion is UInt32 in storage DDL, int in C# contracts, Int (32-bit) in GraphQL. Match. Just worth noting the chain. |
None. |
| 6 | Prototype ContextRepository builds fixtures in-memory in code. Will be replaced wholesale by D4's IOutcomeContextStore calls during code convergence. |
Code work, not a design gap. |
| 7 | Prototype CLAUDE.md describes Rule 3 as enforced and lists CONTEXT_VERSION_MISMATCH as an active error code. Will be wrong after D1. |
Update during Phase 3. |
| 8 | Spec §9.1 omits the Definition field from OutcomeDefinition; §11.8.2 includes it. Internal inconsistency. |
Resolved by D2 — the Definition field exists; spec §9.1 needs updating to match §11.8.2 (and both need to switch from ExpressionInput? to the postfix-template form). |
Cleanups required by side¶
Prototype (graphql-outcomecontext/)¶
- Remove Rule 3 check in
Query.SeasonContext. - Retire
CONTEXT_VERSION_MISMATCHfrom the error taxonomy and the ErrorBuilder sites. - Delete the misalignment fixtures
kc_lar_NEXTandkc_den_w3fromContextRepository.BuildFixtures. UpdateCLAUDE.md's scope table accordingly. - Change
OutcomeDefinition.DefinitionfromExpressionInput?to a postfix-template string (or wrapper type). - Replace
ExpressionCanonical.ExpandDerived's tree-walk with a postfix-template substitution + reparse path. - Replace static
OutcomeCatalogwith a templated catalog backed by storage's template registry, fed by the run's pre-sim catalog when materialising concretes. - Replace
ContextRepository's in-memory fixture builder with calls intoIOutcomeContextStore. - Update
CLAUDE.mdto reflect: dropped Rule 3, postfix-templateDefinition, templated catalog, store-backed repository. - Update spec sections: §4 Rule 3, §9.1, §9.2, §9.3, §10.3, §11.1, §11.8.2.
Storage (lbs.foundry/)¶
- Add a
Definitionfield (postfix template string) to the catalog. Persist it. Decide where (outcome_catalogClickHouse table per spec §9.2, or Marten document, or generated config). - Migrate the derivations currently computed read-side in
AmericanFootballOutcomeCatalogue(and helpers likeTdOrdinalDeriver) into declarative templates. Inventory pass to identify which migrate cleanly and which are escape-hatch sim-produced booleans. - Split
IOutcomeCatalogueinto a roster-agnostic template catalog and a roster-driven pre-simulation catalog producer. - Design and persist the pre-simulation catalog (canonical name TBD).
- Extract
IOutcomeContextStoreread interface as part of the storage-layer rework. Replace ad-hocClickHouseBackend.ReadGameOutcomeContextAsyncwith an implementation behind the interface. - Move
OutcomeIdParser(or its equivalent) into the sharedAccumulationproject so both sides reference one parser.
Recommended next steps¶
- Phase 2 — convergence reference document. Builds on this gap analysis. Audience is broader than this doc — it's the artefact the storage team and any new contributor uses as the orientation point. Lives next to this file in
lbs.foundry/docs/. - Phase 3 — code convergence plan. Broken into the cleanups listed above, sized and ordered. Naturally sequences as: D1 cleanup (smallest, removes code) → D2 catalog/Definition changes → D3 catalog split + pre-sim catalog → D4 read interface during the storage-layer rework.
- Sub-design follow-ups. The three open sub-designs (D2 build pipeline, D3 pre-sim catalog, D4 method shape) get short standalone specs as their shapes firm up — none is large enough to need a separate gap analysis but each will touch real code.
- Coordination. Decide who owns each side of the boundary going forward. The prototype was a one-person spike; the storage-layer rework is more substantial. Ownership of the template catalog (storage),
IOutcomeContextStore(storage), and the GraphQL surface (query) needs to be explicit before code starts moving.