Skip to content

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 into IOutcomeTemplateCatalog + 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:

  1. Define the new types in LBS.OutcomeContext.Contracts (sport-agnostic): OutcomeTemplate (with slots + roles), TemplateSlot, the persisted SimulationCatalog document type. Update the Mermaid ERD.
  2. Refactor IOutcomeCatalogue in LBS.Model.AmericanFootball.Accumulation to split into IOutcomeTemplateCatalog (sport-agnostic, lands in Contracts; no Team arguments - emits raw + derived templates) and a SimulationCatalog-producing path (AmericanFootballSimulationCatalogBuilder or similar, stays in Accumulation - takes IReadOnlyList<Team> + schedule, emits the run's persisted catalog).
  3. 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.
  4. Persist the SimulationCatalog on sim start. The orchestrator currently doesn't think about catalogs; this is a real wiring change.
  5. Add the IOutcomeTemplateCatalog and ISimulationCatalogStore accessors to the storage rework's interface split.
  6. Update the runtime OutcomeCatalog in the Query project to materialise its in-memory catalog from IOutcomeTemplateCatalog × ISimulationCatalogStore reads at query time.
  7. 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 optional runId parameter materialises concretes scoped to a specific run.
  8. 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