Outcome Context — Query Layer Build Plan¶
Date: 2026-05-04 (original); last updated: 2026-05-05 (status banner).
Status: Implemented. D1–D4 plus the AmericanFootball template-registry migration shipped on branch LBS-1183 (PR #1389). The plan body below is preserved as the as-designed reference.
Reads alongside: gap analysis, convergence reference.
Implementation status (added 2026-05-05)¶
- D1 — Rule 3 dropped,
worldSetRefis informational,CONTEXT_VERSION_MISMATCHretired. - D2 —
OutcomeDefinition.Definitionis a postfix-templatestring?.ExpandDerivedrecursively expands derived outcomes down to raw stored counters. - D3 — Split into
IOutcomeTemplateCatalog(sport-agnostic) +ISimulationCatalogStore(per-run rosters + fixtures).OutcomeCatalogMaterializer.Materialise(templates, simCatalog)is the live entry point. - D4 —
IOutcomeContextStoreextracted; ClickHouse code lifted intoLBS.OutcomeContext.Storage;ContextRepositoryis async. - Catalogue migration —
AmericanFootballOutcomeCatalogueandIOutcomeCatalogueretired. The full ID surface is materialised fromAmericanFootballOutcomeTemplates×SimulationCatalog. The simulator-vs-templates contract test inAmericanFootballOutcomeCatalogueTests.TemplatesContainAllAccumulatorSeasonOutcomesis the regression guard. - Box-score additions —
TEAM_FIRST_DOWNS,TEAM_TOTAL_YARDS_GAME, penalties (count + yards), 3rd/4th down efficiency, red zone, time of possession, two-point conversions are produced byGameFlowAccumulator+TeamScoringAccumulator. Remaining gaps (per-defender, punter, returner, safeties) blocked on simulation-sidePlaymodel extensions; documented inline inAmericanFootballOutcomeTemplates.cs.
This plan covers how the GraphQL query-layer prototype was brought into lbs.foundry/ and docked onto the Outcome Context storage experiment. Originally written as a pre-implementation proposal; the work has now landed.
The plan respects the gap-analysis principle: D1–D4 are committed enough to start coding against, but if implementation surfaces a constraint that breaks an assumption, the gap analysis gets updated rather than worked around.
1. Project placement¶
The Outcome Context query layer is sport-agnostic — nothing about expressions, postfix tokens, canonicalisation, type checking, or stack evaluation is American-Football-specific. The earlier draft of this section put everything under LBS.Model.AmericanFootball.*, which conflated the engine with the sport binding. Corrected: three new projects, all under a new src/OutcomeContext/ folder.
1.1 New project — LBS.OutcomeContext.Contracts¶
Sport-agnostic data types referenced by both storage (write side) and the query layer (read side). Class library, no project dependencies, minimal package surface.
Houses:
| Type | Role |
|---|---|
OutcomeRow |
One outcome's per-world values |
OutcomeDefinition (record) + OutcomeValueType (constants) |
Catalog entry shape; D2 lands Definition here |
GameOutcomeContext, SeasonOutcomeContext |
Storage-side context types |
OutcomeIdParser |
{TYPE}_{TIMEPERIOD}_{PARTICIPANTID} id parser, anchored on time-period vocabulary |
TimePeriodConstants |
GAME / SEASON / HALF1 / HALF2 / Q1 / Q2 / Q3 / Q4 / OT |
LBS.Model.AmericanFootball.Accumulation references this project for the storage contract types. The American Football catalogue (AmericanFootballOutcomeCatalogue) and the play accumulators stay in Accumulation — those are genuinely sport-coupled (they take Team parameters, walk plays, derive sport-specific stats).
1.2 New project — LBS.OutcomeContext.Query¶
Sport-agnostic query engine. Class library, depends only on LBS.OutcomeContext.Contracts (and on whichever project ends up owning IOutcomeContextStore after the storage-layer rework — see §3).
Houses:
| Concern | Type / module |
|---|---|
| Query-side context abstraction | IOutcomeContext interface |
| Wire input contract | ExpressionInput, OutcomeRefInput, BinaryExprInput, UnaryExprInput |
| Internal postfix tokens | PostfixToken (sealed hierarchy: OutcomeToken, ConstToken, OpToken) |
| Translation | InfixToPostfix.Translate(...) |
| Canonicalisation | ExpressionCanonical (ExpandDerived, Canonicalise, ToPostfix, ToInfix, IsBooleanRoot, HashOfPostfix) |
| Type-checking | TypeRule, DefaultTypeRules, TypeChecker, EvalWarning, EvalError |
| Evaluation (inner ring) | PostfixEvaluator, EvaluationInternalResult |
| Outer wrapper | EvaluationEngine, EvaluationResult |
| Repository adapter | ContextRepository — thin adapter from IOutcomeContextStore to IOutcomeContext[] (Phase 0e) |
| Discovery types | Outcome, OutcomeFilter, OutcomeCatalog, OutcomeCatalogEntry |
| Hard-rule constants | OperationConstants, BinaryOperatorConstants, UnaryOperatorConstants, ContextTypeConstants, ResultTypeConstants, RuleSeverityConstants, EffectiveTypeConstants (prototype enums become static class Foo { public const string Bar = "BAR"; }) |
Namespace: LBS.OutcomeContext.Query, with sub-namespaces …Query.Expressions, …Query.Evaluation, …Query.Discovery. Keeps the ring boundary the prototype's CLAUDE.md describes: outer-ring types live next to outer-ring types, etc.
1.3 New app — LBS.OutcomeContext.QueryApi (Phase 0e)¶
Sibling of LBS.Api, Ballr.WebApi, LBS.Ballr.McpServer under src/Apps/. ASP.NET Core minimal host using HotChocolate 14, implements the prototype's Wave 1 contract. Depends on LBS.OutcomeContext.Query and on the storage project that ships IOutcomeContextStore. Provides:
/graphqlendpoint with the schema bound fromQuery,GameContext,SeasonContext,Outcome, etc.- DI wiring:
IOutcomeContextStoreresolved from the storage project;ContextRepositoryresolved as a scoped service. - Telemetry / logging through the same
ILoggerFactorypatterns the rest of Foundry uses.
A dedicated app rather than mounting on LBS.Api / Ballr.WebApi: the query surface is a different consumer audience (model-evaluation clients, not the existing Foundry domain APIs) and a separate process keeps deploy / scale concerns independent.
1.4 Test project — LBS.OutcomeContext.Query.Tests¶
xUnit v3, run via dotnet run --project .... Covers Contracts (parser + period constants) and Query (translation, canonicalisation, type checker, evaluator, discovery). Sub-folders mirror the source layout: Contracts/, Expressions/, Discovery/, Evaluation/.
The five canned scenarios in the prototype's file header become the integration-test scaffold for Phase 0e (minus the two that retire under D1).
1.5 Where do the gap-analysis "shared types" land?¶
| Type | Lands in | Rationale |
|---|---|---|
OutcomeRow, OutcomeDefinition, GameOutcomeContext, SeasonOutcomeContext |
LBS.OutcomeContext.Contracts |
Storage contract; sport-agnostic. Accumulation references Contracts. |
OutcomeIdParser |
LBS.OutcomeContext.Contracts |
The id format is part of the storage contract — both sides need to enforce it. |
OutcomeValueType constants |
LBS.OutcomeContext.Contracts |
Same reason. |
TimePeriodConstants |
LBS.OutcomeContext.Contracts |
Same reason — the period vocabulary is part of the id format. |
IOutcomeContext |
LBS.OutcomeContext.Query |
Query-only abstraction — the evaluator's view over storage's concrete types. |
ExpressionInput family |
LBS.OutcomeContext.Query |
Wire format for the query language; not storage. |
| Postfix tokens, evaluator, canonicaliser, type checker | LBS.OutcomeContext.Query |
All ring-2/ring-3 internals. |
IOutcomeContextStore |
LBS.OutcomeContext.Storage (after rework — see §3) |
Storage's sport-agnostic read API. The Query project depends on this abstraction. |
IBlobSink, LocalDiskBlobSink, AzureBlobSink |
LBS.OutcomeContext.Storage (extracted Phase 0r-2) |
Sport-agnostic byte-level blob abstraction. Was in LBS.Model.AmericanFootball.Storage; the AmericanFootball project keeps only the genuinely sport-coupled PlayByPlayRecord + Parquet writer. |
AmericanFootballOutcomeCatalogue, play accumulators, derivers |
LBS.Model.AmericanFootball.Accumulation (stays) |
Genuinely sport-coupled — takes Team parameters, walks plays, computes sport-specific derivations. |
2. Task sequencing for D1–D4¶
The gap analysis suggests D1 → D2 → D3 → D4 by size (smallest first, removes code; biggest last, ties into the storage rework). I confirm that order with one tweak: a Phase 0 lift-and-port of the prototype's sport-agnostic engine into LBS.OutcomeContext.Query happens before D1, because D1 is partly a deletion in the ported code — and it's cleaner to delete from a Foundry-conformant version of the engine than to port and delete in the same task.
Phase 0 — Port the prototype engine (large)¶
Bring FoundryQueryProto.cs into the new Query project, splitting the single file into per-concern files, applying Foundry conventions (no enums, this. prefix, camelCase private fields, DateTimeOffset, file headers).
| Outcome | Detail |
|---|---|
| New files | One per type/module from the inventory in §1.1 (~25–30 .cs files). |
| Files changed | None on the storage side. claude.md may gain a row pointing to the Query project's docs once they exist. |
| Tests added | Translation, canonicalisation, type-checker, evaluator unit tests (~80% of the prototype's behaviour-bearing surface, since the prototype has no tests we need to write the regression tests as part of porting). |
| Hard rules | Five (D1's Rule 3 still in place at this stage — D1 deletes it next). The two misalignment fixtures are kept temporarily so the port has the full contract under test before D1 removes that branch. |
| Size | Large. Realistic chunking: (i) inner-ring types + tokens + translator, (ii) canonicalisation + type checker, (iii) evaluator, (iv) GraphQL types + resolvers, (v) repository adapter. Each step ships with tests and is mergeable on its own. |
Phase 1 — D1 cleanup (small)¶
Drop Rule 3, retire CONTEXT_VERSION_MISMATCH, delete the misalignment fixtures, recompute worldSetRef informationally only.
| Outcome | Detail |
|---|---|
| Files changed | Query.SeasonContext (drop Rule 3 check); EvaluationEngine / error builder (retire CONTEXT_VERSION_MISMATCH); ContextRepository test fixtures (delete kc_lar_NEXT, kc_den_w3); spec/ref docs (already noted in gap analysis cleanup list). |
| New files | None. |
| Tests added | None — tests removed (the two fixtures' assertions). The Rule 2 fixtures stay. |
| Storage impact | None. |
| Size | Small. |
Phase 2 — D2 Definition field on OutcomeDefinition (medium)¶
Add Definition (postfix-template string?) to the storage-side OutcomeDefinition. Update the Query project's ExpressionCanonical.ExpandDerived to consume postfix templates with named-placeholder substitution. Migrate the simple read-side derivations from imperative C# to declarative templates.
| Outcome | Detail |
|---|---|
| Files changed | Accumulation/OutcomeDefinition.cs (gain string? Definition, breaking record signature change). Accumulation/AmericanFootball/AmericanFootballOutcomeCatalogue.cs (existing call sites updated to pass Definition: null or a template string — the catalog is the authority). Query/Expressions/ExpressionCanonical.cs (ExpandDerived now consumes postfix templates; InjectScope becomes slot-substitution). |
| New files | Query/Expressions/PostfixTemplate.cs (template type — wraps a string Postfix plus named-slot metadata, parses and substitutes). Query/Expressions/SlotBindings.cs (the bag of {name → value} substitutions a consumer reference materialises). Probably a Query/Expressions/PostfixParser.cs if the existing infix parser doesn't already understand the template grammar. |
| Tests added | Template substitution (slot present, slot missing, slot referenced twice). Full ExpandDerived chain: consumer leaf → template hit → substitute → reparse → evaluate. The §11.8 example chain ANYTIME_TD_GAME_{p} → TOTAL_TDS_GAME_{p} 1 GTE. |
| Storage impact | DDL / catalog row gains a definition column where the catalog is persisted. Where the catalog persists is open (D2 sub-design). Until that's settled, the catalog is generated in C# (current state) and the new Definition column lives only on the in-memory OutcomeDefinition. The persisted catalog table arrives with D3. |
| Mermaid ERD | Update — OutcomeDefinition gains a field. |
| Size | Medium. The mechanical changes are small; the build pipeline (infix authoring → postfix canonicalisation) needs design before implementation — D2 sub-design (§5.1) blocks completion of this phase. |
Phase 3 — D3 catalog split + pre-simulation catalog (large)¶
Split IOutcomeCatalogue into a roster-agnostic outcome template catalog and a roster-driven pre-simulation catalog producer. Persist the template catalog. Stand up the pre-simulation catalog as a new persisted artefact. Replace the prototype's static OutcomeCatalog with a templated catalog that materialises concretes by combining templates with the pre-sim catalog.
| Outcome | Detail |
|---|---|
| Files changed | Accumulation/IOutcomeCatalogue.cs (renamed/refactored — see new files; the existing interface signature changes shape entirely). Accumulation/AmericanFootball/AmericanFootballOutcomeCatalogue.cs (becomes a template producer — no Team arguments to the template part; the roster-binding code moves to a new pre-sim-catalog producer). Storage rework code (extracts IOutcomeCatalogStore and IPreSimulationCatalogStore accessors). Query project's ContextRepository (depends on the new shape). |
| New files | Accumulation/OutcomeTemplate.cs (template record per gap analysis §"D2 Resolution"). Accumulation/TemplateSlot.cs (slot record). Accumulation/IOutcomeTemplateCatalog.cs (the roster-agnostic interface). Accumulation/IPreSimulationCatalog.cs (the run-scoped catalog interface — exact name TBD via D3 sub-design). The implementations live in the corresponding sport-binding project (Accumulation/AmericanFootball/...) and the storage project (Storage/...). |
| Tests added | Template registry round-trip. Pre-sim catalog round-trip. Concrete materialisation: template + roster → enumerated concrete IDs. Discovery: outcomes(filter) materialises against a run's pre-sim catalog. |
| Storage impact | New persisted shape for the template catalog (DDL or Marten document, depending on D3 sub-design). New persisted shape for the pre-simulation catalog. The accumulator side (which today calls IOutcomeCatalogue.GetGameOutcomeDefinitions(home, away) to know what to emit) needs a corresponding read against the new shapes — that's a real operational change to the accumulator wiring. |
| Mermaid ERD | Update — new tables / documents for both catalogues. |
| Spec impact | Spec §11.1 (discovery) needs a rewrite. §11.2 (entity resolution) likely simplifies — entities are first-class in the pre-sim catalog. |
| Size | Large. The pre-sim catalog is a genuinely new Foundry concept and needs its own short sub-design before implementation — D3 sub-design (§5.2) blocks completion. |
Phase 4 — D4 IOutcomeContextStore (medium-to-large)¶
Extract IOutcomeContextStore as part of the storage-layer rework. Replace the experiment's ad-hoc ClickHouseBackend.ReadGameOutcomeContextAsync etc. with an implementation behind the interface. Wire the Query project's ContextRepository to depend on IOutcomeContextStore rather than building fixtures in-memory.
| Outcome | Detail |
|---|---|
| Files changed | StorageExperiment/Infrastructure/IStorageBackend.cs already carries a TODO (lines 17–22) suggesting the split — that TODO is now done as part of the rework. The OC-read methods move to the new IOutcomeContextStore interface; PBP read/write moves to IPlayByPlayStore; analytical query moves to IAnalyticalQueryable. The schema/metrics methods stay on IStorageBackend. ClickHouseBackend splits into multiple implementations. ContextRepository (Query project) gains a constructor dependency on IOutcomeContextStore and the in-memory fixture builder retires entirely (or moves to test scaffolding). |
| New files | Storage/IOutcomeContextStore.cs, Storage/IPlayByPlayStore.cs, Storage/IAnalyticalQueryable.cs. Storage/ClickHouse/ClickHouseOutcomeContextStore.cs and siblings. The exact location depends on whether the rework moves ClickHouse code from StorageExperiment into Storage itself or into a new Storage.ClickHouse project — see §3. |
| Tests added | Integration tests for ClickHouseOutcomeContextStore against Testcontainers ClickHouse (the experiment already has these in StorageExperiment.Tests — they migrate). End-to-end Query-API tests against an IOutcomeContextStore fake at the Query.Tests level, against a real ClickHouse at the integration level. |
| Storage impact | The big one. Replaces the experiment-grade read paths with a production interface. D4 sub-design (§5.3) — method shape — blocks this phase. |
| Mermaid ERD | No ERD impact (interfaces, not schema). |
| Size | Medium-to-large depending on how much of the storage rework lands at the same time. Tightly coupled to §3 below. |
Sequencing summary¶
Phase 0 (port engine)
│
└─► Phase 1 (D1 cleanup) ─── small, isolated
│
└─► Phase 2 (D2 Definition) ─── blocked on D2 sub-design (§5.1)
│
└─► Phase 3 (D3 split) ─── blocked on D3 sub-design (§5.2)
│
└─► Phase 4 (D4 store) ─── blocked on D4 sub-design (§5.3) and §3 storage rework
Phase 0 and Phase 1 can start as soon as this plan is signed off — no blockers. Phases 2/3/4 each gate on their own sub-design; we should stand those up in parallel as soon as Phase 0 is in flight.
3. Storage-layer rework dependencies¶
D4 is "designed as part of the broader storage-layer rework from experiment to production design." From the storage-experiment status docs, that rework is a known but unscoped piece of work. The relevant facts:
- Production-quality already:
Accumulationlibrary;Storagelibrary (Parquet + IBlobSink). These don't need a rework — they need to absorb the parts that bubble up. - Experiment-grade today:
StorageExperiment(Exe, CLI harness —Program.cs);IStorageBackend(cohesion-bleeding interface, with explicitTODOfor splitting);ClickHouseBackend(the bulk of working code — schemas, async_insert lever, parameterised SQL, allowlisted DDL);StreamingOrchestrator(the validated 100K path, with parallelism, merge cap, season-accumulator integration). - The validated-but-disposable distinction: the StorageExperiment was scoped from the outset as "experiment-grade (disposable)" — the harness, experiments, results writer, and Docker Compose are scaffolding. The ClickHouse persistence layer underneath is the keepable bit.
What the rework involves¶
A reasonable scope for the rework — derived from the experiment status doc, the IStorageBackend TODO, and D4:
- Promote the ClickHouse layer out of
StorageExperiment. Lands inLBS.OutcomeContext.Storage(sport-agnostic; already housesIBlobSinkafter Phase 0r-2). Avoid putting sport-agnostic infrastructure underLBS.Model.AmericanFootball.*— that's the recurring naming mistake we corrected in Phase 0r and Phase 0r-2. - Split
IStorageBackend. TheTODOis explicit — intoIStorageBackend(schema/metrics),IOutcomeContextStore(OC R/W + streaming),IPlayByPlayStore,IAnalyticalQueryable. D4 lives in the OC interface; that split is the work that delivers it. - Promote the StreamingOrchestrator into the production assembly (or the parts of it that are operational — chunked write, staging-merge, async_insert lever, parallelism cap).
- Retire the experiment harness.
StorageExperimentExe + experiments stay around as a benchmark/diagnostic harness, but the production storage code lives elsewhere.
Overlaps with D4¶
D4 isn't a separate piece of work — it's the OC-read facet of the rework's interface split. Specifically:
- D4's
GetByScopeIdAsync(scopeId, contextVersion?)↔ existingReadGameOutcomeContextAsync/ReadSeasonOutcomeContextAsync(the scope discriminator is what unifies them — see D4 sub-design). - D4's bulk-read shape ↔ a new method, since the experiment never needed it (no GraphQL bulk fetch existed).
- D4's projection (subset of outcome IDs) ↔ existing
ReadSelectiveGameOutcomeContextAsync— already implemented, just needs the right interface placement. - D4's
IOutcomeTemplateCatalogaccessor ↔ part of the D3 work (catalog persistence target). - D4's
IPreSimulationCatalogaccessor ↔ part of the D3 work.
What needs to happen on the storage side before D4 can land¶
- Decision on: extend
Storageor newStorage.ClickHouse. Recommendation: extend. - The interface split per the IStorageBackend TODO — done at the same time as D4.
- ClickHouseBackend lift from
StorageExperimentinto the production assembly. Code stays the same; namespace and project change. - Retire
IStorageBackend.ReadGameOutcomeContextAsyncetc. as the surface for query-layer reads — the Query project depends onIOutcomeContextStoreonly.
This makes Phase 4 a coordinated drop with the storage rework rather than a standalone task. Open question for Mark in §6 — is the storage rework planned for the same window as the query layer, or does the query layer need to go in first against the existing IStorageBackend and migrate later? Both are tractable; the first is cleaner.
4. Pull-across from the prototype¶
Walked through FoundryQueryProto.cs (1,862 lines, single file). The inventory:
4.1 Move as-is (with Foundry-convention rewrites)¶
These are sport-agnostic, well-bounded, and don't change shape under D1–D4. The "rewrite" is purely mechanical: enum → string constants, file headers, this. prefix, camelCase fields, copyright headers.
| Prototype | New location | Notes |
|---|---|---|
IOutcomeContext |
Query/IOutcomeContext.cs |
No changes. |
PostfixToken hierarchy + Operation enum |
Query/Expressions/PostfixToken.cs (record hierarchy stays); Query/Expressions/Operation.cs (enum → string constants) |
Operation becomes public static class Operation { public const string Add = "ADD"; ... }. |
InfixToPostfix |
Query/Expressions/InfixToPostfix.cs |
Direct port. |
ExpressionInput and inputs |
Query/Expressions/ExpressionInput.cs (sealed class), OutcomeRefInput.cs, BinaryExprInput.cs, UnaryExprInput.cs |
Direct port. The BinaryOp / UnaryOp / ContextType enums become string-constant classes; HotChocolate's EnumType<> bindings re-map them on the GraphQL surface. |
TypeChecker, DefaultTypeRules, TypeRule, RuleContext, EvalWarning, EvalError, EffectiveType, RuleSeverity |
Query/Evaluation/TypeChecker.cs etc. |
Mechanical port. |
PostfixEvaluator, EvaluationInternalResult |
Query/Evaluation/PostfixEvaluator.cs |
Mechanical port. |
Outcome, OutcomeFilter |
Query/Discovery/Outcome.cs, OutcomeFilter.cs |
Direct port. |
EvaluationResult, EvaluationEngine, ResultType |
Query/Evaluation/EvaluationResult.cs, EvaluationEngine.cs |
Direct port. ResultType → string constants. |
HotChocolate type bindings (OutcomeTypeEnumType, TimePeriodEnumType, etc.) |
Query/GraphQL/*Type.cs |
Mechanical, but needs the corresponding string-constant classes shaped to give HotChocolate the enum-name → constant mapping it expects. |
Query, GameContext, SeasonContext resolver classes |
Query/GraphQL/Query.cs, GameContext.cs, SeasonContext.cs |
Direct port; D1 strips Rule 3 in Phase 1. |
4.2 Move with rewrite¶
| Prototype | New location | Why a rewrite |
|---|---|---|
OutcomeIdParser |
Accumulation/OutcomeIdParser.cs |
Per gap analysis §"Cleanups required by side — Storage", moves to Accumulation so both sides reference one parser. The parser implementation is fine; only the location changes. |
OutcomeCatalog (the prototype's flat dictionary) |
Query/Discovery/OutcomeCatalog.cs (D2/D3-shaped) |
Under D3 the catalog becomes templated. The prototype's flat-dictionary shape is wrong for the post-convergence world. The new shape has two halves: IOutcomeTemplateCatalog (storage) and a runtime OutcomeCatalog (in-memory, query-side, materialised by combining templates with the run's pre-sim catalog). The prototype's two methods (TryGet, All) survive; the constructor changes. |
ContextRepository |
Query/Adapters/ContextRepository.cs |
The prototype's BuildFixtures() retires under D4 — fixtures come from IOutcomeContextStore. The class shrinks substantially: it becomes a thin adapter that loads IOutcomeContext instances on demand, plus the OutcomeCatalog getter. The hard-coded NFL data goes (or moves to a test fixture). |
4.3 Move with structural change¶
| Prototype | New location | Change |
|---|---|---|
ExpressionCanonical.ExpandDerived |
Query/Expressions/ExpressionCanonical.cs |
Today walks an ExpressionInput tree (the Definition field is ExpressionInput?). Under D2 the field is a postfix template string? — ExpandDerived becomes (i) parse template postfix into tokens (or directly substitute into postfix-tokens form), (ii) substitute {slotName} placeholders with the consumer leaf's matching values, (iii) splice into the larger token stream. The infix-tree path goes away on the query side. |
OutcomeDefinition (prototype) |
Already in Accumulation |
The prototype carries its own OutcomeDefinition with ExpressionInput? Definition. That duplicate goes; the storage-side type gains the Definition field per D2 (as a postfix-template string?, not an ExpressionInput?). |
4.4 Drop / replace entirely¶
| Prototype | Why dropped |
|---|---|
ContextRepository.BuildFixtures() (the in-memory fixtures) |
Replaced by IOutcomeContextStore reads in D4. Some of the synthetic data may live on as test fixtures; the production code path goes. |
kc_lar_NEXT, kc_den_w3 fixtures |
Drop in D1. Their reason for existing (Rule 3 trigger) is gone. |
CONTEXT_VERSION_MISMATCH error code |
Drop in D1. |
| Single-file structure | The prototype is dotnet run FoundryQueryProto.cs (a .NET 10 file-based app). Splitting into per-concern .cs files in a real .csproj is part of Phase 0. |
4.5 Closed OutcomeType enum¶
The prototype's closed OutcomeType enum (~11 values like ANYTIME_TD, TOTAL_TDS, SCORING_RANK) is far narrower than the ~850 outcome IDs the Accumulation library actually emits. Per gap analysis "Minor gaps #1", the recommendation is open-string + catalog membership check at parse time. This goes from prototype enum → catalog-validated string under D3 (when the templated catalog becomes the authority). For Phase 0 the enum survives as-is so the port doesn't change behaviour; D3 retires it.
5. Sub-designs that block implementation¶
Three sub-designs are deliberately open per the gap analysis. Each one blocks the corresponding D-task. Below: what each task needs answered, and a recommendation for how to firm them up.
5.1 D2 build pipeline — blocks Phase 2¶
What Phase 2 needs answered before it can land:
- Authoring source of truth. Where do template authors edit derivations? Three plausible options listed in the gap analysis: a
derived-outcomes.yamlfile, a code-generated DSL, or hand-written postfix in the catalog table. Phase 2 needs to know which — the canonicalisation pipeline (infix → postfix template) only exists in option 2 + part of option 1. - Postfix template grammar. What does
{participantId}and{otherParticipantId}actually look like in the postfix string? Recommendation:{slotName}as a literal token in the postfix stream — slot tokens are first-class alongside outcome / const / op tokens. The tokeniser splits on|(existing prototype convention) and recognises{...}as a slot. - Build-time validation. Does the build step typecheck each definition (per
TypeChecker.Check)? Verify referenced raw templates exist? Detect cycles? My answer: yes to all three — the alternative is silentUNKNOWN_OUTCOME_IDat evaluation time. This implies the build step uses the sameTypeCheckerandOutcomeCatalogmachinery. - Catalog versioning. How does a template change interact with the §11.5 cache key? Spec §11.8.6 #3 already flags it. Recommendation: each template carries a
catalogVersionfield; the cache key is(expression_hash, worldSetRef, catalogVersion). The version bumps when any template changes.
How to firm it up: A short standalone sub-design doc — docs/outcome-context/sub-designs/d2-derivation-build-pipeline.md. ~3–4 pages. Done before Phase 2 starts. Owner: storage + query, jointly (the build pipeline lives between them).
5.2 D3 pre-simulation catalog — blocks Phase 3¶
What Phase 3 needs answered before it can land:
- Canonical name.
SimulationCatalog?RunManifest?FixtureCatalog? My recommendation:SimulationCatalog— closest to the existing vocabulary. Each name implies different framing; pick one before code. - Persistence target. Marten document store, ClickHouse table, or flat JSON per run? Recommendation: Marten document — reads frequently during a run, queryable for discovery, small enough that a document store fits, and Foundry already has the document-store stack. ClickHouse is overkill for ~one document per run.
- Identification scheme. How does a context reference "the pre-sim catalog for this run" given Rule 3 is dropped? Recommendation:
(seasonId, contextVersion)is the natural key — already on every context, monotonic, idempotent under the existingReplacingMergeTreesemantics. No new run id is needed. - Slot grammar. How does a template declare what its placeholders bind to? Recommendation: a small grammar —
{slotName: roleType}whereroleTypeis one ofparticipant | team | opposingParticipant | season. Authored alongside the template. - Lifecycle. Is the pre-sim catalog mutable mid-run (injuries, substitutions)? Recommendation: no — locked at sim start matches the run-as-coordinated-unit model. Mid-run mutability would push into Rule 3 territory the gap analysis explicitly avoids.
How to firm it up: Short sub-design doc — docs/outcome-context/sub-designs/d3-pre-simulation-catalog.md. ~4–5 pages. Done before Phase 3 starts. Owner: storage (catalog persistence, lifecycle, identification) with query-layer review (slot grammar consumer side).
5.3 D4 IOutcomeContextStore method shape — blocks Phase 4¶
What Phase 4 needs answered before it can land:
- Run-version semantics when omitted. When the caller doesn't specify
contextVersion, what's returned — latest? latest-before-cutoff? Recommendation: latest, becauseReplacingMergeTree(context_version)already resolves to MAX server-side. But this needs to be policy on the interface, not an accident of merge state — document it on the method, not in the implementation. - Bulk read shape. A
seasonContext(seasonId, gameIds:)query loads 1 + N contexts. Recommendation: explicitGetManyByScopeIdAsync(IReadOnlyList<string> scopeIds, ...)on the interface, implementation does oneSELECT … WHERE scope_id IN (...)per table. Avoids N+1 entirely. - Projection. A query that only references three outcome IDs shouldn't fetch all ~500 rows. The existing experiment already has
ReadSelectiveGameOutcomeContextAsync(gameId, IReadOnlyList<string> outcomeIds, ...). Recommendation: lift it to the interface as an optional parameter onGetByScopeIdAsync/GetManyByScopeIdAsync(IReadOnlyList<string>? outcomeIds = null). - One store or two for catalogues. Does the same
IOutcomeContextStoreserve the template catalog and the pre-sim catalog, or do they split? Recommendation: split. The OC store is per-context reads; the catalogues are different read patterns and probably different persistence targets (template catalog → ClickHouse or Marten; pre-sim catalog → Marten). Two interfaces (IOutcomeContextStore,IOutcomeTemplateCatalog,IPreSimulationCatalog) cleanly served from the same Storage project.
How to firm it up: Short sub-design doc — docs/outcome-context/sub-designs/d4-store-interface.md. ~3 pages. Done in tandem with the storage rework (§3) — this isn't a Query-project decision, it's a storage decision the Query project consumes. Owner: storage.
6. Open questions for Mark¶
These are choices I can't make from the docs alone. Each is flagged with the options I see and a default recommendation; please confirm or redirect.
6.1 One Query project, or split engine vs sport binding?¶
Options:
- A. [Original recommendation, since revised — see §1.] Single LBS.Model.AmericanFootball.Query project housing both the sport-agnostic engine and the AmericanFootball-specific binding.
- B. [Adopted.] Sport-agnostic split: LBS.OutcomeContext.Contracts + LBS.OutcomeContext.Query + LBS.OutcomeContext.QueryApi. Sport-coupled code stays in LBS.Model.AmericanFootball.Accumulation.
Recommendation: A. Only one sport today; the engine has clear ring boundaries inside the project; lifting it later if a second sport materialises is mechanical.
6.2 Where does the GraphQL endpoint live?¶
Options:
- A. New app LBS.OutcomeContext.QueryApi under src/Apps/.
- B. Mount on existing LBS.Api.
- C. Mount on existing Ballr.WebApi.
Recommendation: A. Different consumer audience (model-evaluation clients vs domain APIs), independent deploy/scale, doesn't entangle with existing FastEndpoints surfaces.
6.3 Storage rework timing — same window as query layer, or before?¶
Options:
- A. Storage rework happens first (or concurrently). The Query project depends on the new IOutcomeContextStore from day one. Phase 4 lands cleanly.
- B. Query layer goes in against the existing IStorageBackend and migrates to IOutcomeContextStore later. Phase 4 has two steps: get on the existing surface, then migrate.
Recommendation: A. Tight coupling between D4 and the storage interface split; doing them together once is cheaper than doing them once-and-a-half. But this depends on capacity — if the storage rework can't start for weeks, B is the right call.
6.4 Catalog persistence target¶
Options for the outcome template catalog (D2/D3):
- A. ClickHouse table (outcome_catalog) — matches spec §9.2 sketch.
- B. Marten document — fits Foundry's existing document-store stack.
- C. Generated config file (in-memory at startup, source-of-truth in the repo).
Options for the pre-simulation catalog (D3): - A. Marten document per run. - B. ClickHouse table. - C. JSON document on object store, indexed in Marten.
Recommendation: Templates → C (generated config) for now, B (Marten) once authoring tooling exists. Pre-sim catalog → A (Marten document per run). Both matters live in their respective sub-design docs (§5.1, §5.2) and need ratification before code.
6.5 Do we keep the prototype source repo around?¶
The prototype repo is redundant now that Phase 0–4 have landed. Options: - A. Archive it in-place; mark as superseded. - B. Delete it; the gap analysis + reference docs are the new source of record. - C. Keep it as the reference implementation for the spec's Wave 2/3 features that haven't been ported yet.
Recommendation: C until Wave 1 is fully landed; then A.
6.6 Spec ownership going forward¶
Foundry_Query_Spec.md (1,365 lines) lives in the prototype repo. Several sections are explicitly affected by D1–D4 (gap analysis §"Cleanups required by side — Prototype"). Options:
- A. Move the spec to lbs.foundry/docs/ so it's edited alongside the code that implements it.
- B. Leave it in the prototype repo, noting in the convergence reference that it's the long-form doc.
- C. Promote sections of it into Foundry's docs tree as they're implemented (incremental migration).
Recommendation: C — promote section by section as each phase lands. The convergence reference covers the post-D1–D4 shape; the spec sections that are already obsolete can be retired from the prototype copy as part of D1's "spec impact" cleanup.
7. What happens after sign-off¶
Once this plan is signed off:
- Open the three sub-design docs (§5.1, §5.2, §5.3) as empty drafts so the work is visible.
- Begin Phase 0 — port the prototype engine. Chunk into the 5 mergeable steps in §2 Phase 0 row.
- Phase 1 (D1) follows directly off Phase 0's last chunk.
- Phases 2/3/4 land as their sub-designs firm up, in that order.
Each phase emits a working set of changes that can be reviewed and merged independently — the plan does not produce a single mega-PR.
8. References¶
- Gap analysis — D1–D4 decisions and rationale.
- Convergence reference — post-D1–D4 system shape.
- Storage experiment status — what the storage rework absorbs.