D3 sub-design — Pre-simulation catalog¶
Status: Implemented on branch LBS-1183 (PR #1389). Live shape: SimulationCatalog (LBS.OutcomeContext.Contracts), ISimulationCatalogStore + MartenSimulationCatalogStore (LBS.OutcomeContext.Storage/Marten), IOutcomeTemplateCatalog + InMemoryOutcomeTemplateCatalog (LBS.OutcomeContext.Storage). The materialiser is OutcomeCatalogMaterializer.Materialise(templates, simCatalog). The body below is preserved as the as-designed reference.
Reads alongside: gap analysis, build plan implementation status.
D3 splits the storage-side IOutcomeCatalogue into a roster-agnostic outcome template catalog and a roster-driven pre-simulation catalog. This sub-design fills in the pre-simulation catalog's shape.
What's settled¶
From the gap analysis D3 resolution:
- Two catalogues, separated by cadence: templates change at model-design cadence (rare); the pre-sim catalog changes at run cadence (every sim).
- Concrete outcome IDs are produced by combining the two: each template plus the relevant participants from the pre-sim catalog yields one concrete ID.
IOutcomeCatalogue(storage) splits intoIOutcomeTemplateCatalog+ a pre-sim catalog producer.
Open questions¶
Q1 — Canonical name¶
The gap analysis flags this: SimulationCatalog? RunManifest? FixtureCatalog?
| Option | Implies |
|---|---|
A SimulationCatalog |
Closest to existing vocabulary. Makes "the catalog of things this simulation knows about" the natural mental model. |
B RunManifest |
Implies an inventory-style document; signals "list of stuff in this run" without committing to catalog semantics. |
C FixtureCatalog |
Narrower - just fixtures (games / schedule). Misses the rosters / players dimension. |
Recommendation: A — SimulationCatalog. Matches the convergence reference's "pre-simulation catalog" prose, signals the simulation-coupled lifecycle, and the word "catalog" is already in the team vocabulary via IOutcomeCatalogue.
If A wins, the storage-side interface name becomes ISimulationCatalogStore (and the read API on IOutcomeContextStore becomes GetSimulationCatalogAsync(...)).
Q2 — Persistence target¶
| Option | Pros | Cons |
|---|---|---|
| A Marten document store | Foundry already has Marten; rich query capability; one document per run is a natural fit for the document store; transactional with the rest of Foundry's writes | Requires Marten configuration for a new store or document type |
| B ClickHouse table | Co-located with OC data; one query reaches both | ClickHouse is poorly suited to single-document reads; document-shape mapping is awkward |
| C Flat JSON document on object store, indexed in Marten | Cheap; simple; easy to re-read across systems | Two storage layers to coordinate; harder to query for ad-hoc analysis |
Recommendation: A — Marten document. The pre-sim catalog is read frequently during a run, queryable for discovery, and small (one document per run per sport). Foundry's existing document-store stack (claude.md "Database stores" section names five Marten stores) absorbs this naturally; ClickHouse is the wrong shape for single-document semantics.
The Foundry store dimension to use is open - probably IFoundryStore (the most general), unless there's an operational reason to keep simulation artefacts in a dedicated store.
Q3 — Identification scheme¶
Even with Rule 3 dropped, the pre-sim catalog itself needs a stable id so contexts can reference "the catalog for this run."
Recommendation: (seasonId, contextVersion). Both fields are already on every GameOutcomeContext / SeasonOutcomeContext. They're monotonic, idempotent under the existing ReplacingMergeTree(context_version) semantics, and don't require a new identifier surface. A re-run at the same (seasonId, contextVersion) overwrites both the OC data and the catalog - aligned semantics.
Alternative: introduce a separate runId UUID. This was the original Rule-3 path the gap analysis rejected (see D1 Resolution); the same reasons apply here - extra surface, no real benefit, and the catalog is already coupled to the season+version pair.
Q4 — Slot / role grammar¶
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_{participantId} referencing {otherParticipantId}) need richer slot semantics.
Recommendation: a small role grammar - {slotName: roleType} where roleType is one of:
| Role | Binds to | Example |
|---|---|---|
participant |
Any participant in the materialised context | {participantId: participant} |
team |
One of the two teams playing the game | {teamAbbr: team} |
opposingParticipant |
A participant other than the one in {participantId: participant} |
{otherParticipantId: opposingParticipant} |
season |
The season scope itself | {seasonId: season} |
In the YAML authoring source (per D2 Q1) the role is declared with the slot:
- id: TOP_SCORER_SEASON_{participantId}
category: SeasonRanking
valueType: Boolean
slots:
participantId: participant
otherParticipantId: opposingParticipant
definition: TOTAL_TDS_SEASON_{participantId} > TOTAL_TDS_SEASON_{otherParticipantId}
The build step (D2) emits the slot metadata into the persisted template catalog. The runtime substitution path (D2's ExpandDerived) consults the slot metadata when materialising a concrete consumer reference.
Cross-coordination with D2: the role grammar is part of the persisted template catalog, but the consumer-side substitution semantics live in ExpandDerived. Both sub-designs need to agree on the same shape.
Q5 — Lifecycle¶
Is the pre-sim catalog mutable mid-run (e.g. injury substitutions producing new player rosters)?
Recommendation: no - locked at sim start. The whole D1 resolution rests on the orchestrator being the source of correlation. Mid-run mutation breaks that property: a context produced in chunk 5 of a 200-chunk run would reference a different roster than the one from chunk 1. Dragging the integrity-checking surface back in is exactly what D1 was rejecting.
If a real product driver appears for mid-run substitutions, the right shape is a new run with a new (seasonId, contextVersion) - the existing semantics already handle this.
Q6 — Read interface placement¶
This bleeds into D4 (build plan §6.4 Question 4): "One store or two for catalogues. Does the same IOutcomeContextStore serve the template catalog and the pre-sim catalog, or do they split?"
Recommendation: two interfaces, both served by the same Storage project. IOutcomeContextStore (per-context reads), IOutcomeTemplateCatalog (template lookup), ISimulationCatalogStore (run-scoped catalog read by (seasonId, contextVersion)). All three are surfaces on the Storage assembly; the implementation can fan out to one or many backends as needed.
The query layer's ContextRepository (D4) takes constructor dependencies on all three (or whichever it actually needs at the call site).
Phase 3 implementation notes¶
Once this sub-design is signed off, Phase 3 (D3) should:
- Define the new types in
LBS.OutcomeContext.Contracts(sport-agnostic):OutcomeTemplate(with slots + roles),TemplateSlot, the persistedSimulationCatalogdocument type. Update the Mermaid ERD. - Refactor
IOutcomeCatalogueinLBS.Model.AmericanFootball.Accumulationto split intoIOutcomeTemplateCatalog(sport-agnostic, lands in Contracts; noTeamarguments - emits raw + derived templates) and aSimulationCatalog-producing path (AmericanFootballSimulationCatalogBuilderor similar, stays in Accumulation - takesIReadOnlyList<Team>+ schedule, emits the run's persisted catalog). - Adjust the accumulator to consume the new shape. Today the accumulator calls
IOutcomeCatalogue.GetGameOutcomeDefinitions(home, away)to know which outcome IDs to emit; under D3 this changes shape - it walks templates × the run's roster to materialise concrete IDs. - Persist the SimulationCatalog on sim start. The orchestrator currently doesn't think about catalogs; this is a real wiring change.
- Add the
IOutcomeTemplateCatalogandISimulationCatalogStoreaccessors to the storage rework's interface split. - Update the runtime
OutcomeCatalogin the Query project to materialise its in-memory catalog fromIOutcomeTemplateCatalog×ISimulationCatalogStorereads at query time. - Discovery rewrite:
outcomes(filter)on a context materialises concretes from templates × the run's pre-sim catalog.outcomeDefinitions(filter)at root returns templates by default; an optionalrunIdparameter materialises concretes scoped to a specific run. - Spec impact: §11.1 (discovery) needs a rewrite. §11.2 (entity resolution) likely simplifies - entities become first-class in the pre-sim catalog.
Cross-references¶
D2 Q2 (postfix template grammar) and D3 Q4 (slot/role grammar) are tightly coupled. The simplest path: both sub-designs commit to the same {slotName: roleType} shape, with D2 owning the runtime substitution and D3 owning the persisted slot metadata.
References¶
- Gap analysis §"D3 - Catalog discovery" + §"D3 follow-up"
- Convergence reference §4 (the two catalogues)
- Build plan §2 Phase 3 + §5.2
- Spec §11.1 (discovery), §11.2 (entity resolution)