Model Plug-and-Play Layer — In-Depth Design #

Companion to architecture.md (Pricing Layer), flows.md (Flows 0, 1, 6). This is the engineering specification for how probabilistic models plug into the pricing layer without touching the spine, while preserving the four system guarantees: bit-exact determinism, lineage, gated evaluation, and provability.

Contents:

  1. Design principles
  2. The model contract
  3. Fixed-point number system
  4. Artifact format specification
  5. The GBM evaluator
  6. Deterministic softmax and exact-sum normalization
  7. The enforcement wrapper
  8. Feature schema versioning
  9. Registry and control stream
  10. Admission pipeline
  11. Shadow mode, promotion, and binding semantics
  12. Runtime execution model
  13. Python SDK and the exporter
  14. Failure modes and guard rails
  15. Testing strategy
  16. Security considerations
  17. ZK provability path
  18. Scope
  19. Appendix — full visual breakdown of every model in the system

1. Design principles #

P1 — Models plug in as data, not code. A model is an artifact file executed by a built-in deterministic evaluator that ships with the runtime. Adding a model means storing an artifact and appending a registration event: no rebuild, no new code on the spine. Code-plugged models (a Rust crate implementing the trait) are an escape hatch for genuinely new architectures, at a documented cost (§18).

P2 — The runtime owns the invariants, never the plugin. Domain constraints (truncation, exact-sum, validity) are enforced by a wrapper the runtime controls. A malicious or buggy artifact can produce a bad forecast; it cannot produce an invalid distribution on a stream, and it cannot quote.

P3 — Every lifecycle action is an event. Registration, validation results, shadow, promotion, suspension are appends to a control stream. A replayed run reproduces not just prices but which models existed, when, and what they were bound to. There are no mutable config files on the spine.

P4 — Bit-exactness over approximate equality. Every numeric operation in the inference path is integer arithmetic with pinned semantics. Two implementations of this spec (Rust runtime, Python reference) must agree to the last bit; parity is enforced by golden vectors, not tolerances. "Close enough" float comparisons are how determinism dies and how the ZK path closes.


2. The model contract #

/// Identity = BLAKE3 of artifact header (hash field zeroed) ‖ body (§4).
/// Covers interpretation context (format, schema), not just parameters.
pub struct ModelId(pub [u8; 32]);

pub trait KillDistributionModel {
    fn model_id(&self) -> ModelId;
    fn feature_schema(&self) -> FeatureSchemaId;
    /// Pure function: no I/O, no clock, no RNG, no interior mutability.
    /// Returns the RAW distribution over k = 0..=5 in Q0.16 units.
    /// MUST sum to exactly SCALE (65_536). Violations are caught by the
    /// wrapper (§7), never trusted.
    fn predict(&self, features: &FeatureVector) -> RawDistribution;
}

pub struct RawDistribution(pub [u32; 6]); // parts of SCALE

Contract semantics:

  • Stateless per tick. If a model needs cross-tick memory, that state must be a declared fold of its input stream — the same law as every transformer in the system. No hidden state, ever.
  • Total function. predict must return for every well-formed input vector of its declared schema. "I can't price this" is expressed as a distribution the wrapper rejects (§7), never as a panic. Panics are treated as model_output_invalid with a panic reason code and count against coverage.
  • No allocation requirements imposed — but the built-in evaluators are zero-allocation on the hot path (§12).

3. Fixed-point number system #

There is no single universal Q-format; each quantity has a pinned encoding.

Quantity Type Encoding Notes
Probability u32 Q0.16 — parts of SCALE = 2^16 = 65_536 distribution sums to exactly SCALE
Model score (per class) i64 Q31.16 accumulator sum of leaf values, see overflow analysis
Leaf value i32 Q15.16 written by the exporter
Feature value i32 per-feature encoding from the schema (§8) e.g. health = raw 0..100, x-coordinate = map units × 256, round clock = ticks
Threshold i32 same encoding as the feature it splits on comparison is integer <=

Overflow analysis (pinned as format limits, checked at parse time): with format caps of ≤ 10 000 trees and leaf magnitude < 2^31, the worst-case class score is < 10^4 × 2^31 ≈ 2^44.3 — comfortably inside i64. The parser rejects artifacts whose static worst-case bound (including score_rescale) exceeds i64::MAX / 2 — half, not full, because §6's max-subtraction takes the difference of two in-bound scores, and that difference must also fit i64 (the bound is pinned).

Why per-feature integer encodings instead of normalized floats: the quantization grid is defined once, in the schema, and shared by the feature extractor, the exporter, and the evaluator. Thresholds are quantized onto the same grid as the features they split (§13), which is what makes tree traversal exact rather than approximately-right near split boundaries.


4. Artifact format specification #

Binary, little-endian, section-based. Canonical bytes: there is exactly one valid serialization of a given model (no optional padding, sections in fixed order, no floating-point anywhere in the body).

offset  size  field
──────  ────  ─────────────────────────────────────────────
HEADER (fixed 96 bytes)
0       8     magic "D2MODEL\0"
8       4     format_id        (1 = gbm-quantized)
12      4     format_version   (1)
16      32    feature_schema_id (BLAKE3 of the schema definition, §8)
48      32    content_hash     (= ModelId: BLAKE3 of HEADER with this
                               field zeroed, concatenated with BODY —
                               identity must cover format and schema, or
                               identical trees under two schemas collide)
80      8     body_len (u64)
88      8     meta_len (u64)

BODY (format-specific; for gbm-quantized/1)
        4     n_classes        (2..=MAX_CLASSES=8; 6 = CS2 kills, 2 = LoL win/loss
                               — format v2; the floor is two: one class is no
                               outcome space a model can quote, refused at parse; the wire
                               width's floor is event-catalog §6.4's. A 6-class
                               artifact is byte-identical to format v1:
                               n_classes already lived
                               in the hashed body, so its ModelId is unchanged.)
        4     n_trees_total
        4     n_features       (must match schema)
        8     score_rescale    (Q31.16, applied once per class after summation;
                               carries the log2(e) base conversion, §6/§13)
        per tree:
        4     class_index (0..n_classes)
        4     n_nodes
        per node (fixed 16 bytes, index order = preorder):
          4   feature_index    (u32; MSB set = leaf)
          4   threshold_or_leaf_value (i32)
          4   left_child_index (u32)
          4   right_child_index (u32)

METADATA (informational; hashed separately as meta_hash in the
registration event — content NOT part of ModelId; its length is,
via the header's meta_len — see rules below)
        CBOR map: training lineage {dataset_split_hash, training_code_commit,
        hyperparameters, produced_by_run_id, created_at},
        golden_vectors: array of {input: [i32; n_features], output: [u32; n_classes]}
        (minimum 1024 vectors, coverage rules in §15)

Metadata encoding pinned: the metadata section is the platform's canonical integer-keyed CBOR (the stream-core codec — one canonical form everywhere), a two-field map {0: lineage map, 1: golden-vector array of [input, output] pairs}. The lineage map's own fields are informational and title-tooling-defined; golden vectors are structurally validated at parse (input length = n_features, output an exact-SCALE distribution), while the 1024-minimum and §15 coverage rules are gate-1 policy, not parse rules — a tiny test artifact parses, but it can never be admitted.

Rules:

  • ModelId covers header and body, never metadata CONTENT: identity is behavior plus interpretation context. Re-exporting with different same-length notes yields the same ModelId; changing one leaf value, the format version, or the bound schema yields a different one. Precision note (found by the cross-language parity gates): the hash input is the HEADER with the content_hash field zeroed — and the header includes meta_len — so identity covers the metadata section's LENGTH, though never its bytes. Both implementations (the pricing encoder/parser and the §13 Python exporter) agree on this construction; it is the recorded normative form.
  • Canonical bytes are enforced, not assumed: unused fields (a leaf node's child indices, reserved bits) MUST be zero, and the parser rejects violations — otherwise two byte-different serializations of the same model would both be "valid" and ModelId would not be unique.
  • Traversal semantics are part of the format: at a non-leaf node, go left iff feature[feature_index] <= threshold, else right. No missing-value branch in format v1 — the schema guarantees all features present (§8); an extractor emitting an absent feature is a schema violation upstream, not a model concern.
  • Parse-time validation (all rejections are admission failures, §10): magic/version match, section lengths consistent, content_hash verified, every child index in range and acyclic (checked via preorder bound), leaf nodes' child fields exactly zero, class indices in range, worst-case score bound (including score_rescale, §5) fits the widened arithmetic, caps of §16.

The runtime ships one evaluator per format. Every artifact of that format inherits the evaluator's determinism, auditability, and provability. This is the central plug-and-play economy: N models, one audited execution path.


5. The GBM evaluator #

Reference algorithm (normative):

evaluate(artifact, features: [i32; n_features]) -> RawDistribution:
    n = artifact.n_classes                       # 6 = CS2, 2 = LoL (format v2)
    scores = [0_i64; n]
    for tree in artifact.trees:                  # artifact order, fixed
        node = tree.root
        while node is not leaf:
            f = features[node.feature_index]
            node = node.left if f <= node.threshold else node.right
        scores[tree.class_index] += node.leaf_value as i64
    for c in 0..n:
        # widened Q-multiply: i64 × i64 can exceed i64 (scores reach ~2^44,
        # rescale is Q31.16), so the intermediate MUST be 128-bit
        scores[c] = ((scores[c] as i128 * artifact.score_rescale as i128) >> 16) as i64
    return softmax2_exact(scores)                # §6
    # (Implementation: scores is a [i64; MAX_CLASSES] stack buffer used over
    #  0..n — no per-evaluation allocation.)

Properties: integer-only, branch decisions exact on the shared quantization grid, iteration order fixed by artifact bytes, zero allocation, O(total tree depth) per tick. At the demo scale (two models × two teams × ~500 trees × depth ~8, 4 Hz) this is microseconds — the 250 ms p99 budget is untouched.


6. Deterministic softmax and exact-sum normalization #

Softmax is the one transcendental step between tree scores and a probability distribution, and exp on floats is where cross-platform bit-equality dies. The spec removes it in two moves:

Move 1 — base-2 softmax. Define softmax2(s)_i = 2^(s_i) / Σ_j 2^(s_j). Base-2 softmax equals natural softmax when scores are pre-multiplied by log2(e). The exporter performs that rescale once at quantization time (folded into score_rescale, §4), so semantics match the trained model while the runtime never computes a natural exponential.

Move 2 — pinned integer exp2. Normative algorithm:

exp2_q(x: i64, Q.16 fixed-point, guaranteed x ≤ 0 by max-subtraction) -> u64:
    shift = x >> 16                        # arithmetic shift = floor(x / 2^16); shift ≤ 0
    if shift < -32: return 0               # below Q1.32 precision — exact zero by rule
    frac  = (x - (shift << 16)) as u32     # fractional exponent in [0, 65536)
    hi    = EXP2_LUT[frac >> 10]           # 65-entry table (indices 0..64), Q1.32,
    lo    = EXP2_LUT[(frac >> 10) + 1]     #   pinned constants; LUT[64] = exp2(1.0) domain edge
    interp = hi + (((lo - hi) * (frac & 0x3FF)) >> 10)   # integer lerp
    return interp >> (-shift)              # shift ≤ 0, so this is a RIGHT shift;
                                           #   never a left shift, never overflow

softmax2_exact(scores):
    m = max(scores); e_i = exp2_q(scores_i - m)   # all inputs ≤ 0
    # the max element has x = 0 → e = 2^32 exactly, so total ≥ 2^32 > 0 always
    return normalize_exact(e_i)                   # below

The 65-entry EXP2_LUT (indices 0..64 — the interpolation reads entry i + 1, so the table includes the exp2(1.0) domain edge) is generated once, embedded as constants in this spec's reference implementations (Rust and Python), and never regenerated at runtime. The LUT bytes are part of format_version — changing them is a new format.

Exact-sum normalization — largest-remainder (Hamilton) method (used both here and in the wrapper's renormalization, §7):

normalize_exact(w: [u64; n]) -> [u32; n] summing to exactly SCALE:
    # n = the model's n_classes (6 = CS2, 2 = LoL; format v2)
    total = Σ w_i                       # if 0 → invalid, wrapper handles
    p_i   = (w_i * SCALE) / total       # floor division
    r     = SCALE - Σ p_i               # residual units, 0..=n-1
    give +1 to the r entries with the largest remainder
        (w_i * SCALE) mod total, ties broken by LOWER index
    return p

Deterministic, exact, and order-independent given the tie-break rule. This tiny function is the reason "sums to exactly SCALE" can be a hard contract instead of an epsilon test.


7. The enforcement wrapper #

Every model executes inside a runtime-owned wrapper; the model never writes to its probability stream directly.

Per tick, per market (CS2: per team market; a match-winner title: the match's one market, on the raw path of step 2 — §12 title dispatch):

  1. Invoke predict (panics caught → invalid, reason panic).
  2. Validate raw output: sum == SCALE exactly, all <= SCALE, width 1..=MAX_CLASSES — the Distribution law, one home. Two paths : the CS2 kill-market path (enforce) additionally requires the kills family's N_CLASSES = 6 entries, because step 3's truncation is CS2-ordinal; a non-kills family (e.g. a 2-class match-winner model) takes the validate_raw path — steps 1, 2, 4, 5 with no step 3.
  3. Apply domain truncation (CS2 kill-market path only): zero out entries k < current_kills (from the same game-state event the features came from), then normalize_exact over the surviving support. If the surviving mass is zero → invalid, reason empty_support.
  4. Emit to the model's probability stream: constrained distribution + state_version + model_id + wrapper_version.
  5. On any invalidity: emit model_output_invalid {model_id, state_version, reason} to the control stream, emit no probability event for this tick. The bound bookmaker sees a missing tick and stays silent (no-quote beats bad-quote); the gap counts against the model's 95% coverage KPI (spec §10).

The wrapper is versioned and lineage-tagged: a probability stream's lineage records (model_id, wrapper_version, feature_schema_id) — the complete recipe for reproducing it.


8. Feature schema versioning #

A feature schema is a canonical CBOR document, identified by its BLAKE3 hash (FeatureSchemaId), specifying an ordered list of features:

{ name: "dust2/v1",
  features: [
    {idx: 0, name: "current_kills",  type: i32, encoding: "raw",        bounds: [0,5]},
    {idx: 1, name: "alive_own",      type: i32, encoding: "raw",        bounds: [0,5]},
    {idx: 2, name: "round_clock",    type: i32, encoding: "ticks",      bounds: [0, 7680]},
    {idx: 3, name: "bomb_state",     type: i32, encoding: "enum:not_planted|planted|defused|exploded"},
    {idx: 4, name: "team_side",      type: i32, encoding: "enum:T|CT"},
    ... health/armour per slot, weapon ids, positions ×256, recent-kill
        counters, advantage aggregates ...
  ],
  projections: { "dust2/baseline-v1": [0,1,2,3,4] } }

The concrete dust2/v1 vocabulary (hash-pinned in pricing::feature::dust2; the bomb enum follows the real event-catalog §6.1 tick vocabulary, which has four standing states, not the five once sketched here):

Indices Features Encoding Bounds
0–4 current_kills, alive_own, round_clock, bomb_state, team_side the baseline five (projection dust2/baseline-v1) as sketched above
5–6 alive_opp, kills_opp raw [0, 5]
7–21 hp_own_0..4, armor_own_0..4, weapon_own_0..4 raw / raw / weapon_id [0, 100] ×2, [0, 65535]
22–36 hp_opp_0..4, armor_opp_0..4, weapon_opp_0..4 same same
37–51 pos_own_{0..4}_{x,y,z} map_units_x256 [−2^23, 2^23−1]
52–66 pos_opp_{0..4}_{x,y,z} map_units_x256 same
67–68 recent_kills_own, recent_kills_opp raw — kill-tally delta over the last 8 game-state ticks; window resets at round boundaries [0, 5]
69–71 alive_advantage, hp_advantage, centroid_distance raw / raw / map_units_x256_manhattan (Manhattan distance between the sides' alive-player centroids, ×256 units) [−5, 5], [−500, 500], [0, 3·(2^24−1)] — two centroids can span the full coordinate cube

72 features. Slot blocks are fixed at five lanes; shorthanded sides (SC-3) pad missing slots with the dead-at-origin encoding (hp 0, armor 0, weapon 0, position 0/0/0) — padding is a DEFINED value, never an absent feature. Perspective (*_own vs *_opp) is baked into the vector, and team_side encodes the SIDE (T/CT — sides swap at halftime), not team identity: team and player identity remain features of neither model (§19.1, architecture.md feature contract), which is what lets one model price both team markets.

What the house model observes — cadence and scope (descriptive; the numeric contract is the table above). The extractor emits one full 72-feature vector per team, per game-state tick (§6.2 game-state stream); game-state ticks are the ~4 Hz DEFAULT decision cadence (event-catalog §6.12 rule 6 — the opencs2 corpus samples every 16th demo tick, and a demo tick is 64/s, so 64/16 = 4 Hz). The stride is not fixed: sample_every is a frozen RunManifest field (default 16 — omit-at-default: the encoder omits it at 16 and a document spelling 4: 16 explicitly is refused, §12), so a run may sample finer (e.g. stride 6 ≈ 100 ms ≈ 10 Hz), and the served match log must be materialized at the run's stride — the serve/re-run edge refuses a cadence mismatch, because the recent-kill window is counted in samples and its wall-time meaning is a function of the stride. At the default cadence the model sees roughly four snapshots per second, NOT the demo's full 64 Hz tick rate, so sub-250 ms events between snapshots are not observed (the 250 ms spacing ties to the §5 p99 budget); a finer stride shrinks that spacing proportionally (stride 6 ≈ 10 snapshots/s, so it does observe sub-250 ms events). Each snapshot carries, for all ten players, alive / HP / armour / held-weapon and position (x, y, z) — so player movement IS captured, as position deltas between the (default ~4 Hz) snapshots — plus round clock, bomb state, kill tallies, recent-kill counters, and the advantage aggregates. What is NOT in dust2/v1: any per-shot or "who is firing" signal (combat enters only as KILLS — the outcome — plus the weapon each player holds), aim direction, economy/money, and grenade/utility state. gbm_v1 reads all 72 features; baseline_v1's trees are confined to the five-feature dust2/baseline-v1 projection (current_kills, alive_own, round_clock, bomb_state, team_side — no positions, HP, or weapons; it is fed the full vector but reads only those five columns, per the projection-consumption note below). Richer input — a finer sample rate, or shot/utility/economy events — is a new corpus plus a dust2/v2 schema (a new hash, never an edit — schemas are immutable, below), with the matching / pricing / settlement core untouched.

**Materializing at a run's cadence — store rich, reduce by parameter ** "The served match log must be materialized at the run's stride" (above) has two equivalent productions, and the serve box uses the second because it has neither the corpus nor parquet. (1) Re-materialize from the OpenCS2 source tables at the target stride (opencs2-edge, event-catalog §6.12 rules 1–11) — the local/offline path. (2) Resample a single finest- cadence master (stride 1, the 64 Hz enriched log materialized once) DOWN to the target stride by dropping the non-sample tick_states and re-sealing at the new cadence (ingest::cs2::resample). The two are byte-identical: resample(materialize(S), N) equals materialize(N) for every target stride N that the source stride S divides, because the normalizer emits every record in non-decreasing (replay_ts, class) order and the stride changes ONLY which tick_state ticks appear — every other §6.1 event is cadence-independent (§6.12 rule 6b), so the coarser stride's sample ticks are a strict subset of the finer stride's. This is what lets the box serve any researcher's or the house model's cadence from one stored master, and it never invents ticks: a target stride the source does not divide (upsampling, or a non- multiple) is refused, not approximated. The recent-kill window is counted in SAMPLES (8), so its wall-time span scales with the chosen stride — a fixed wall-time window would be a new schema (dust2/v3), not a cadence parameter.

dust2/v2. The forward reference above: dust2/v2 (hash c801b9d8…) is 158 features — the v1 72 as a VERBATIM PREFIX (indices 0..=71, so the baseline projection and every v1 feature meaning carry over) plus the enriched per-player motion the v1 vector lacked: aim (pitch/yaw + per-tick deltas), velocity (x/y/z), and the firing bit (the attack input — so "who is firing" IS observed in v2), plus six team aggregates (firing count, Manhattan speed sum, aim-activity, per side). It reads state/v2 (event-catalog §6.2), derived by the enriched opencs2-edge adapter at the NormalizeConfig cadence — which the run freezes as RunManifest.sample_every and the serve/re-run edge cross-checks against the match log's recorded stride. The "NOT in dust2/v1" list above still holds for v1; v2 closes the aim / velocity / firing gap. Still out (the rest of the v1 gap list): economy/money (out of scope), grenade/utility state, and per-damage attrition (deferred to a later schema — it rides a separate duels source table). The matching / pricing / settlement core is untouched — the evaluator reads a wider vector, no more. A model binds dust2/v2 exactly as v1 (the wrapper checks the schema id); the enrichment is a pure feature-layer change.

lol/v1. The first NON-CS2 feature schema, proving §8 is title-neutral: a second title reuses the same FeatureSchema/extractor machinery with a new vocabulary and a new hash, the matching / pricing / settlement core untouched. lol/v1 is 70 features extracted from the lol/state/v2 per-minute rich snapshot (event-catalog §6.15.3, ingest::lol::state_v2) — hash-pinned in pricing::feature::lol:

Indices Features Encoding Bounds
0 game_minute minutes (the snapshot frame_index, §6.15.3) [0, 200]
1–10 blue_{gold, xp, cs, level, kills, structures_lost, deaths, obj_s, obj_a, obj_b} gold ×2 / count ×8 — aggregates summed over slots 0..=4 (gold/xp/cs/level, and deaths from per_slot_deaths) + the genuinely-per-side cumulative tallies (kills/structures/objectives by S/A/B tier) gold,xp [0, 10^7]; cs [0, 10^4]; level [0, 150]; kills,deaths [0, 500]; structures [0, 40]; obj [0, 60]
11–20 red_{…same ten…} same (slots 5..=9) same
21–28 {gold, xp, cs, level, kill, death, structure, objective}_diff signed blue−red diffs (the strong win signals; structure_diff = red_lost − blue_lost = blue's structural lead) gold,xp [−10^7, 10^7]; cs [−10^4, 10^4]; level [−150, 150]; kill,death [−500, 500]; structure [−40, 40]; objective [−180, 180] (±3 tiers × 60)
29 turret_plates count (side-agnostic, §6.15.3) [0, 200]
30–69 p{0..9}_{gold, xp, level, cs} gold ×2 / count ×2 — every player's economy, slot-indexed (0..=4 blue, 5..=9 red) gold,xp [0, 2·10^6]; level [0, 30]; cs [0, 2000]

70 features. Integer-only (all inputs are u64 feed integers, §6.15.2 — no quantization, converted with saturating casts, never as-truncated). Unlike dust2/v1's per-team pair, the extractor emits one vector per snapshot from a FIXED blue perspective — a LoL match is a single 2-class (blue/red win) outcome, so there is one row per per-minute state, and the label (who won) is joined at corpus-build time from match_complete/v1, never carried in the pre-outcome snapshot. No projections in v1. The feature layer itself is n_classes-agnostic; model-format-v2 (variable n_classes: LoL = 2 vs CS2's 6) covers the model-output path: a lol/v1 artifact parses, evaluates, and backtests. SERVING a lol/v1 model into a market rests on the generalization of the CS2-ordinal economics: the bookmaker's 200·k grid is the market family's payout vector as data (architecture.md quote fold) and the wrapper's kill-floor path (enforce) stays CS2's while a 2-class model reaches the fold through validate_raw; the match-winner market itself (policy, outcome source, router) is replay::match_winner, clearing::match_winner, bookmaker::binary (settlement-contract SC-7); runtime title dispatch (§12): a run manifest whose title is lol binds the state/v2 projector, the match-winner policy / router / outcome source and this extractor, and the model's outputs reach the fold through validate_raw — the headless serve of the LoL house model (the baseline_v1 recipe bound to lol/v2, 340619b7…; named after its lol/baseline-v1 projection) over a real Riot match is pinned end to end (trainset/tests/title_serve_e2e.rs).

lol/v2: the identical 70-feature grid plus the lol/baseline-v1 projection. The first production-scale LoL run (over the 3,300-match Riot corpus: the 30- and 270-match challenger pulls plus the 3,000-match bulk pull, unique by game id) needs a gate-4 baseline that means something — a 50/50 baseline means nothing, and the CS2 baseline_v1 recipe trained on a title's SAME full grid is a near-tautology (the dota2/v2 note below resolves it with a five-feature projection); LoL takes the same shape. A projection is part of the canonical document these hashes cover, so per the immutability rule below it is a NEW schema, not an edit of lol/v1 (a real lol/v1-bound artifact was trained and admitted, and the pinned id is published; event-catalog §7 rule 1):

Schema Features Projections Hash
lol/v1 the 70 above none 3c6e5ff2…
lol/v2 the SAME 70, same indices, encodings, bounds (one builder) lol/baseline-v1 = [game_minute, gold_diff, xp_diff, kill_diff, structure_diff] = indices [0, 21, 22, 25, 27] — the spectator's scoreboard: elapsed time and the four blue-minus-red differentials; structure_diff (towers, LoL's map-control readout) stands where Dota2's five carry alive_count_diff, since the lol grid has no alive count (no alive flag in the fold — event-catalog §6.15.1 fold rule 2, inherited by state/v2 per §6.15.3 rule 1) 94ac8580…

The members are declared by feature NAME in the pricing::feature::lol builder and resolved to indices at construction through the shared feature::schema::named_projection (the one home of that resolution — the dust2 and dota2 builders use it too); consumers read the indices from the recorded document; the tests pin the literal indices as the independent oracle. A v2 vector IS a v1 vector (LolFeatureExtractor takes either and stamps the bound schema's id); the trainset CSV carries no schema id, only the width, so the lol/v1 corpus export trains v2-bound models unchanged — the schema document handed to the trainer (d2-trainset lol-schema --v2) is what the artifact binds. Both schemas stay registered (known_lol_schemas).

lol/race-v1: the race-to-N-kills grid. The first family-specific schema: the lol/v1/v2 70-feature grid (ONE builder — a race vector's head IS a match-winner vector) plus four race features, hash-pinned in pricing::feature::lol_race:

Indices Features Encoding Bounds
0–69 the lol/v1 grid, unchanged as above as above
70 race_threshold count — the LIVE line N (settlement-contract SC-8 rule 6: the line the lifecycle has open on this snapshot) [0, 500]
71–72 blue_kills_to_go, red_kills_to_go count — N − the side's cumulative kills (≥ 1 while a line is live; saturating) [0, 500]
73 kills_to_go_diff signed blue − red kills-to-go [−500, 500]
Schema Features Projections Hash
lol/race-v1 the 74 above lol/race-baseline-v1 = [game_minute, kill_diff, race_threshold, blue_kills_to_go, red_kills_to_go] = indices [0, 25, 70, 71, 72] — the spectator's race scoreboard 90e86517…

The extractor (LolRaceFeatureExtractor) drives the SAME race fold the lifecycle policy and the clearing outcome source drive (replay::race_to_kills::RaceTracker, over the same game-state stream), and emits ONE vector per state/v2 snapshot WHILE A LINE IS LIVE — for the line the fold has live AFTER the snapshot's own crossing, so the model prices the market the lifecycle now has open, never the one it just closed (on the crossing frame itself that next market is still Pending its barrier, so that one quote is dropped and the new line's first quote arrives on the following snapshot — state-layers §4) — and nothing between the last line's settlement and the match's end (no market to price: state-layers §4, no-quote beats bad-quote). The target is 2-class like the match winner's (class 1 = the perspective side reached the line first), so the §10 match-winner promotion regime applies unchanged; the label is joined at corpus time from the race fold's own settlement (§13), never from match_complete. The lol/race-v1 id resolves through the RACE registry (known_lol_race_schemas / lol_race_schema_for_id), which the runtime binds under the manifest family race_to_kills (§12) — a lol/v2 model never wires under the race, nor a race model under the match winner; the title-scoped gate arms (lol-backtest) resolve through the title's union (lol_title_schema_for_id).

lol/minute-v1: the kills-over-N-at-minute-M grid. The lol/v1/v2 70-feature grid (the same ONE builder) plus five line features, hash-pinned in pricing::feature::lol_minute:

Indices Features Encoding Bounds
0–69 the lol/v1 grid, unchanged as above as above
70 line_minute minutes — the LIVE line's checkpoint M (settlement-contract SC-9 rule 6: the line the lifecycle has open on this snapshot) [0, 200]
71 line_kills count — the live line's kills N (over is N + 1 or more) [0, 500]
72 minutes_to_go minutes — M − this snapshot's ordinal (≥ 1 while a line is live; saturating) [0, 200]
73 total_kills count — both sides' cumulative kills summed (the quantity the line is judged on; saturating on the grid, the fold checks the settlement sum) [0, 1000]
74 kills_margin signed total − N (over is any positive margin at the minute's frame) [−500, 1000]
Schema Features Projections Hash
lol/minute-v1 the 75 above lol/minute-baseline-v1 = [game_minute, line_minute, line_kills, minutes_to_go, total_kills, kills_margin] = indices [0, 70, 71, 72, 73, 74] — the spectator's line scoreboard 37a69677…

The bounds are PINNED to the lines rule at compile time (MinuteLines:: MAX_MINUTE = the grid's game_minute bound, MAX_KILLS = its per-side kills bound), so every admissible line sits on the grid. The extractor (LolMinuteFeatureExtractor) drives the SAME minute fold the lifecycle policy and the clearing outcome source drive (replay::kills_at_minute::MinuteTracker, over the same game-state stream) and emits ONE vector per state/v2 snapshot WHILE A LINE IS LIVE — for the line the fold has live AFTER the snapshot's own step, so the settling frame prices the NEXT line (still Pending its barrier: that one quote is dropped, and the new line's first quote arrives on the following snapshot — state-layers §4) — the minutes to go read off the frame ORDINAL the fold counts, and nothing once every line is decided. The target is 2-class (class 1 = the total exceeded N at the minute), so the §10 match-winner promotion regime applies unchanged; the label is joined at corpus time from the minute fold's own settlement (§13). The id resolves through the MINUTE registry (known_lol_minute_schemas / lol_minute_schema_for_id), bound by the runtime under the manifest family kills_at_minute (§12) — never under the race or the match winner; the title's union (lol_title_schema_for_id) knows every lol grid — v1, v2, race, minute, side-minute, gold-lead, towers, dragons, barons.

lol/side-minute-v1: the SIDE kills-over-N-at-minute-M grid. The lol/v1/v2 70-feature grid (the same ONE builder) plus six line features, hash-pinned in pricing::feature::lol_side_minute — the minute grid's lanes with the line's TEAM and the team's own count in place of the total (pricing::feature::minute_lanes::side_minute_line_lanes, one home for both titles):

Indices Features Encoding Bounds
0–69 the lol/v1 grid, unchanged as above as above
70 line_minute minutes — the LIVE line's checkpoint M (settlement-contract SC-10 rule 6) [0, 200]
71 line_kills count — the live line's kills N [0, 500]
72 line_team side — the TEAM whose count the line reads (0 = blue, 1 = red) [0, 1]
73 minutes_to_go minutes — M − this snapshot's ordinal (≥ 1 while a line is live) [0, 200]
74 team_kills count — the line's team's cumulative kills (the quantity the line is judged on) [0, 500]
75 kills_margin signed team kills − N [−500, 500]
Schema Features Projections Hash
lol/side-minute-v1 the 76 above lol/side-minute-baseline-v1 = [game_minute, line_minute, line_kills, line_team, minutes_to_go, team_kills, kills_margin] = indices [0, 70, 71, 72, 73, 74, 75] a28eb230…

The bounds are PINNED to the lines rule at compile time as the minute grid's are (MinuteLines::MAX_MINUTE / MAX_KILLS), the team lane to the side vocabulary (TEAM_BOUND = side_kills_at_minute::TEAMS − 1), so every admissible line sits on the grid. The extractor (LolSideMinuteFeatureExtractor — the ONE LoL minute extractor, LolMinuteFeatureExtractor<SideMinuteLine>, generic over the grid like the fold over the judged count) drives the SAME minute fold over side lines (MinuteTracker<Lol, SideMinuteLine>) with the minute extractor's emission rule verbatim; the target is 2-class (class 1 = the team's count exceeded N at the minute), the label joined at corpus time from the fold's own settlement (§13). The id resolves through the SIDE-MINUTE registry (known_lol_side_minute_schemas / lol_side_minute_schema_for_id), bound by the runtime under the manifest family side_kills_at_minute (§12); the title's union knows every lol grid (v1, v2, race, minute, side-minute, gold-lead, towers, dragons, barons, total, handicap).

dota2/v1. The second NON-CS2 feature schema, extending the §8 title-neutrality proof to a third title. dota2/v1 is 68 features joining two ingest streams — dota2/player_frame/v1 (schema 1009, a per-tick snapshot of all ten players' economy/combat state, deliberately carrying NO team semantics — ingest::dota2::frame's own doc) against dota2/roster_side/v1 (schema 1010, R4b, ONE record per match resolving each raw archive tick-slot to its Radiant/Dire side, event-catalog §6.11 rule 9) — hash-pinned in pricing::feature::dota2:

Indices Features Encoding Bounds
0 tick ticks (the frame's raw source-archive engine tick, 30/s, frame.rs) [0, 10^6]
1–10 radiant_{gold, net_worth, xp, level, kills, deaths, assists, last_hits, denies, alive_count} gold ×3 / count ×7 — aggregates summed over the roster-resolved radiant slots (dota2/v1 carries net_worth alongside gold, which lol/v1 does not, hence 3 gold-encoded fields here vs lol/v1's 2) gold [0, 10^7]; net_worth [0, 2.5·10^7]; xp [0, 10^7]; level [0, 150]; kills,deaths,assists [0, 500]; last_hits [0, 10000]; denies [0, 2000]; alive_count [0, 5]
11–20 dire_{…same ten…} same (roster-resolved dire slots) same
21–27 {gold, net_worth, xp, level, kills, deaths, alive_count}_diff signed radiant−dire diffs (the strong win signals; assists/last_hits/denies left as raw side aggregates only — noisier team-composition artifacts) gold [−10^7, 10^7]; net_worth [−2.5·10^7, 2.5·10^7]; xp [−10^7, 10^7]; level [−150, 150]; kills,deaths [−500, 500]; alive_count [−5, 5]
28–67 p{0..9}_{gold, xp, level, last_hits} gold ×2 / count ×2 — every player's economy, RAW archive slot order (0..=9, no team meaning — a caller wanting per-player side must cross-reference the roster separately) gold,xp [0, 2·10^6]; level [0, 30]; last_hits [0, 2000]

68 features. Integer-only (all inputs are u64/bool feed values off the wire — no quantization, converted with the shared saturating sat/diff casts, pricing::feature::{sat, diff}, promoted out of lol.rs onto this addition rather than a third duplicate copy). Deliberately excluded from v1: player_frame/v1's optional attribute fields (strength/agility/intelligence/ armor/move_speed — absent 7–45% of archive rows, per that record's own doc) and its position/item fields (deferred for real archive-data-quality reasons, unchanged here) — mirroring lol/v1's own economy-first scope discipline. Unlike lol/v1, which decodes its blue/red split directly out of lol/state/v2 per record, dota2/v1's side split is NOT embedded in the per-tick source (player_frame/v1 carries none) — the Dota2FeatureExtractor binds the sides at the stream's leading roster_side/v1 (event-catalog §6.11 rule 11 / §6.11.1) — once, since a match's side assignment never changes mid-match — halts on a frame that arrives before it, halts on a later roster record whose sides contradict the bound ones (an identical repeat is idempotent; a different MATCH announced while one is live is the lifecycle tracker's halt, SC-7 rule 4, which runs first), releases the binding at the forwarded completion (state-layers §4 draws the machine), and apply folds each player_frame/v1 tick against that fixed roster into ONE sys/features/v1 vector, from a FIXED radiant perspective (team wire 0) — a Dota2 match is a single 2-class (radiant/dire win) outcome, so there is one row per snapshot, and the label (radiant_win) is joined at corpus-build time from dota2/match_complete (schema 1002), never carried in the pre-outcome snapshot. No projections in v1.

dota2/v2: the identical 68-feature grid plus the dota2/baseline-v1 projection. A Dota2 gate-4 run comparing its candidate against the CS2 baseline_v1 recipe on the SAME 68 features is a near-tautology (§10). CS2's comparison is meaningful because baseline_v1 sees only the five-feature dust2/baseline-v1 projection; the operator chose the same shape for Dota2. A projection is part of the canonical document these hashes cover, so per the immutability rule below it is a NEW schema, not an edit of dota2/v1 (whose id real artifacts and a dataset bind):

Schema Features Projections Hash
dota2/v1 the 68 above none f559de6e…
dota2/v2 the SAME 68, same indices, encodings, bounds (one builder) dota2/baseline-v1 = [tick, net_worth_diff, xp_diff, kills_diff, alive_count_diff] = indices [0, 22, 23, 25, 27] — the spectator's scoreboard: elapsed time and the four radiant-minus-dire differentials, the Dota2 analogue of CS2's clock / kills / alive five cd49d5d0…

The projection's members are declared by feature NAME in the pricing::feature::dota2 builder and resolved to indices at construction; consumers read the indices from the recorded document (the projection-consumption note below); the tests pin the literal indices as the independent oracle. A v2 vector IS a v1 vector (Dota2FeatureExtractor takes either and stamps the bound schema's id); the trainset CSV carries no schema id, only the width, so the dota2/v1 full-archive dataset trains v2-bound models unchanged — the schema document handed to the trainer (d2-trainset dota2-schema --v2) is what the artifact binds. Both schemas stay registered (known_dota2_schemas), so v1-bound artifacts still resolve.

Rules:

  • Schemas are immutable; evolution = new schema + new hash.
  • Projections map a schema onto an older/simpler one by index selection. The extractor emits the full vector once per tick; projected views are derived without re-extraction. The baseline model consumes the dust2/baseline-v1 projection (spec §5 feature lists — see the feature contract table in architecture.md). Projection-consumption mechanics: a projection-consuming model is TRAINED on the projection's columns but its artifact binds the full schema id (n_features = the full width) with tree feature indices remapped to absolute schema positions at export (§13's feature_map). At runtime the wrapper feeds the one full vector and the trees simply never read outside the projection — behaviorally identical to a projected view, with one feature stream and no per-model slicing on the hot path. The projection's member indices come from the recorded schema document alone, never a hardcoded list.
  • Binding check: the runtime refuses to wire a model to a stream whose schema doesn't match the artifact's feature_schema_id. Mismatch is a registration-time failure, not a runtime surprise. (Matching via a declared projection is not a path: artifacts always bind a full schema id, and projections are index lists inside the schema document, not hash-identified schemas.)
  • Encodings define the quantization grid (§3) that the exporter must use for thresholds — the schema is the single source of numeric truth shared by extractor, exporter, and evaluator.

dota2/race-v1: the Dota2 race grid through the same seam. The dota2/v1/v2 68-feature grid (ONE builder body — a race vector's head IS a match-winner vector) plus the same four race features, hash-pinned in pricing::feature::dota2::race; a side's kills are the per-slot cumulative counters summed through the match's roster (event-catalog §6.11 rules 3/9) — the same join the grid's kills aggregate lanes make, so the race lanes and the aggregates agree by construction:

Indices Features Encoding Bounds
0–67 the dota2/v1 grid, unchanged as above (the dota2/v1 table) as above
68 race_threshold count — the LIVE line N (settlement-contract SC-8 rule 5) [0, 500]
69–70 radiant_kills_to_go, dire_kills_to_go count — N − the side's roster-summed cumulative kills (≥ 1 while a line is live; saturating) [0, 500]
71 kills_to_go_diff signed radiant − dire kills-to-go [−500, 500]
Schema Features Projections Hash
dota2/race-v1 the 72 above dota2/race-baseline-v1 = [tick, kills_diff, race_threshold, radiant_kills_to_go, dire_kills_to_go] = indices [0, 25, 68, 69, 70] — the spectator's race scoreboard 283b30e4…

The extractor (Dota2RaceFeatureExtractor) drives the SAME race fold as the LoL one, whose frame reader on this title CARRIES the match's roster binding (replay::dota2::Dota2FrameReader over ingest::dota2::roster_side:: RosterBinding — the one binding the dota2/v1 extractor drives too — plus, the running objective tally), bound at the stream's leading roster_side/v1; the extractor reads the sides off its own fold's reader, so a frame before the roster halts in the fold (FrameWithoutMatch — the roster is the envelope; SidesUnbound is a live match's frame before its binding record), never in a second binding. Emission is the LoL race extractor's rule verbatim: one vector per player_frame/v1 WHILE A LINE IS LIVE, for the line the fold has live AFTER the frame's own crossing, nothing otherwise. The dota2/race-v1 id resolves through the dota2 RACE registry (known_dota2_race_schemas / dota2_race_schema_for_id), bound by the runtime under dota2 + race_to_kills (§12); the title-scoped gate arm (dota2-backtest) resolves through the title's union (dota2_title_schema_for_id).

dota2/minute-v1: the Dota2 kills-at-minute grid through the same seam. The dota2/v1/v2 68-feature grid (ONE builder body) plus the same five line features, hash-pinned in pricing::feature::dota2::minute; the total is the per-slot cumulative counters summed over BOTH sides through the match's roster (event-catalog §6.11 rules 3/9) — the same join the grid's kills aggregate lanes make; the "minute" is the frame ORDINAL (the M-th sampled frame at the archive's per-minute stride, §6.11 rule 6):

Indices Features Encoding Bounds
0–67 the dota2/v1 grid, unchanged as above (the dota2/v1 table) as above
68–72 line_minute, line_kills, minutes_to_go, total_kills, kills_margin as the lol/minute-v1 table (minutes are frame ordinals here) [0, 200], [0, 500], [0, 200], [0, 1000], [−500, 1000]
Schema Features Projections Hash
dota2/minute-v1 the 73 above dota2/minute-baseline-v1 = [tick, line_minute, line_kills, minutes_to_go, total_kills, kills_margin] = indices [0, 68, 69, 70, 71, 72] 8c173bad…

The extractor (Dota2MinuteFeatureExtractor) drives the same minute fold as the LoL one, whose frame reader on this title carries the match's roster binding (bound at the stream's leading roster_side/v1; a frame before the roster halts in the fold as FrameWithoutMatch); emission is the LoL minute extractor's rule verbatim. The id resolves through the dota2 MINUTE registry (known_dota2_minute_schemas / dota2_minute_schema_for_id), bound by the runtime under dota2 + kills_at_minute (§12); dota2-backtest resolves through the title's union.

dota2/side-minute-v1: the Dota2 side-minute grid through the same seam. The dota2/v1/v2 68-feature grid (ONE builder body) plus the same six side lanes, hash-pinned in pricing::feature::dota2::side_minute; the team's count is its slots' cumulative counters summed through the match's roster (event-catalog §6.11 rules 3/9) — one of the two sums the minute grid adds together:

Indices Features Encoding Bounds
0–67 the dota2/v1 grid, unchanged as above as above
68–73 line_minute, line_kills, line_team, minutes_to_go, team_kills, kills_margin as the lol/side-minute-v1 table (minutes are frame ordinals; the team 0 = radiant, 1 = dire) [0, 200], [0, 500], [0, 1], [0, 200], [0, 500], [−500, 500]
Schema Features Projections Hash
dota2/side-minute-v1 the 74 above dota2/side-minute-baseline-v1 = [tick, line_minute, line_kills, line_team, minutes_to_go, team_kills, kills_margin] = indices [0, 68, 69, 70, 71, 72, 73] 7f032073…

The extractor (Dota2SideMinuteFeatureExtractor = Dota2MinuteFeatureExtractor<SideMinuteLine>, the ONE dota2 minute extractor over the side grid) is the dota2 minute extractor over side lines (the roster bound inside the fold, the team's count read through SideMinuteLine::judged off the two roster sums); the id resolves through the dota2 SIDE-MINUTE registry (known_dota2_side_minute_schemas / dota2_side_minute_schema_for_id), bound under dota2 + side_kills_at_minute (§12); the title's union knows it.

lol/gold-lead-v1: the GOLD-LEAD-over-N-at-minute-M grid. The lol/v1/v2 70-feature grid (the same ONE builder) plus six line features, hash-pinned in pricing::feature::lol_gold_lead — the side grid's lane shape with a GOLD line and the team's SIGNED lead in place of the kills line and count (pricing::feature::minute_lanes::gold_lead_line_lanes, one home for both titles):

Indices Features Encoding Bounds
0–69 the lol/v1 grid, unchanged as above as above
70 line_minute minutes — the LIVE line's checkpoint M (settlement-contract SC-11 rule 6) [0, 200]
71 line_gold gold — the live line's gold lead N [0, 100000]
72 line_team side — the TEAM whose lead the line reads (0 = blue, 1 = red) [0, 1]
73 minutes_to_go minutes — M − this snapshot's ordinal (≥ 1 while a line is live) [0, 200]
74 team_lead signed gold_diff — the team's gold minus the other side's (negative behind; the settlement's judged lead saturates at 0 — the model sees how far behind) [−25000000, 25000000]
75 lead_margin signed gold_diff — team lead − N [−25000000, 25000000]
Schema Features Projections Hash
lol/gold-lead-v1 the 76 above lol/gold-lead-baseline-v1 = [game_minute, line_minute, line_gold, line_team, minutes_to_go, team_lead, lead_margin] = indices [0, 70, 71, 72, 73, 74, 75] d397e4e4…

The gold lane's bound is PINNED to the lines rule at compile time (GOLD_BOUND = replay::line_family::MAX_GOLD_LEAD, 100,000), the team lane to the side vocabulary, the lead lanes to the widest title gold-diff lane (dota2's net_worth_diff, ±25,000,000), so every admissible line sits on the grid and a lead the title grid admits is never refused. The extractor (LolGoldLeadFeatureExtractor = LolMinuteFeatureExtractor<GoldLeadLine>, the ONE LoL minute extractor) drives the SAME minute fold over gold lines (MinuteTracker<Lol, GoldLeadLine> — the fold's frame read is the line's quantity, FrameSource<Gold>: the ten total_gold fields summed per side, gold_from_record); the grid receives the fold's own Gold value — the settlement's read passed in, never re-summed; the target is 2-class (class 1 = the team's lead exceeded N at the minute), the label joined at corpus time from the fold's own settlement (§13). The id resolves through the GOLD-LEAD registry (known_lol_gold_lead_schemas / lol_gold_lead_schema_for_id), bound by the runtime under the manifest family gold_lead_at_minute (§12); the title's union knows every lol grid (v1, v2, race, minute, side-minute, gold-lead, towers, dragons, barons).

dota2/gold-lead-v1: the Dota2 gold-lead grid through the same seam. The dota2/v1/v2 68-feature grid (ONE builder body) plus the same six gold-lead lanes, and hash-pinned in pricing::feature::dota2::gold_lead; the team's lead is its slots' net_worth summed through the match's roster (event-catalog §6.11 rules 3/9) minus the other side's — the net_worth_diff lane's quantity, read for the family through FrameSource<Gold> (net_worth_from_record):

Indices Features Encoding Bounds
0–67 the dota2/v1 grid, unchanged as above as above
68–73 line_minute, line_gold, line_team, minutes_to_go, team_lead, lead_margin as the lol/gold-lead-v1 table (minutes are frame ordinals; the team 0 = radiant, 1 = dire; the lead in net worth) [0, 200], [0, 100000], [0, 1], [0, 200], [−25000000, 25000000], [−25000000, 25000000]
Schema Features Projections Hash
dota2/gold-lead-v1 the 74 above dota2/gold-lead-baseline-v1 = [tick, line_minute, line_gold, line_team, minutes_to_go, team_lead, lead_margin] = indices [0, 68, 69, 70, 71, 72, 73] 38bf4369…

The extractor (Dota2GoldLeadFeatureExtractor = Dota2MinuteFeatureExtractor<GoldLeadLine>, the ONE dota2 minute extractor over the gold grid) is the dota2 minute extractor over gold lines (the roster bound inside the fold, the lead read off the fold's own Gold — the roster sums the settlement read, passed in); the id resolves through the dota2 GOLD-LEAD registry (known_dota2_gold_lead_schemas / dota2_gold_lead_schema_for_id), bound under dota2 + gold_lead_at_minute (§12); the title's union knows it.

lol/towers-v1 / lol/dragons-v1 / lol/barons-v1 and dota2/towers-v1 : the TALLY-over-N-at- minute-M grids. ONE generic module per title (pricing::feature::lol_tally / dota2::tally, TallyLine<K> over the kind tag): the title grid (70 lanes on LoL, 68 on Dota2 — the same ONE builder) plus six line lanes — the side grid's lane shape over the team's COUNT of the kind (pricing::feature::minute_lanes::tally_line_lanes, one home for both titles), the count read through the ONE home TallyLine::judged over the fold's tally (the SAME value the policy judges, MinuteTracker::last_frame):

Indices (LoL / Dota2) Features Encoding Bounds
0–69 / 0–67 the title grid, unchanged as above as above
70 / 68 line_minute minutes — the LIVE line's checkpoint M (settlement-contract SC-12 rule 6) [0, 200]
71 / 69 line_count count — the live line's count N [0, 20]
72 / 70 line_team side — the TEAM whose count the line reads (0 = blue / radiant, 1 = red / dire) [0, 1]
73 / 71 minutes_to_go minutes — M − this snapshot's ordinal (≥ 1 while a line is live) [0, 200]
74 / 72 team_count count — the team's count of the kind at this snapshot (the reader's tally) [0, 20]
75 / 73 count_margin signed count_diff — team count − N [−20, 20]
Schema Features Projections Hash
lol/towers-v1 the 76 above lol/towers-baseline-v1 = [game_minute, line_minute, line_count, line_team, minutes_to_go, team_count, count_margin] = indices [0, 70, 71, 72, 73, 74, 75] 428e0ffe…
lol/dragons-v1 the 76 above lol/dragons-baseline-v1 = the same members, indices [0, 70, 71, 72, 73, 74, 75] 4cc55075…
lol/barons-v1 the 76 above lol/barons-baseline-v1 = the same members, indices [0, 70, 71, 72, 73, 74, 75] 9f8c04c7…
dota2/towers-v1 the 74 above dota2/towers-baseline-v1 = [tick, line_minute, line_count, line_team, minutes_to_go, team_count, count_margin] = indices [0, 68, 69, 70, 71, 72, 73] 8e09a52d…

The grids of the three kinds differ ONLY in their names (and so in their hash-pinned ids): a model trained on towers is a different artifact from a model trained on dragons, and the bind resolves a recorded id through the KIND's grid only (lol_tally_schema_for_kind_and_id) — a towers model is never bound under dragons_at_minute. The count lanes' bound is PINNED to the lines rule at compile time (COUNT_BOUND = replay::line_family:: MAX_TALLY, 20), the team lane to the side vocabulary — and the LIVE lane (team_count) shares that bound deliberately, unlike the gold family's widened lead lane: a real match never takes more than 11 towers a side (fewer dragons, fewer barons), so a tally past 20 is a corrupted or foreign log and the extractor HALTS on it (validate_vector's OutOfBounds, settlement-contract SC-12 rule 7) rather than pricing it. The extractors (LolTallyFeatureExtractor<K> = LolMinuteFeatureExtractor<TallyLine<K>>, Dota2TallyFeatureExtractor<K> — the ONE minute extractor per title) drive the SAME minute fold over tally lines (the fold's frame read is FrameSource<Tally>: the title's frame reader's running counts off the forwarded objective events, §6.15.1 / §6.11.1); the target is 2-class (class 1 = the team's count exceeded N at the minute), the label joined at corpus time from the fold's own settlement (§13). The ids resolve through the TALLY registries (known_lol_tally_schemas — three grids; known_dota2_tally_schemas — towers), bound by the runtime under the manifest families towers_at_minute / dragons_at_minute / barons_at_minute (§12); each title's union knows its tally grids too.

lol/total-v1 / lol/handicap-v1 and dota2/total-v1 / dota2/handicap-v1 : the HOUSE-LINE grids. ONE generic module (pricing::feature::house_grid over the title seam HouseGrid — lol_house / dota2::house bind it — and the kind tag house_lanes::HouseLanes): the title grid (70 lanes on LoL, 68 on Dota2 — the same ONE builder) plus the KIND's lanes, three on the total, four on the handicap. No minute lanes: the market settles on the match's LAST frame, whose ordinal nobody knows in advance — the title grid's own clock lane is the elapsed time. Every lane value is read through the line's own rule (HouseLine::judged — the SAME checked total the settlement reads; HouseLine::signed_lead — the handicap team's SIGNED lead, negative behind, where the settlement's judged lead saturates at 0: the model sees how far behind), never recomputed:

Indices (LoL / Dota2) Features Encoding Bounds
0–69 / 0–67 the title grid, unchanged as above as above
total 70 / 68 line_kills count — the line the HOUSE set on the opening snapshot (settlement-contract SC-13 rule 5) [0, 500]
total 71 / 69 total_kills count — both sides' kills at this snapshot (HouseLine::judged) [0, 1000]
total 72 / 70 kills_margin signed count_diff — total − line [−500, 1000]
handicap 70 / 68 line_kills count — the house-set lead line [0, 500]
handicap 71 / 69 line_team side — the TEAM whose lead the line reads (0 = blue / radiant, 1 = red / dire) [0, 1]
handicap 72 / 70 team_lead signed count_diff — the team's kills minus the other side's at this snapshot [−500, 500]
handicap 73 / 71 lead_margin signed count_diff — team lead − line [−1000, 500]
Schema Features Projections Hash
lol/total-v1 73 lol/total-baseline-v1 = [game_minute, line_kills, total_kills, kills_margin] = indices [0, 70, 71, 72] 1ac72840…
lol/handicap-v1 74 lol/handicap-baseline-v1 = [game_minute, line_kills, line_team, team_lead, lead_margin] = indices [0, 70, 71, 72, 73] f90a9d4c…
dota2/total-v1 71 dota2/total-baseline-v1 = [tick, line_kills, total_kills, kills_margin] = indices [0, 68, 69, 70] 2afe132a…
dota2/handicap-v1 72 dota2/handicap-baseline-v1 = [tick, line_kills, line_team, team_lead, lead_margin] = indices [0, 68, 69, 70, 71] e2188c60…

The two kinds' grids differ in their lanes AND their names: a total model is a different artifact from a handicap model, and the bind resolves a recorded id through the KIND's grid only (lol_house_schema_for_kind_and_id / dota2_house_schema_for_kind_and_id — id AND name). What is new against every earlier grid is WHO fills the line lane: the pricing layer's HouseLineSetter<T, K> — the replay::house_line::LineSetter seam's one implementation — evaluates the run's HOUSE model (the manifest's first house sleeve's binding, as the bench holds it) once per candidate line of the run's terms on the match's OPENING snapshot (the title head constructed ONCE, the kind's lanes swapped per candidate — at most 128 thresholds, two teams on the handicap) through the §7 binary path (validate_raw) and takes the line whose OVER probability is nearest one half (HouseLine::candidates + HouseLine::nearest_even: the tie order — the lower threshold, then the lower team — lives on the seam). The runtime bind constructs ONE setter and hands it, Arc-shared, to the lifecycle policy AND the extractor (HouseFeatureExtractor<T, K>, driving the SAME HouseFold the policy and the clearing source drive), so the line in the descriptor and the line the lanes carry are one rule evaluated on one frame (SC-13 rule 6; pinned by runtime/tests/house_line_serve.rs reading the features stream against the created descriptor). The extractor emits from the opening snapshot on — the market is created AND opened in that lifecycle step, so the first quote arrives on the opening frame — one vector per snapshot while the match runs, nothing after the completion or for a match that never opened; the target is 2-class (class 1 = the line pays OVER on the match's FINAL frame), the label joined at corpus time from the fold's own final frame (§13). The ids resolve through the HOUSE registries (known_lol_house_schemas / known_dota2_house_schemas — two grids each), bound by the runtime under the manifest families total_kills / kill_handicap (§12); each title's union knows its house grids too.

synth/v1 (task 166 A; event-catalog §6.18 rule 5). The synthetic title's one-lane schema, hash-pinned in pricing::feature::synth: name synth/v1, ONE feature x — the evidence x_k of synth/synth_state/v1 quantized to the i32 lane as Q16 with the encoding word q16 and the bounds i32::MIN ..= i32::MAX (the lane value the CHECKED conversion i32::try_from(x >> 16), an arithmetic shift; a value past the lane is refused EvidenceBeyondLane {x} — a typed halt the projector's own bound, event-catalog §6.18 rules 5 and 7, makes unreachable from an admitted log); no projection; the FeatureSchemaId is the hash of this declaration (name, feature name, encoding, bounds), published by the pinned constant in the crate and resolved by synth_schema_for_id in the runtime's bind. No house model is declared on it in task 166 (futarchy-system.md §9 C140: the house is silent in every dynamics run).

9. Registry and control stream #

The model registry is a fold over the control stream plus an artifact blob store (content-addressed by ModelId). Control events (canonical serialization, hash-chained like every stream):

model_registered   {model_id, format_id, feature_schema_id, meta_hash, artifact_size}
model_gate_result  {model_id, gate: static|determinism|parity|backtest, pass, details_hash}
model_shadowed     {model_id, effective_from: next_market_open}
model_promoted     {model_id, bookmaker: "B", effective_from: next_market_open}
model_suspended    {model_id, reason: operator|invalid_rate|coverage, effective: immediate}
model_output_invalid {model_id, state_version, reason}    # emitted by wrapper — rides the DERIVED model_health stream (event-catalog §6.10 writer split)
stateDiagram-v2
    [*] --> Registered: model_registered (artifact stored, hash verified)
    Registered --> Validated: all four gates pass
    Registered --> Rejected: any gate fails (event records which and why)
    Validated --> Shadow: model_shadowed — probability stream live, no bookmaker
    Shadow --> Live: model_promoted (report card + approver sign-off)
    Live --> Shadow: superseded — another model promoted to this binding, the stream still running but unbound
    Live --> Suspended: model_suspended (kill switch)
    Shadow --> Suspended: model_suspended — shadow streams are graded by the same §14 thresholds (the edge is pinned)
    Suspended --> Shadow: re-admission after fix (gates re-run)
Diagram source
stateDiagram-v2
    [*] --> Registered: model_registered (artifact stored, hash verified)
    Registered --> Validated: all four gates pass
    Registered --> Rejected: any gate fails (event records which and why)
    Validated --> Shadow: model_shadowed — probability stream live, no bookmaker
    Shadow --> Live: model_promoted (report card + approver sign-off)
    Live --> Shadow: superseded — another model promoted to this binding, the stream still running but unbound
    Live --> Suspended: model_suspended (kill switch)
    Shadow --> Suspended: model_suspended — shadow streams are graded by the same §14 thresholds (the edge is pinned)
    Suspended --> Shadow: re-admission after fix (gates re-run)

The approver is a declared parameter of the promotion rule, not a hardcoded role: an operator (the alignment note's phases 0–2); whether a later phase binds it to a market is UNRULED — the one market the economics layer specifies is the allocation market, which decides bookmaker CAPACITY (sleeve ceilings), not model promotion (allocation-market.md §8; the market SHAPE for that layer is itself UNRULED — the CEO, 2026-10-06: "no burns" — the comment ruled out burns only, and the notes disagree about per-strategy proposal markets versus one pari-mutuel market; allocation-market.md §1 and §15 question 3) — same state machine, same control stream, different authority (see §3.5 of the real-world alignment note, gitignored). The admission gates are protocol-level validation and never move to the approver.

Effect timing: shadowed and promoted take effect at the next market_open lifecycle event, never mid-round — a market's pricing source is constant for the lifetime of that market. suspended is immediate: the bound bookmaker pulls all resting quotes within one state_version on every OPEN market (same machinery as a stale-data halt); a market HALTED at the suspension's merge position defers its pull to market_resumed — a batch on a halted market is rejected (§11). All timing is event-recorded, so replay honors it exactly. On a BOARD (§12) a promotion's bookmaker sleeve must be the one TAGGED to the model's own column — the sleeve's BK-4 family against the model's recorded feature schema: a recorded promotion across columns is corrupted history and halts the run when its open would apply it (RuntimeError::BindingColumnMismatch), never served on another family's markets (settlement-contract SC-13 rule 5's per-family setter depends on it). Likewise a recorded registration of a model whose grid NO column emits halts the fold (UnknownFeatureSchema; two columns ⇒ AmbiguousSchema) — the gateway REFUSES the model_register frame at the edge, before the mirror and before any gate runs (gateway-protocol §5 code 16 unknown_feature_schema; §10), so the halt is the corrupted-history path only; both are state-layers §11's typed-halt edge out of Running.


10. Admission pipeline #

Gates run automatically on model_registered, in order, each emitting a model_gate_result:

# Gate What it proves Method
1 Static artifact well-formed full §4 parse-time validation + fuzz-vector sanity (10k random in-bounds vectors → well-formed outputs)
2 Determinism bit-stability same vector set evaluated through two independently parsed artifact instances → byte-identical outputs (fixed-point makes this a hard equality, not a tolerance; the separate-process variant runs in CI, §15)
3 Parity Rust evaluator ≡ training-side reference evaluate all embedded golden vectors; every output must match bit-for-bit. This is the permanent guard on the Python/Rust boundary
4 Backtest forecast quality is measured and recorded; coverage is gated KPI evaluator over the validation split: RPS vs current baseline, calibration buckets, coverage. Produces the report card; pass = coverage ≥ 95% (gate-4 mechanics note below — quality itself grades promotion)

Gate mechanics notes — which grid the gates run against (gateway Driver::new, dust2/v2 per-cadence; per column on a board): gates 1–3 run against the serving run's grid — on a board, the column whose grid the artifact's recorded feature_schema_id names (Scheduler::schema_for, unique-or-none — the fold's own column_for_schema rule); an artifact no column emits (or two do) is never gated at all: gates 1–3 are PRE-COMPUTED once in Driver::new before the first tick and consumed at the register drain (they are a pure function of (bytes, schema) — fixed-seed LCG, no clock, no float — so the recorded log is byte-identical either way), the pre-computation holds an entry for exactly the artifacts a column serves, and the MISSING entry is what the gateway refuses the model_register with at the edge (gateway-protocol §5 code 16 unknown_feature_schema, nothing appended — the §9 fold would halt on the recorded registration, UnknownFeatureSchema / AmbiguousSchema, so the edge enforces, the codes-4/7/10 posture); it is never guessed into a column (a registration "gated against the first column's schema and refused by gate 1" would describe a run the register drain had already halted; the gateway's tests/board_admission.rs pins both halves). Gate mechanics: the in-run gate 2 evaluates the fuzz set through two independently parsed artifact instances — the separate-process variant of the same check is the §15 two-process determinism harness in CI (spawning processes inside the deterministic admission run would be an impurity; CI is the sanctioned home for it). Gate 1's "random" vectors come from a fixed-seed LCG so the admission run is replayable. Gates 1–3 ship with the pricing crate; gate 4 is the training pipeline's (the KPI evaluator needs real ingested data: the pipeline + §13 exporter, then the gate-4 evaluator), and until it emits its model_gate_result a model simply never reaches Validated — the §9 fold requires all four passes.

Report card (CBOR, hash recorded in the gate event): RPS mean and match-clustered 95% CI vs baseline, calibration table, coverage %, per-round RPS distribution, evaluation split identity. Report cards are comparable across models because the split and methodology are pinned.

Gate-4 mechanics. The pass criterion is coverage alone: candidate valid-output rate ≥ 95% over the full validation split, as the exact rational valid·20 ≥ total·19. The RPS-vs-baseline comparison, CI, and calibration are recorded on the card and grade promotion (the Flow 6 model gates: trained RPS ≤ 0.95 × baseline, CI above zero, over the sealed test split — for the 6-class kill distribution; the 2-class match-winner regime below relaxes both legs); they cannot gate admission because the baseline itself passes the same four gates (flows.md Flow 0 note) and no model beats itself by 5%.

Promotion regimes (the user, 2026-09-19: "we can be more loosy on RPS, but still in high performance range"; 2-class titles only). The Flow-6 model gates were fixed for CS2's 6-class kill distribution, where the baseline is a five-feature projection that leaves a 5% margin to beat. A 2-class match-winner title's projection baseline already holds most of the signal, so an honest improvement over it clears the one-sided 95% critical value (1.645) but not the two-sided one (1.96) — the same floor would refuse every honest improvement. The rule is therefore a REGIME selected by the artifacts' recorded n_classes (hash-bound in the model id; the card does not carry it, the caller resolves it — pricing::backtest::PromotionRegime), one home (promotion_from_card):

Regime n_classes Evaluator Effect-size leg Significance leg
kill distribution (CS2, unchanged) 6 the §7-wrapped run_backtest candidate RPS ≤ 19/20 × baseline two-sided 95% CI on baseline − candidate strictly above zero
match winner (LoL, Dota2 — the key is the outcome WIDTH, not the title) 2 run_backtest_raw (no kill-floor) candidate RPS < baseline RPS (any strict improvement) ONE-sided 95% bound strictly above zero — diff_mean − half95·Z90/Z95 derived from the recorded two-sided interval (Z90_Q16 = 1.645, Q.16 107 807; Z95_Q16 = 1.96, Q.16 128 451), rounded to nearest, integer-only; a card whose interval is not centred on its recorded mean, or inverted, is refused, not graded

Any other width has no rule and test-eval refuses it — before any evaluation: no card is written and the split is not spent. The card format is unchanged (d2-report-card/1); the verdict line names the regime. CS2's historical promotions (gbm_v1, gbm_v2) are graded exactly as before. Methodology pins, fixed-point per core law 2:

  • Snapshots are evaluated through the §7 wrapper (gate 4 grades what a bound bookmaker would consume); a wrapper refusal is the coverage gap, and RPS/calibration aggregate over snapshots where both models produced valid output (paired comparison).
  • Snapshot RPS = Σₖ₌₀..K₋₂ (CDFₖ − Oₖ)² / (K−1) in Q32.32 units (probabilities are Q.16, so a squared CDF error is exactly Q32.32). The divisor requires K ≥ 2: §4's parser refuses a one-class artifact , and a one-class pair from any other implementor of the raw evaluator's model trait reaches that evaluator's own refusal before any RPS is taken (BacktestError::ClassWidthTooNarrow, the raw-evaluator note below); the CS2 evaluator never sees one — enforce fixes its width at six. A "round" is one team side's claim series (match, round, side) — the market's claim unit. Aggregation: snapshot → round mean → match mean → overall mean, matches equally weighted (the clustering unit); every division rounds nearest, ties away from zero.
  • CI: per-match paired difference dₘ = baseline − candidate (positive = candidate better), mean ± 1.96·√(s²/M) with the between-match sample variance s²; 1.96 is pinned as Q.16 constant 128 451; the half-width shift floors. Fewer than 2 matches refuses — the gate cannot run, rather than fabricating a CI.
  • Calibration: every (snapshot, class) prediction falls in bucket ⌊p·10/SCALE⌋ (clamped to 9); per-bucket mean predicted vs observed frequency, plus a count-weighted mean absolute error.
  • The card (d2-report-card/1, canonical CBOR, integer keys 0–26) additionally records both models' per-round RPS deciles and the dataset's recorded dataset_split_hash. Normative field layout: pricing::backtest::report (a new layout is a new format tag).

Gate 4 for a non-kill (raw-evaluated) title — run_backtest_raw. The bullet above ("evaluated through the §7 wrapper... a wrapper refusal is the coverage gap") is written for a kill-outcome title and does not literally hold for a title with no CS2-ordinal outcome space (no current_kills, e.g. a LoL win/loss model): step 3 of §7 (kill-monotonicity truncation) has no meaning there. The title-neutral variant, pricing::backtest::run_backtest_raw, uses wrapper::validate_raw instead of wrapper::enforce — the SAME steps 1–2 (panic-caught invocation, exact-sum validation), WITHOUT step 3. Coverage under this variant is NOT a rubber stamp: a candidate/baseline that panics, or whose output fails the exact-sum check, is still counted invalid and excluded from pairing — exactly the CS2 posture, minus the ordinal truncation step that title has no analogue for. In practice a well-formed model (the only kind admission gate 1 ever lets bind) passes coverage essentially always, so the RPS-vs-baseline comparison + CI remain the meaningful signal — same as the CS2 note above, just without a kill-floor survival test contributing to "valid." A candidate/baseline pair predicting different widths (n_classes mismatch — a setup/binding error, not a per-snapshot data defect) refuses the whole run loudly (BacktestError::ClassWidthMismatch), never silently mispairs incommensurable distributions; a model whose width moves between two paired snapshots of one run refuses the same way at the first drift (BacktestError::ClassWidthDrift), and a one-class pair refuses at its first paired snapshot (BacktestError::ClassWidthTooNarrow — the RPS of one ordered class is undefined; §4's parser refuses a one-class artifact at admission, so this arm guards the evaluator's other KillDistributionModel implementors, never a parsed model) — all three on this raw evaluator alone, the CS2 path's enforce fixing N_CLASSES. §4's artifact body records n_classes once per model, and the card reader below re-derives each calibration table's observation count as paired_snapshots · k for one k ∈ 2..=MAX_CLASSES (both bounds §4's, the floor also the RPS's — a one-class run refuses above; the card does not carry n_classes), so the producer holds what the reader checks. A second table fact rests on the producer the same way: the reader weighs each table's bucket means against paired_snapshots · SCALE, which is exact only because §3's distribution law is exact — every distribution either evaluator folds sums to SCALE and nothing else is ever folded. §9's "backtest" gate-outcome edge is unchanged in shape (outcome_from_card, the same one-home function) — only pass 1's per-snapshot validity check differs by title.

A second raw-evaluated title — dota2-backtest. The run_backtest_raw variant above is title-neutral by construction, so Dota2's gate 4 adds no new evaluator: d2-trainset dota2-backtest and lol-backtest dispatch to the ONE shared gate4::registry_backtest, whose grid is the CANDIDATE's own — resolved from the candidate's recorded feature_schema_id through the title's registry (a title with two registered schemas makes a fixed grid wrong by construction). Its card carries the candidate's feature_schema_id and the validation split's recorded dataset_split_hash, verified before use exactly as the LoL and CS2 arms verify theirs. test-eval (promotion over the sealed test split) resolves its candidate through every registry (resolve_known_schema) and grades under the regime n_classes selects (the promotion regimes above); the market/settlement work a non-CS2 promotion feeds is §18 and SC-7, its LIVE serving §12 (the dota2 / lol manifest titles), so a Dota2/LoL "promotable" verdict has the same meaning as a CS2 one. The gate-4 BASELINE for a title with no product baseline model is the operator's declared choice, stated on the card's own lineage: CS2's baseline_v1 is a small forest (60 rounds × 15 leaves) confined to the five-feature dust2/baseline-v1 projection (§8 projection-consumption note, §19.1); the same recipe on a title's full grid is a near-tautology (an RPS ratio at parity, the CI straddling zero), so the Dota2 gate-4 baseline is the baseline_v1 recipe (60 rounds × 15 leaves) trained on dota2/v2's dota2/baseline-v1 projection and binding dota2/v2, and the LoL gate-4 baseline the same recipe on lol/v2's lol/baseline-v1 projection, binding lol/v2; the baseline must bind the same id as the candidate (the §8 binding check), and each title's registry knows its v1 and v2.

Gate wiring at the operator edge. Gates 1–3 run automatically at the gateway's register drain through the real admission pipeline over the store's raw bytes; gate 4's card arrives as the backtest_submit/v1 DOCUMENT, decoded by the hardened card reader (ReportCard::from_canonical_bytes — size cap, closed required-key map, exact shapes, the Q.16 fields held to 0..=SCALE, and the internal-consistency refusals: fifteen facts aggregate — the producer run_backtest and run_backtest_raw share — guarantees by construction — nine over the counts and the interval, five over the calibration tables and a sixth since task 111: an empty bucket all zero, a non-empty bucket's mean inside its own decile (the observed rate is not banded), the two tables' observation counts equal, that count paired_snapshots · k for one k ∈ 2..=MAX_CLASSES, EACH TABLE's bucket means weighing to one distribution per paired snapshot (|Σ_b count_b·mean_pred_b − paired_snapshots·SCALE| ≤ ⌊total/2⌋ over that same observation count — sound because every distribution a producer folds sums to exactly SCALE, §3, and every stored mean is its bucket's own sum rounded; neither premise is itself on the card, so neither is checked), and each MAE the count-weighted recompute of its own table — listed in backtest/consistency.rs with the facts it leaves unchecked, so a submitted card cannot claim a count, a band or a MAE no run produces; its cargo-fuzz target crates/gateway/fuzz/fuzz_targets/decode_report_card.rs under the reader's own bijection — gateway-protocol §7) with the verdict derived from the card's own coverage fields through the ONE-HOME outcome_from_card (the CLI's run_backtest records through the same function, so the two paths produce byte-identical outcomes for the same card; a claimed flag never exists on the wire). The details home: every gate outcome's details string is stored content-addressed under the run's details/ directory and the card under cards/, both fsynced BEFORE the record that references them — the recorded details_hash chain (event → details string → card hash) resolves to durable documents beside the log.

Split governance: admission uses the validation split only. The test split is sealed and touched exactly once per frozen candidate for the formal spec-§10 gate. This is enforced socially and mechanically: the test-split match list lives behind a separate manifest the backtest gate has no access to.

The promotion evaluation (the Flow-6 model gates over the sealed test split, run by d2-trainset test-eval) is the one sanctioned reader of the test manifest, and it verifies three things (plus that the candidate's width has a promotion regime) before spending the split, so the number it produces is an honest generalization estimate (the sealed-split rule): the recorded dataset_split_hash matches the train+validation manifests (the dataset generation); a separate recorded dataset_generation_hash — a length-prefixed domain-separated hash over all three manifests, written at export — matches, so the test manifest is provably the one this generation exported and cannot be substituted for a cherry-picked set; and the test manifest is disjoint from train and validation (no leakage). The dataset_generation_hash is a separate recorded dataset-side value (written beside the manifests at export, not carried in the §4 model lineage map or on the report card): it does not feed dataset_split_hash, so the admission seal is unchanged and the test manifest remains absent from every input the admission gate reads. A card whose split_name is not validation is refused at the admission control edge, so a test-split card can never be recorded as an admission gate result.


11. Shadow mode, promotion, and binding semantics #

Shadow = a parallel probability timeline, free by construction. A shadowed model is one more transformer tapping the same feature stream, producing a lineage-tagged probability stream with no bookmaker attached. Zero market impact, real data, honest evaluation by the same taps that grade live models. This is the fork machinery from architecture.md doing product work.

Binding is configuration, not code. A bookmaker instance declares which probability stream it consumes. Promotion appends model_promoted {bookmaker}; at the next market open, that bookmaker folds its quotes from the new stream. The old model keeps running in shadow if desired (its stream simply loses its consumer) — instant rollback is re-promotion of the previous ModelId.

Run-manifest binding. The model → bookmaker binding is recorded in the run manifest as the recorded INITIAL state — the same treatment sleeve ceilings have (matching-engine §6 "run-manifest parameters"); for a run without a control log it is constant for the run, which trivially satisfies the constant-for-the-market rule; model_promoted supersedes it as the binding's home (the next block), and the constant-for-the-MARKET rule stands.

Control-root consumption. The runtime folds a RECORDED control log (a third root input beside the match and order logs; its stream id joins every derived stream's lineage parents, so a run with a control log derives distinct stream ids — event-catalog §4 lineage_ref: stream identity is a function of (name, lineage record)). The manifest binding is the recorded INITIAL state; model_promoted supersedes it at the next market_open; model_suspended severs it immediately — the sleeve emits pure-cancel batches for its resting quotes at the suspension record's stamp and stays silent until re-promotion. Registration is verified against the run's content-addressed artifact store (an operational input): the declared format_id, feature_schema_id, artifact_size, and meta_hash must equal the stored artifact's parse-verified facts, and a contradiction halts (corrupted history, never a guess). Binding actions name manifest HOUSE sleeves only — an external or undeclared sleeve halts (an external sleeve has one driver, its claiming account; an undeclared sleeve has no fold in the run) — a SLEEVE-level suspension is its own control record, read by the runtime's control consumer (risk-engine.md §5). Suspension's pull respects the halt machinery: markets Open at the suspension's merge position emit their pure cancel at the suspension's stamp; markets HALTED there defer — a batch on a halted market is rejected, and an own rejection is mirror divergence — and the cancel fires at each market's market_resumed, at the resume's stamp. Control actions name control-REGISTERED models only: the manifest's initial model is bootstrap state outside the §9 machine — to manage it through the control stream, register the same ModelId (its artifact in the store) and walk it through gates → shadow → promote, after which it is registry-managed like any other; to displace it without that, promote a replacement (the manifest model keeps emitting, unbound — the same semantics as a superseded model). Mechanics pins, all deterministic:

  1. Strict control join. A control record joins the first tick whose timestamp STRICTLY exceeds its stamp — a record stamped at a tick's own ts sorts after that tick's class-2 quote emissions (matching-engine §2), which is also exactly when a live drain surfaces it, so live and headless serving agree by construction (the merge-position rule). Orders join strictly too (dev-kickoff §1 rule 1): the remaining distinction is that the control join has NO state gate — control records are released by their stamp filter at every tick's fold, decoupled from drain timing — while the order join DEFERS to the first state-emitting tick after the stamp (a non-state tick, a kill emitting no version, would otherwise share a stamp with the next state tick and split live from headless).
  2. Shadow-start pin. A shadow taking effect at an open begins EMITTING with the first state evaluated after that open's fold point — the open's own tick has already run its MOD step, so the stream's first records carry the next evaluated state_version. A promotion of an ALREADY-EMITTING model, by contrast, takes quoting effect at the open itself: the binding flip arrives before the BM step, and quoting the newly-opened market from its lifetime pricing source at its own open is exactly the constant-for-the-market rule.

The run-level feed pause beside the sleeve suspension. A stall of the run's feed (risk-engine.md §3 R7) pulls every bound sleeve of the run, house and external alike, with the suspension's mechanics: a zero-level batch per sleeve with resting levels on a market Open at the stall record's merge position, at class 2 at its stamp, and a HALTED market's pull deferred to its market_resumed, at the resume's stamp — the deferral this section pins for the suspension, shared. The pull is the system's, never a second driver of an external sleeve: it carries no levels and drives the pure cancel alone. Unlike a suspension, the pause binds the RUN, is a record of the match log rather than of the control root, and lifts at the recovery record with no operator's resume; no model's binding moves. Under R7 v2 the stall record's tick also cancels every resting retail order on each such market after the pulls (retail_cancelled{feed_stall}, risk-engine.md §3 R7 v2 (e)); that cancel is R7 v2's record on the market-event stream, no quote batch, and a halted market's fires at its market_resumed, right after that record and before its parked pull and every other class 1–3 record of its stamp.

The sleeve suspension shares the mechanics. A sleeve_suspended on the run's control root (risk-engine.md §5; reason 0 the operator's pause, reason 2 the cross-run stop of a liquidated desk) pulls ONE sleeve, house or external, through the same emission: a zero-level batch on every Open market where the sleeve rests levels at the record's merge position (a market with nothing resting emits nothing), at class 2 at its stamp, a HALTED market's pull parked and fired once at its market_resumed. External sleeves are pulled on the control path as the feed stall pulls them — the system's pull, never a second driver. The parked pulls are ONE set shared by the model suspension, the feed stall and the sleeve suspension, and it carries no cause: a pull parked on a halted market is not cleared by a later sleeve_resumed and fires once at the market's resume (risk-engine.md §5). The sleeve records are partitioned out of the control stream before the registry folds it, so no model's binding moves and the registry never sees a sleeve record; a house sleeve's ticks emit no batch while it is suspended, and an external sleeve's quoting records are silenced.

The live writer edge: gateway-protocol §2 rows 7–13 are the operator frames (rows 12 and 13 the sleeve frames, pre-validated by the gateway's sleeve mirror, gateway-protocol §5 code 14), the control root is the serve's third recorded artifact, invalid actions are rejected on the wire by a drain-time mirror registry (never appended — the fold's halt stays a corruption detector), and the details documents live content-addressed beside the log (§10 gate-wiring note). Two wire rules keep the mirror sound (gateway-protocol §5 code 14): an action naming a model with a PENDING queued effect is refused until the open applies it (promotion after a pending shadow excepted — its acceptance is order-invariant, preserving the shadow-then-promote window above), so every accepted record's fold verdict is invariant to open interleaving; and re-registration is the §9 re-admission trigger for SUSPENDED models only — gates 1–3 re-run onto the log with no second model_registered record (a duplicate registration is corruption).

A/B/n: n candidate models = n shadow streams + one evaluator tap. Ranking comes from report cards over identical replayed spans — same inputs, same methodology, no special machinery.


12. Runtime execution model #

  • Title dispatch: the run manifest's title (cs2 — the omit-at-default value, so every earlier manifest keeps its bytes and run_id; a document spelling the field cs2 explicitly is refused, §12 below — lol, dota2, or synth (event-catalog §6.18: the match-winner market over the synth/v1 one-lane grid, served to no SDK client)) selects the plug-ins the scheduler binds at every seam, statically (runtime::scheduler::title, an enum per seam — no dyn, no allocation, the CS2 path byte-identical): the projector (cs2 state/v1|v2; lol state/v2, event-catalog §6.15.3; dota2 the betty FRAME projector, §6.11.1; synth synth_state/v1 forwarded verbatim, §6.18), the market policy (cs2 rounds; the match-winner policy, state-layers §3), the feature extractor — whose schema is resolved from the benched models' recorded feature_schema_id through the TITLE's registry (§8; a model bound to another title's schema never wires), the quote router and the outcome source (SC-7). The §7 wrapper path follows the title: enforce with the kill floor read off a cs2 state record, validate_raw on a match-winner snapshot. The serve edges refuse a match log whose recorded lineage is another title's before any fold runs.

  • Family dispatch: the manifest's family (absent = the title's default market — cs2 kills, lol / dota2 match winner — the omit-at-default value; race_to_kills = the race, kills_at_minute = the kills over N at minute M, side_kills_at_minute = one side's kills over N at minute M, gold_lead_at_minute = one side's GOLD LEAD over N at minute M, towers_at_minute / dragons_at_minute / barons_at_minute = one side's COUNT of that kind over N by minute M, total_kills / kill_handicap = the match's FINAL total kills / a team's FINAL kill lead over a line the HOUSE sets; match_winner = the title's DEFAULT market itself as a BOARD ENTRY (Family::MatchWinner / FamilyLines::MatchWinner, NO lines field, the MF-6 text alone in its MF-13 entry): a board of line families carries the headline market beside them, bound to the SAME plug-ins as the default market (bind_column's None | Some(MatchWinner) arms — the column's family is the one difference) and settled by the same SC-7 barrier, named <title>/match_winner on a board; ALONE it is refused (ManifestError::BoardDefaultAlone — at validate, so neither canonical_bytes nor a hand-authored file spells it): the empty board IS the match-winner run, and one definition has one byte form — every family on LoL and Dota2 (every line family; dragons and barons LoL only; the match winner is their default), never cs2 — and cs2 has NO board form at all: its kills default is no Family, so a cs2 board refuses by FamilyNotOffered; synth offers no family beyond its match-winner default, so a synth board is refused the same two ways — BoardDefaultAlone alone, FamilyNotOffered with any line family) — every LINE family swaps the title's market column for the family's (the winner's column IS the default market's column, its family tag the one difference): the SAME projector (the frame-kills seam's FrameTitle::Projector — lol/state/v2's fold on LoL, the identity frame fold on Dota2), then RaceToKillsPolicy<T> / the race extractor (LolRaceFeatureExtractor on the lol/race-v1 grid, Dota2RaceFeatureExtractor on dota2/race-v1 — each resolved through ITS title's RACE registry, §8) / BinaryRouter<Race<T>> / RaceOutcomeSource<T> (SC-8), all over the lines the FamilyLines::Race variant carries (MF-7 race_lines on the wire); or, under kills_at_minute, KillsAtMinutePolicy<T> / the minute extractor (LolMinuteFeatureExtractor on lol/minute-v1, Dota2MinuteFeatureExtractor on dota2/minute-v1 — each resolved through ITS title's MINUTE registry) / BinaryRouter<KillsAtMinute<T>> / KillsAtMinuteOutcomeSource<T> (SC-9), all over the lines FamilyLines::Minute carries (MF-8 minute_lines on the wire); or, under side_kills_at_minute, SideKillsAtMinutePolicy<T> / the side-minute extractor (LolSideMinuteFeatureExtractor on lol/side-minute-v1, Dota2SideMinuteFeatureExtractor on dota2/side-minute-v1 — each resolved through ITS title's SIDE-MINUTE registry) / BinaryRouter<SideKillsAtMinute<T>> / SideKillsAtMinuteOutcomeSource<T> (SC-10), all over the lines FamilyLines::SideMinute carries (MF-9 side_minute_lines on the wire); or, under gold_lead_at_minute, GoldLeadAtMinutePolicy<T> / the gold-lead extractor (LolGoldLeadFeatureExtractor on lol/gold-lead-v1, Dota2GoldLeadFeatureExtractor on dota2/gold-lead-v1 — each resolved through ITS title's GOLD-LEAD registry) / BinaryRouter<GoldLeadAtMinute<T>> / GoldLeadAtMinuteOutcomeSource<T> (SC-11), all over the lines FamilyLines::GoldLead carries (MF-10 gold_lead_lines, [minute, gold, team] triples on the wire — the fold reading GOLD through the title's FrameSource<Gold>); or, under towers_at_minute / dragons_at_minute / barons_at_minute, TallyAtMinutePolicy<T, K> / the tally extractor (LolTallyFeatureExtractor<K> on lol/<kind>-v1, Dota2TallyFeatureExtractor<Towers> on dota2/towers-v1 — each resolved through ITS title's TALLY registry for THAT kind) / BinaryRouter<TallyAtMinute<T, K>> / TallyAtMinuteOutcomeSource<T, K> (SC-12), all over the lines FamilyLines::Towers / Dragons / Barons carry (ONE wire field, MF-11 tally_lines, [minute, count, team] triples — the family text names the kind; the fold reading the TALLY through the title's FrameSource<Tally>; dragons and barons refused on dota2 by Family::offered_by); or, under total_kills / kill_handicap , HouseLinePolicy<T, K, Arc<HouseLineSetter<T, K>>> / the house extractor (HouseFeatureExtractor<T, K> on <title>/total-v1 / <title>/handicap-v1 — each resolved through ITS title's HOUSE registry for THAT kind) / BinaryRouter<HouseLineFamily<T, K>> / HouseLineOutcomeSource<T, K> (SC-13), all over the TERMS FamilyLines::Total / Handicap carry (ONE wire field, MF-12 house_line, ONE [open_minute, lo, hi] triple — the family text names the kind), the bind constructing ONE HouseLineSetter over the column's house sleeve's model as the bench holds it (the sleeve TAGGED to the family on a board, the first model sleeve on a single run — board-per- on a board; ModelBench::model; RuntimeError::HouseModelUnbenched when the bench lacks it) and handing it to the policy and the extractor alike; the dispatch is exhaustive on (title, family), so a family a title starts offering gains its own arm, never a fall-through. On Dota2 the shared frame fold's reader carries the match's roster binding, so every line plug reads the sides from the stream's own roster record. The BOARD: a run serves the families its manifest's board: Board lists on each match — empty = the title's default market, one = the single-family run, two or more = a board; the earlier one-family-per-run rule ("a run offering two families is a multi-model scheduler, its own project") is retired at the DEFINITION and at the scheduler (between the two the runtime refused a board of two or more by name — RuntimeError:: BoardUnsupported, in Scheduler::new before any stream opened, the run still Defined — a refusal retired with the columns, the arm gone). The bookmaker still routes one open market per side PER FAMILY (BinaryRouter indexes only its own family's market_created) — the board adds columns, never a second market on one side of one family. The board is a set in wire-name order (Family::name, immune to the enum's declaration order) with at most one entry per family (Board::new sorts the operator's spelling and refuses a duplicate, ManifestError::BoardDuplicateFamily; more than Board::MAX_FAMILIES = Family::ALL.len() (10, the match winner among them) entries refuse by count, BoardTooMany) — one definition, one byte form; the DECODER never normalizes: an MF-13 array out of that order is refused by index (BoardOutOfOrder), as the bookmakers array refuses an unsorted sleeve (SleeveOrder); and a document that decodes but is not the encoder's spelling of what it decodes to — an omit-at-default field spelled AT its default, 4: 16 or 5: "cs2" (MF-14 allocation_ref, has no at-default spelling: absent IS its default, a run citing no allocation; present, it is the closed nested map {0: epoch_id, 1: allocation_offset} of allocation-market.md §8, a malformed one refused by AllocationRefWire) — is refused by the lowest differing TOP-LEVEL field (ManifestError::NonCanonical { field }; a divergence inside a bookmaker or board entry is named by its top-level key, 1 or 13, not by the entry's index), the compare run after validate so an invalid document keeps its own refusal — so a stored file's bytes are the bytes its run_id derives from. On the wire an empty board is the family fields ABSENT, a board of one is the legacy top-level MF-6..12 form (every single-family manifest and run_id since unchanged — pinned over the committed fixture manifests by runtime/tests/board_manifest.rs) — EXCEPT the title's default market, whose board of one would be the empty board's second spelling: it is refused at validate (BoardDefaultAlone — on encode and on a hand-authored file alike, since both paths validate; the one-entry board would otherwise serve through the untagged single-family path with the empty board's exact streams under a second run_id) —, a board of two or more rides MF-13 board alone: an array of per-family maps, each map the family's own MF-6..12 encoding (the MF-6 text ALONE for a family with no lines field — the match winner; count-capped before any collect, each entry a map through the one family reader, an entry without a family text refused by index — BadBoardEntry / BoardEntryWithoutFamily; MF-13 beside a top-level family field, or with fewer than two entries, refused — BoardBesideFamily / BoardTooFew). Each sleeve carries an optional FAMILY tag (BK-4 family, the family text, read by the ONE family-text reader MF-6 uses — Family::read_text, length-capped before any clone): on a board every model sleeve names the family its model prices (ModelSleeveNeedsFamily otherwise — the scheduler routes that model's distributions to that family's markets alone and checks the tag against the model's recorded feature schema at bind), a tag must name a family the board carries (SleeveFamilyNotOnBoard), off a board the tag is refused (SleeveFamilyOffBoard — it would be a second byte form of one definition), a second model sleeve tagged to one family is refused (ModelSleeveFamilyTwice — the family's house, a house-line family's SETTER, must be unambiguous inside the run identity: one model sleeve per family — the rule binds MODEL sleeves on both sides), and an EXTERNAL sleeve MAY carry a tag on a board (it was refused as a field nothing honoured): absent, the sleeve quotes every family of the board; present, the sleeve is SCOPED to that family — the gateway's hello_ack quoting directory carries the scope as the entry's field (2), the family's claim text (gateway-protocol §2 frame 129 field 4), refuses a quoting frame naming another family's market wire-side (§5 code 17) and the runtime halts on such a recorded record (RuntimeError::QuotingScope, architecture.md external drive rule 3); several external sleeves may scope to one family (they set nothing), and a scoped external beside the family's model sleeve is the normal shape; the tag must still name a family the board carries (SleeveFamilyNotOnBoard) and is refused off a board (SleeveFamilyOffBoard), as a model sleeve's is. The scheduler binds a scoped external sleeve to its family's column (bind_board's sleeve_column, which Scheduler::new loads into bookmaker_column — read by Scheduler::sleeve_covers and sleeve_scope); an unscoped external sleeve has no column and covers every market. The house-line rule reads per family: each house-line family on the board needs a model sleeve tagged to IT (HouseLineNeedsHouseModel { family }); on a single-family house run any model sleeve is the house's, as before. Those are the only columns that need a model: a board column with no model sleeve — external sleeves only — is a VALID run definition for every other family (researchers quote it; its grid is the family's v1 — the column rule below), exactly as an external-only single-family run is (pinned: scheduler/board.rs an_external_only_board_column_is_a_valid_definition). The ops kit refuses such a board (house_models_for_title resolves one house model per family) — an operational choice of the kit, not a manifest rule (the question whether the manifest should demand a house per column was ruled NO). The bookmaker array itself is count-capped (MAX_BOOKMAKERS = 64, TooManyBookmakers, by count before any entry is read — §16; since RunManifest::validate holds a CONSTRUCTED manifest to the same bound, so every encoding path produces bytes its own decoder accepts). d2-manifest takes --family more than once, each occurrence opening the group its lines flag belongs to (the same lines flag twice in one group refuses; a match_winner occurrence opens and closes a group with NO lines flag — a lines flag beside it refuses LinesWithoutFamily), and --sleeve <id>:<ceiling>:model:<hex>[:<family>] and --sleeve <id>:<ceiling>:external[:<family>] — the external sleeve's tag SCOPES it — an unknown tag is refused by its text on either). (the SCHEDULER's board columns — runtime::scheduler::board): bind_board binds ONE COLUMN per board entry (FamilyColumn — its own LifecycleFold, so a family's barrier chain never serialises against another's; its extractor on the grid its TAGGED house sleeve's model binds, None ⇒ the family's v1; its router; its outcome source to the clearing engine's matching column, ClearingEngine::board in the board's order), the run's ONE projector shared; every benched model belongs to exactly one column — the one whose grid is its recorded schema (column_for_schema: none UnknownFeatureSchema, several AmbiguousSchema; two sleeve models of one column on different grids MixedColumnSchemas; a later column asking for another projector than the first's MixedColumnProjectors, unreachable by the arms as written) — and is evaluated on THAT column's vectors alone: column_active, the active set's ONE home, partitioned by column and maintained by set_active (never a per-tick filter), the bench walking a column's set rather than the whole bench, and every features record reaching a model checked against the model's recorded grid first (BenchError::ForeignGrid — two grids of one lane count can never mis-price silently). A column's GRID is the one its tagged model sleeve's artifact records (None — an external-only column — the family's v1), which replaces the single-run "the run's grid is the one every benched model binds" (dust2/v2 per-cadence, ModelBench:: feature_schema_id — now a bench-level invariant with no scheduler caller): a benched model of a grid no column emits is refused at bind, never served on a grid it was not trained on. A sleeve's column is fixed at bind (Scheduler::bookmaker_column) and a model's distributions drive that router; every column's router indexes market_created and the column that did is the market's (market_column — market_classes / payout_of answer in one lookup); the house-line setter is PER FAMILY — the column's tagged sleeve's model (settlement-contract SC-13 rule 5 and its restatements amended); a recorded Live{bookmaker} binding must put a model on a sleeve of ITS column (BindingColumnMismatch — §9 "Effect timing", the board clause); a registration assigns the model its column by schema (none ⇒ UnknownFeatureSchema at the fold, two ⇒ AmbiguousSchema — the gateway refuses such a registration at the edge first, gateway-protocol §5 code 16 unknown_feature_schema, so either halt is the corrupted-history path only); the gateway gates an artifact against the column whose grid it binds (Scheduler:: schema_for, the driver's one lookup, unique-or-none), and an artifact no column emits is not gated at all — never guessed into a column (§10). The tick runs LC for EVERY column (board order) before any FX/MOD, then FX → MOD per column in board order, then BM — so "lifecycle before distributions at equal ts" (architecture.md's quote-fold amendment) holds board-wide, and the ONE lifecycle stream and the ONE features stream carry their cross-family records in board order (event-catalog §6.9 / §6.3). The equivalence pin (runtime/tests/board_serve.rs): a board serves each family EXACTLY as that family's single run does — identical lifecycle records, fills and settlement rows per family, the barriers naming their family, the settlement stream's cross-family order the board's, a house-line column's line set by ITS tagged sleeve — on LoL and Dota2, byte-identical twice; the control edge over a board (runtime/tests/board_control.rs): a cross-column promotion halts by name, a within-column one binds. the MATCH WINNER joins the board — Family::MatchWinner (above), so a title's board can carry its headline market beside the line families; the pin (board_serve.rs): a race + winner board serves the winner EXACTLY as the title's default run does, the barrier named. The box serves BOARDS — FH_BOARD_BY_TITLE="<title>:winner,race,…" names a plain menu entry's board (the winner suffix the match winner, every other the family serve whose lines map and house model the board reuses), build-manifest.sh --family … --board-model <family>:<path> renders one tagged model sleeve per family (ids 10, 11, …), house_models_for_title hands every model to d2-gateway / d2-runtime ([model.d2m ...]), readiness and the determinism sweep run the whole board, deploy.sh migrates a family-serve menu into boards once (migrate-boards.sh); the SDK app bridge routes each settlement_complete to ITS family's markets (SC_FAMILY, the engine's covers rule) and /health lists the claim families the run has created markets for so far; pinned over the real three-family lol_board fixture (sdk/python/tests/test_bridge.py: the minute family's epoch-0 barrier settles neither the race to 10 nor the winner). hello_ack's quoting directory is scoped per family (gateway-protocol §2 frame 129 field 4 (2), §5 code 17) and an external sleeve may carry the BK-4 tag on a board (the paragraph above). The workbench's board view (sport → match → board) is out of scope here. The settlement barriers name their family on a board (event-catalog §6.8 settlement_complete.family / void_complete.family — OPTIONAL, absent on a single-family run, a bare barrier matching by epoch alone) and the exchange engine, the bookmaker fold (both through SettlementComplete::covers) and the clearing engine retire by (series.family, epoch); the lifecycle fold advances only on its own family's barrier (LifecycleFold::family); the clearing engine holds one column per family (ClearingEngine::board, in wire-name order); the SDK bridge routes by family (above). A family the title does not bind refuses at validate (ManifestError::FamilyNotOffered), on a board as on a single run; a family WITH its lines is ONE value (FamilyLines — the board's entry type — Race / Minute / SideMinute / GoldLead / Towers / Dragons / Barons / Total / Handicap / MatchWinner, each variant a family over its lines (the two house-line variants over their TERMS; MatchWinner the one entry with none)), so lines without their family, or a family without its lines where its family HAS a lines field (Family::lines_field — None for the match winner, whose text alone pairs), are unrepresentable in the struct and are refused where the parts arrive apart — the wire's family text beside its lines arrays and the CLI's --family beside its lines flag — by the ONE rule FamilyLines::pair (ManifestError::LinesWithoutFamily { lines, family } names the lines field given and the run's family — every lines field beside match_winner is foreign —, LinesMissing(family) a lines-bearing family with no lines; being the decoder's, these precede validate's FamilyNotOffered — a hand-authored cs2 + race_to_kills file with no MF-7 is told its lines are missing), the lines rules each having one home (replay::race_to_kills::RaceLines::new: non-empty, ascending, 1..=500, at most 16; replay::kills_at_minute::LinesAtMinute::new — ONE rule for all six minute families: non-empty, minutes strictly ascending with the quote window (the first ≥ 2, each later ≥ the previous + 3 — settlement- contract SC-9 rule 5) in 0..=200, at most 16 — each line's own check adding its threshold cap (kills in 0..=500 for both kills families, check_kills — a line judged on another quantity caps its own) and the side line's team wire in {0, 1}, the gold line's cap — GoldLeadLine::check, MAX_GOLD_LEAD = 100000, MinuteLinesError::GoldOutOfRange — beside check_kills, the tally line's count cap — TallyLine::check, MAX_TALLY = 20, MinuteLinesError::CountOutOfRange; on the wire an array of [minute, kills] uint pairs, MF-8, of [minute, kills, team] uint triples, MF-9, of [minute, gold, team] uint triples, MF-10, or of [minute, count, team] uint triples, MF-11 — ONE field for the three tally families, the kind in the MF-6 family text alone, so the pairing refusal compares by lines FIELD; the offer table for the tally families is read off each title's TallyTitle::TALLY_KINDS_OFFERED — Family::offered_by never re-spells it); and — MF-12 house_line, ONE [open_minute, lo, hi] uint triple (not an array of items) for the two house-line families (total_kills, kill_handicap — the kind in the MF-6 family text alone, the same by-FIELD pairing), values through replay::house_line::HouseTerms::new (1..=200, lo ≤ hi ≤ 500, at most 128 candidate thresholds — ManifestError::HouseLine); a house-line run also requires a house sleeve bound to a model — the setter's — refused by validate otherwise (ManifestError::HouseLineNeedsHouseModel { family }; on a board the sleeve tagged to that family, above).

  • The feed rule: MF-15 feed_rule is the closed map {0 tag: bytes — a match-log lineage tag the ingest crate's tag registry declares, ending in /v followed by digits, at most 64 bytes (MAX_FEED_TAG_LEN, §16); 1 stall_ms: u64 above 0; 2 continuity: u64, 0 increasing, 1 contiguous}, the route of one field-78 row of the cited epoch document (risk-engine.md §7). Absent is no rule: the field absent, so every earlier manifest keeps its bytes and its run_id. Present it is whole — a partial map refused by its missing key, an unknown key by name, and a bad tag, a zero stall_ms and a continuity outside {0, 1} by ManifestError::FeedRuleTag, FeedRuleZeroStall and FeedRuleContinuity; the tag's title is the manifest's (ManifestError::FeedTagTitle, the registry's row naming the title). check_match_log requires the served lineage to be the rule's registered tag followed by exactly a 32-byte source ref (FeedTagMismatch); the epoch store edge holds the rule equal to a row of the cited document (FeedRuleMismatch) and requires it under a document whose risk chain names R7 (FeedRuleMissing). With it the manifest map's closed key set is MF-0..15.

  • The retail fee rule: MF-16 retail_fee_rule, the manifest map's key 16, is the closed map {0 version: u64, 1 hash: 32 bytes} — a pinned RULE IDENTITY (CLAUDE.md law 7(b), law 8), the version and the content hash of the declared fee rule that matching-engine §6's subaccount admission applies (risk-engine.md §3 R2 v2). Its one declared version, v1, is "no fee": no admission term, no fee moved by a fill — a CONFIG default, its source the tokenomics note's "Status for Monday" ("Still open: … fees") and its "Before commitments open" list ("… fee rules …"), replaced only by a dated ruling record, a later version applying from the next epoch (its document's field 79 and every manifest citing that epoch held equal to it; futarchy-system.md §9 C108); a manifest citing no allocation carries no epoch's commitment, its MF-16 pin its own, changed only by a later manifest (C108). The fee's base, rate, per-fill or per-order application and recipient are a dependency on the economist, declared by a later version from the first epoch that pins it, or from the first manifest pinning it for a run citing no allocation; its recipient is part of that version's declared text, and every version is paid in the stablecoin unit, mills (the tokenomics note: "fees are paid in stablecoins"). A rule's content hash is the BLAKE3 over the UTF-8 bytes of its canonical rule text, as a rule field's hash covers that rule's own declared text (futarchy-system.md §2, allocation-market.md §3). The declared pairs, the one table ManifestError::RetailFeeRule checks against:

    version canonical rule text hash
    1 no fee: an admitted subaccount instruction carries no fee term and no fill moves a fee BLAKE3 of the text's UTF-8 bytes

A (version, hash) pair naming no declared rule is refused (ManifestError::RetailFeeRule). Under a cited epoch document the epoch store edge holds it equal to the document's field 79 (ManifestError::RetailFeeRuleMismatch, a document without field 79 matching none; risk-engine.md §7), so one epoch's runs apply one published fee rule. Absent, the field is no fee rule — never a decoder default of "no fee": every earlier manifest keeps its bytes and its run_id, and the run serves no operator session (gateway-protocol §2 row 14 refused, §5 code 22), so no subaccount instruction is admitted without a pinned rule. With it the manifest map's closed key set is MF-0..16.

  • The resting-order cap: MF-17 max_resting_orders, the manifest map's key 17, is a u64 in 1..=2^32 − 1 — a pinned VALUE (CLAUDE.md law 8(c)): the most resting retail orders one subaccount may hold at once (matching-engine §5 step 8; a GTC place whose remainder would rest beyond it is order_rejected{resting_cap}). The notes set no such limit; it bounds the fold's per-subaccount state, so it is CONFIG with no default in code — the value is the manifest's, never a code constant or a decoder default, changed only from the next run manifest. A zero or a value above 2^32 − 1 is refused. Absent, the run serves no operator session, exactly as an absent MF-16 (gateway-protocol §2 row 14 refused, §5 code 22): a run serving operator sessions pins both. With it the manifest map's closed key set is MF-0..17.
  • Within a tick, admitted models are evaluated in ascending ModelId order — outputs are independent, but the interleaving of emitted events in shared logs must be deterministic, so the order is pinned.
  • Evaluation is sequential in the Exchange Runtime's deterministic process (architecture.md Level 2; co-located with the Clearing House in one process per the scope ledger). Parallel evaluation is a permitted later optimization only with a deterministic merge of outputs back into ModelId order; it is banned until measured need exists.
  • Hot path is zero-allocation: artifacts are memory-mapped and validated once at registration; feature vectors and distributions are stack arrays.
  • Budget accounting: model evaluation is inside Flow 1 steps 1→9 and therefore inside the 250 ms p99 repricing KPI. Measured expectation: microseconds per model per tick (§5).

13. Python SDK and the exporter #

The exporter is the bridge from the float training world to the fixed-point runtime world. Procedure (normative):

  1. Feature grid: load the schema; quantize training data onto the schema's integer encodings before training, so the model only ever sees representable values and thresholds land on the grid naturally.
  2. Train (LightGBM/CatBoost multiclass — 6 classes for CS2 kills, 2 for a LoL win/loss model) in Python as usual. The exporter records the model's num_class as the artifact's n_classes (format v2 — mirrors the Rust format; a 6-class artifact stays byte-identical), so the Python reference evaluator + reader + the §15 differential harness run at the recorded width, not a hardcoded 6. If training used a §8 projection's columns, remap each split's feature index from its projected column position to the absolute schema index via the projection's recorded index list (the exporter's feature_map; the artifact binds the full schema id per the §8 projection-consumption note).
  3. Quantize thresholds: for each split on feature f, t_q = floor(t * scale_f). Because training data was pre-quantized, x <= t and x_q <= t_q agree on every representable input — traversal is exact, not approximate.
  4. Quantize leaves: leaf_q = round(leaf * 2^16) into i32 (range-checked).
  5. Fold base conversion: score_rescale = round(log2(e) * 2^16) composed with any global learning-rate scaling — this makes runtime base-2 softmax equal training-time natural softmax (§6).
  6. Generate golden vectors (≥1024; coverage rules §15) by running the Python reference evaluator — a line-for-line port of §5–§6, including the pinned EXP2_LUT — never the float model.
  7. Serialize canonical bytes (§4), compute ModelId.

SDK surface:

artifact = sdk.export(booster, schema="dust2/v1")     # steps 3–7 (or "dust2/v2", the enriched schema)
report   = sdk.backtest(artifact, split="validation") # gate-4 preview, local
mid = sdk.register(artifact)      # → control-stream event; gates run
sdk.shadow(mid)                   # → Shadow at next market open
sdk.promote(mid, bookmaker="B")   # → Live at next market open
sdk.suspend(mid)                  # immediate kill switch

sdk.stream(mid).read(offset=0)                     # read-only tap
sdk.replay(match="0042", model=mid)                # counterfactual fork

The SDK writes only to the two legal impure edges: the control stream and the order log. Every other capability is a read-only tap and structurally cannot affect the spine.

The float model is a training convenience; the artifact is the model. All evaluation, admission, and the gates run against the quantized artifact. Quantization loss shows up in the gate-4 report card like any other modeling choice — if quantization hurts RPS, that's real information, not measurement error.

The LoL corpus. The same dataset pipeline serves LoL through the title-neutral halves of trainset. d2-trainset lol-ingest materializes a Riot (match, timeline) JSON pair into a sealed lol/ match-log segment (riot-edge → SegmentWriter, lineage = ingest::lol::lineage carrying the derived Riot gameId); d2-trainset lol-export replays each segment through the REAL replay::lol::LolStateV2Projector → REAL pricing::feature::lol extractor on the lol/v1 grid (§8), labels every per-minute snapshot with the single match outcome blue_win read from lol/match_complete/v1 (a LoL match is one 2-class outcome — there is no per-round settlement; one row per snapshot, fixed blue perspective), and writes the chronological split + manifests

  • hashes through the shared DatasetWriter (the CS2 export and this share one CSV/manifest/hash implementation). The chronological split keys on the Riot gameId (monotonic per routing region — ingest::lol::lineage; a title-specific exception to trainset::split's CS2-derived "match ids are NOT a chronology proxy" warning — Dota2 deviates differently, keying on the archive match_id with no chronology claim). The corpus-CLI ingest refusal taxonomy (lol-ingest, pinned by trainset/tests/lol_corpus_cli.rs; the Dota2 twin pinned by trainset/tests/dota2_corpus_cli.rs): the pair directory is walked sorted. Three classes ABORT the run: a .match.json with no sibling .timeline.json (a corpus-layout defect — nothing further is sealed); a corpus file over riot-edge's 16 MiB MAX_CAPTURE_BYTES, refused at the file boundary, unread (a capture defect, checked before the resume check); and a payload that fails riot-edge's parse/hardening (RiotError, not Ineligible — poisoned bytes, LolCorpusError::Riot's own doc). One class is a PER-MATCH refusal: an adapter-ineligible match (Ineligible::*) — the run continues and leaves no segment at the derived path. Resume: a present segment that reads back sealed with the same derived id is left untouched (resume-safe), and one that does not (torn by a killed run, unsealed, a foreign id, or any stale file under that name) is removed BEFORE materialization — SegmentWriter is create_new, so leaving it would abort the run; the refusal path therefore has nothing left to clean up (the CS2 ingest arm's resume convention, trainset::ingest, restored for the two newer titles). dota2-ingest (trainset::dota2_corpus::materialize_dota2_shard) has the same orphan-abort / per-match-refusal / resume classes and received the same torn-segment fix the same day; it needs no CLI-side capture cap because dota2-edge's parquet readers cap the whole table file themselves (source::MAX_TABLE_BYTES). Its abort classes are the shard-pairing defect (a ticks/<name> with no buildings/<name> — the corpus-layout analogue) and any source-table defect dota2-edge's readers refuse (SourceError → Dota2CorpusError::Source, LolCorpusError::Riot's twin): a table over that cap, refused on its length and unread (SourceError::OversizeTable — unlike LoL's, this cap lives inside the reader, so it is not a separate CLI-side class), a real table of the wrong shape (MissingColumn), poisoned bytes; corpus-layout and segment-I/O failures (read_dir, Dota2CorpusError::Segment) abort likewise. Its per-match refusals are the typed MatchRefusals (Frame, RosterSide, and NoMetadata — kept as its own variant though structurally unreachable once RosterSide succeeded, both reading the same metadata row), each leaving no segment at the derived path; one shard walk can seal, refuse and skip matches side by side (the archive is sharded — one table pair holds many matches; pinned with all three counters non-zero from one walk). The step-2 "6 classes" above is CS2's kill-outcome space; a LoL model is 2-class (blue/red win) — model-format-v2 (variable n_classes; which this corpus preceded) now records n_classes and evaluates/backtests a 2-class artifact, so this corpus can be backtested once a model is trained. Training itself (the float model) and a production-scale Riot pull are operational work outside this repo (dev-kickoff scope ledger; core law 6).

The LoL race corpus. d2-trainset lol-race-export <segments> <out> --lines 10,15,20 replays the SAME sealed segments (the walk is shared with lol-export) through the REAL LolStateV2Projector → REAL LolRaceFeatureExtractor on the lol/race-v1 grid (§8): one row per per-minute snapshot WHILE A LINE IS LIVE, for that line, labeled 1 if the perspective side reached the line first and 0 if the other side did — the label read from the same RaceTracker the serve settles on (settlement-contract SC-8), never from match_complete. The rows of a line that VOIDS (a tie at its crossing frame, the match completing first) are DROPPED: nobody is paid on a void, so the model's target is P(blue first | the line settles). A log in which a priced line never resolves (a torn capture with no completion) refuses (LineWithoutOutcome). The lines are a corpus parameter: the vectors carry the line, so a model trained on {10, 15, 20} serves the manifest whose race_lines are those — a different lines set is a different dataset. d2-trainset lol-race-schema <out> writes the document the trainer binds (--num-class 2, --projection lol/race-baseline-v1 for the baseline recipe); lol-backtest scores a race-bound candidate through the title's union registry.

The Dota2 race corpus. d2-trainset dota2-race-export <segments> <out> --lines 10,15,20 replays the SAME sealed betty segments (the walk is shared with dota2-export) through the REAL Dota2FrameProjector → REAL Dota2RaceFeatureExtractor on the dota2/race-v1 grid (§8): one row per frame WHILE A LINE IS LIVE, labeled 1 if radiant reached the line first and 0 if dire did — the label from the same RaceTracker the serve settles on, the sides from the segment's own roster record, voided lines' rows DROPPED, a torn log refused — exactly the LoL race corpus's contract with the raw archive tick as the row's time bucket. d2-trainset dota2-race-schema <out> writes the document the trainer binds (--num-class 2, --projection dota2/race-baseline-v1 for the baseline recipe); dota2-backtest scores a race-bound candidate through the title's union registry.

The kills-at-minute corpora. d2-trainset lol-minute-export <segments> <out> --lines 10:14,15:24,20:36 and dota2-minute-export … --lines 10:13,15:23,20:34 (the served house models' lines — each title's corpus medians at minutes 10 / 15 / 20) replay the SAME sealed segments through the REAL projector → REAL minute extractor on the <title>/minute-v1 grid (§8): one row per snapshot WHILE A LINE IS LIVE, for that line, labeled 1 if the total exceeded the line at its minute and 0 if not — the label from the same MinuteTracker the serve settles on (settlement-contract SC-9), the rows of a line the match ends before DROPPED (nobody is paid on a void: the target is P(over | the line settles)), a torn log refused (LineWithoutOutcome naming the line's minute and kills). Both race and minute builders on both titles are the ONE trainset::line_dataset pair of passes (pass 1 the family fold's outcomes by epoch through replay::line_family::LineFamily, pass 2 the extractor's rows joined to them) — each title/family module binds only its extractor, its frame-index read and its error names. The lines are a corpus parameter (a model trained on {10:14, 15:24, 20:36} serves the manifest whose minute_lines are those — a different set is a different dataset), and the rule they must satisfy is the family's (MinuteLines::new: minutes ≥ 2 and ≥ the previous + 3, kills ≤ 500); d2-trainset lol-minute-schema / dota2-minute-schema <out> write the documents the trainer binds (--num-class 2, --projection <title>/minute-baseline-v1 for the baseline recipe); the title backtests score a minute-bound candidate through the title's union registry.

The side-kills-at-minute corpora. d2-trainset lol-side-minute-export <segments> <out> --lines 10:7:0,13:10:1,16:13:0,19:16:1 and dota2-side-minute-export … --lines 10:5:0,13:8:1,16:11:0,19:14:1 (the served house models' lines — each team's corpus median at minutes / frames 10, 13, 16, 19, the teams interleaved so one run trains both team lanes) are the minute exports on the <title>/side-minute-v1 grid: one row per snapshot WHILE A LINE IS LIVE, labeled 1 if the line's TEAM's count exceeded the line at its minute (the same MinuteTracker, over side lines — settlement-contract SC-10), the rows of a line the match ends before DROPPED, a torn log refused (LineWithoutOutcome naming the line's minute, kills AND team). The same ONE trainset::line_dataset pair of passes. The lines rule is the minute family's plus the team wire (SideMinuteLines::new: minutes ≥ 2 and ≥ the previous + 3, kills ≤ 500, team ∈ {0, 1}; every item <minute>:<kills>:<team>); lol-side-minute-schema / dota2-side-minute-schema <out> write the documents the trainer binds (--num-class 2, --projection <title>/side-minute-baseline-v1 for the baseline recipe); the title backtests score a side-minute-bound candidate through the title's union registry.

The gold-lead corpora. d2-trainset lol-gold-lead-export <segments> <out> --lines 10:0:0,13:2100:1,16:0:0,19:3600:1 and dota2-gold-lead-export … --lines 10:0:0,13:2500:1,16:0:0,19:3900:1 (the served house models' lines — a "team is ahead" line (N = 0, ~50 %) and a median-|lead| line (~25 %) per title, measured on the minute corpora's train splits at minutes 13 and 19; the teams interleaved) are the minute exports on the <title>/gold-lead-v1 grid: one row per snapshot WHILE A LINE IS LIVE, labeled 1 if the line's TEAM's gold lead exceeded the line at its minute (the same MinuteTracker, over gold lines, reading gold — settlement-contract SC-11), the rows of a line the match ends before DROPPED, a torn log refused (LineWithoutOutcome naming the line's minute, gold AND team). The corpus passes are the race's (trainset::line_dataset). The lines rule is the side family's with the gold cap in place of the kills cap (GoldLeadLines::new: minutes ≥ 2 and ≥ the previous + 3, gold ≤ 100000, team ∈ {0, 1}; every item <minute>:<gold>:<team>, the gold up to six digits); lol-gold-lead-schema / dota2-gold-lead-schema <out> write the documents the trainer binds (--num-class 2, --projection <title>/gold-lead-baseline-v1 for the baseline recipe).

The tally corpora. d2-trainset lol-tally-export <segments> <out> --kind towers|dragons|barons --lines <m:n:t,…> (the served house models' lines — towers 13:0:0,16:0:1,19:1:0,22:2:1, dragons 16:0:0,19:1:1,22:1:0,25:1:1, barons 22:0:0,25:0:1, measured on the LoL corpus's train split: the first line where P(> N) crosses a fifth, the teams interleaved) and dota2-tally-export … --lines 10:0:0,13:0:1,16:1:0,19:1:1 (towers — the kind Dota2 offers) are the minute exports on the <title>/<kind>-v1 grid: one row per snapshot WHILE A LINE IS LIVE, labeled 1 if the line's TEAM's count of the kind exceeded the line at its minute (the same MinuteTracker, over tally lines, reading the frame reader's tally — settlement-contract SC-12), the rows of a line the match ends before DROPPED, a torn log refused (LineWithoutOutcome naming the line with its kind); ONE generic builder per title (build_lol_tally_rows::<K> / build_dota2_tally_rows::<K>) over the same the corpus passes are the race's (trainset::line_dataset). The lines rule is the side family's with the count cap in place of the kills cap (TallyLines::<K>::new: minutes ≥ 2 and ≥ the previous + 3, count ≤ 20, team ∈ {0, 1}; every item <minute>:<count>:<team>, the count up to two digits); the --kind word is the kind's own spelling (TallyKind::from_name) — an unknown kind prints the usage and exits 2; lol-tally-schema <out> --kind <k> / dota2-tally-schema <out> write the documents the trainer binds (--num-class 2, --projection <title>/<kind>-baseline-v1 for the baseline recipe).

The house-line corpora. d2-trainset lol-house-export <segments> <out> --kind total|handicap --house-line <K:lo:hi> [--stride <s>] and dota2-house-export … (the served house models' terms — LoL total 10:20:120 at stride 4, LoL handicap 10:0:64 at stride 4, Dota2 total 10:25:140 at stride 8, Dota2 handicap 10:0:64 at stride 8: the ranges cover the corpora's final totals (q05..q95+, the corpus survey: LoL q50 53, Dota2 q50 58, max 127 / 150) and final leads (max 37 / 64)) are the exports on the <title>/<kind>-v1 grid with a DIFFERENT row rule from every line export: one row per (snapshot from ordinal 1, candidate line of the terms at the stride — both teams on the handicap), labeled 1 if the line pays OVER on the match's FINAL frame (HouseLine::winner over the fold's last frame — settlement-contract SC-13 rule 3), 0 otherwise; every match with a frame labels its rows on its last frame whether or not it reached the terms' opening ordinal (the corpus covers every opening an operator may choose), a torn log refused (NoMatchComplete). Why every candidate on every snapshot: the served model prices ONE line per match, but the SETTER chooses it by evaluating the model over every candidate on the opening snapshot, and the extractor then prices that line as the total grows past or short of it — the model must be good over the whole (snapshot, line) grid; the stride thins the line axis (the lanes carry the line, the model interpolates) and the served range must sit inside the trained one. ONE generic builder for both titles and kinds (trainset::house_dataset::build_house_rows::<T, K> over the pricing HouseGrid seam — the extractor's own vector builder); the row counts are an order of magnitude above the line families' (LoL total 2.3 M, LoL handicap 3.0 M, Dota2 total 4.5 M, Dota2 handicap 5.4 M rows). The --kind word is the grid word (total / handicap), the terms item the manifest's spelling; a malformed or missing flag prints the usage and exits 2; lol-house-schema <out> --kind <k> / dota2-house-schema <out> --kind <k> write the documents the trainer binds (--num-class 2, --projection <title>/<kind>-baseline-v1 for the baseline recipe).

The export-side refusals (every title and every arm — export / lol-export / lol-race-export / lol-minute-export / lol-side-minute-export / lol-gold-lead-export / lol-tally-export / dota2-export / dota2-race-export / dota2-minute-export / dota2-side-minute-export / dota2-gold-lead-export / dota2-tally-export / lol-house-export / dota2-house-export, the one shared walk; all ABORT the run with a non-zero exit before any dataset file is final): (1) a match id recorded by two segment files (double-weighting in training) — refused in pass 1, before the writer opens anything (each CLI's duplicate guard); (2) CS2 only: a mixed-cadence directory (sample_every or enriched differing between segments — the cadence fixes the grid width and the window semantics) — refused in pass 1 (export_cadence.rs); (3) zero snapshot rows: a segments directory that yields NO rows — empty, or every match yielding zero rows — is refused at the shared writer's finish (WriterError::Empty, "no dataset rows across N routed match(es)"): no manifest and no hash sidecar is written, and every file of the dataset set (dataset_writer::DATASET_FILES: the three CSVs, three manifests, two sidecars) is removed from the output directory — the header-only CSVs create opened AND a previous export's manifests and sidecars, whose CSVs create had already truncated (an old generation's hashes must not stand over data that is gone); files the writer does not own are untouched. The removal is best-effort: a file already gone is the goal state, and any other removal failure is NAMED in the message (; could not remove: <name>: <io error>) rather than masking the refusal. Each title's CLI prints its pass-2 accounting line (segments read; rows, or CS2's rounds settled / rows dropped) BEFORE finish, so a refused run still reports what was read. The hazards closed: an exit 0 that operator scripts branch on; split/generation hashes over empty manifests banked as lineage; and the readers — the Python research/d2m/dataset.py and its Rust twin trainset::readback::parse_rows (whose own Empty means a file with no header line) — both of which parse a header-only CSV without complaint. The consumer-side twin is pricing::backtest::BacktestError::Empty (zero snapshots at evaluation) — one policy at two edges, the evaluator seeing only a generation that already exists on disk. The output directory is the operator's generation workspace, not a lineage store: dataset_split_hash is copied into the artifact's §4 metadata at training time (research/d2m/train.py) and onto the card, while dataset_generation_hash exists only as this sidecar (§10) — re-exporting into a live dataset directory destroys that generation with or without this guard (create truncates the CSVs first), so a new generation belongs in a fresh directory. The guard is whole-corpus: a 1–2 match corpus still exports header-only train and/or validation CSVs with empty manifests — with n = 1 the single match IS the sealed test split (assign_chronological's two cuts both land at 0; pinned by dota2_export_row_ticks_are_the_spec_cadence_ticks); emptiness of the EVALUATED splits is the gates' call (BacktestError::Empty / TooFewMatches), and an empty or tiny TRAIN split is not gated here at all — flows.md Flow 0's preflight GATE (≥1,500 usable rounds, ≥300 test rounds) is the corpus-size gate, this refusal only the zero-row floor beneath it. Pinned by dataset_writer's empty_corpus_refused_and_leaves_no_dataset unit test (including the seeded-directory case) and empty_refusal_names_a_file_it_could_not_remove (a manifest and a sidecar made undeletable: named in the message, the other six still removed), by each title's export-arm integration test through the real binary (export_cadence.rs for CS2 — also export_refuses_a_corpus_whose_rounds_never_settle, the routed-with-zero- rows case over a real log whose round_end is provisional — lol_corpus_cli.rs, dota2_corpus_cli.rs, one assertion body in tests/common that also asserts the accounting line precedes the refusal), and from the Python side by research/tests/test_dataset.py (the refusal, and the real-rows roundtrip over the committed CS2 fixtures that replaced its empty-corpus roundtrip).


14. Failure modes and guard rails #

Failure Detected by Response Event
Malformed artifact gate 1 parse rejected, never loaded model_gate_result{static, fail}
Schema mismatch at binding §8 binding check wiring refused at registration, never a runtime surprise model_gate_result{static, fail}
Rust/Python drift gate 3 golden vectors rejected; boundary bug fixed before any market exposure model_gate_result{parity, fail}
Invalid distribution at runtime wrapper §7 no probability event; bookmaker silent this tick model_output_invalid
Panic in evaluator wrapper catch same as invalid, reason panic model_output_invalid{panic}
Sustained invalid rate / coverage breach control-stream tap (thresholded) auto-suspend; quotes pulled within one state_version on open markets (halted markets defer to resume — §11) model_suspended{invalid_rate}
Bad model that is valid but wrong the gate-4 and shadow report cards (recorded evidence — the gate's pass bit is coverage, §10 mechanics note) never promoted; or demoted by operator model_gate_result{backtest}
Promotion mid-round ambiguity effect-timing rule §9 structurally impossible — bindings change only at market open —
Registry/artifact tampering ModelId verification on every load; control-stream hash chain load refused; lineage break visible —

Note what is absent: there is no "model produced a bad price" runtime guard beyond validity — forecast quality is an admission and evaluation concern. The market-side blast radius of a wrong-but-valid model is already bounded by the bookmaker's bankroll and liability caps (spec §6), which is the correct layer for that risk.


15. Testing strategy #

  • Golden vectors (per artifact, embedded): ≥1024 or as many as the coverage rules require, whichever is larger (a large forest has more thresholds than 1024 vectors can cover), with mandated coverage — every feature at its bounds; values exactly at, one unit below, and one unit above every threshold appearing in the artifact's trees (boundary-exactness is where quantized traversal would drift); the all-zeros and all-max vectors; plus uniform random in-bounds vectors.
  • Property tests (Rust, proptest): for all inputs — output sums to SCALE; truncation never resurrects zeroed support; normalize_exact is permutation-equivariant modulo the index tie-break.
  • Differential harness: Rust evaluator vs Python reference over a shared feature stream, bit-for-bit — broader than golden vectors, run on every change to either implementation or to EXP2_LUT handling. Instantiation: the harness is the d2-trainset eval / python -m d2m.differential pair over the same CSV (identical output bytes, compared whole). CI runs the pair on every pull request (the heavy tier's research gates) over a spec-derived §15-coverage feature stream (the operational dataset is not CI-reachable); the entire validation split run is operational and executes with every gate-4 run (research/README.md) — its output hashes are recorded alongside the report card.
  • Replay determinism (CI): full Flow-1 replay of a fixture match twice; probability stream hashes must be identical (this is the spec-§10 determinism KPI exercised at the model layer).
  • Parser fuzzing (cargo-fuzz): §4 parser against malformed/adversarial artifacts; target = no panic, no overflow, rejection with reason. The posture (the targets that exist — the run-manifest decoder's, the gateway frame codec's, parse's and, beside this list, the submitted-card reader's — are at the end of this bullet): the registration boundary is operator/SDK-trusted (§16) and the deterministic mutation matrix is the merge gate — every rejection arm, plus the sweeps: the gateway edge's single-bit-flip sweep over every inbound and outbound frame (event-catalog §3 rule 5, adopted by gateway-protocol §1), the run-manifest decoder's byte-substitution sweep, and the submitted-card reader's byte-flip sweep over every byte of a real card (two bit positions each — backtest_gate.rs::card_reader_survives_every_byte_flip), all three under the one oracle "refused, or accepted and re-encoding to exactly the mutated bytes"; cargo-fuzz targets for parse (see the close of this bullet), the canonical codec, the gateway frame codec (decode_inbound / decode_outbound, gateway-protocol §7) and the run-manifest decoder (see the close of this bullet) (RunManifest::from_canonical_bytes — it carries a variable-length text field, and a second text (family) plus count-capped uint arrays (race_lines, MF-7, and — — minute_lines, MF-8, an array of [minute, kills] uint pairs, the decoder's first NESTED shape: count-capped before any collect, each item refused unless exactly two uints, values through MinuteLines::new; and — side_minute_lines, MF-9, an array of [minute, kills, team] uint triples, count-capped before any collect, each item refused unless exactly three uints, values through SideMinuteLines::new with the team in {0, 1}; and — gold_lead_lines, MF-10, an array of [minute, gold, team] uint triples through GoldLeadLines::new with the gold in 0..=100000; and — — tally_lines, MF-11, an array of [minute, count, team] uint triples, ONE field for the three tally families, typed by the family text through TallyLines::<K>::new with the count in 0..=20; and — — house_line, MF-12, ONE [open_minute, lo, hi] uint triple through HouseTerms::new (exactly three uints, else the field's type refusal); the five array decoders are ONE reader, read_items, the triple its own read) under the one cross-field pairing rule (FamilyLines::pair — the wire's family text and its lines arrays paired into one FamilyLines value — the text ALONE for the match winner —, a board's entry: MF-13's per-family maps go through the SAME reader, the board's array count-capped at Board::MAX_FAMILIES before any collect and refused out of canonical order; plus the per-sleeve BK-4 family text through the same read_text, and the bookmaker array's MAX_BOOKMAKERS count cap); its oracle is BYTE IDENTITY on every Ok — canonical_bytes of the decoded value equals the input: after validate the decoder holds its input to the canonical document and refuses the lowest differing top-level field (ManifestError::NonCanonical { field }, §12), so with the closed key sets no Ok re-encodes to other bytes; the one shape that did — an omit-at-default field spelled AT its default, 4: 16 or 5: "cs2", which kept the run_id and changed the bytes — is refused (the board's at-default spelling, a board of one naming the default market, was already refused at validate, BoardDefaultAlone, §12), and a stored file's bytes are the bytes its run_id derives from; and the operator CSV parser since leg 3 — ONE generic body, LinesAtMinute<L>::parse_csv over MinuteJudged::parse_item, so a single target parameterized over L covers m:n (MinuteLines), m:n:t (SideMinuteLines), m:g:t (GoldLeadLines, the gold up to six digits — line_family::digits_n) and the tally families' m:n:t (TallyLines<K>, the count up to two digits; the CLI's kind-neutral raw_tally_lines_csv) — whose oracle is: Ok ⇒ at most 16 items and new agrees, Err ⇒ the named item clamped to 16 bytes) — and, the ingest::dota2 frame and roster decoders (PlayerFrameV1::from_record / kills_from_record — and net_worth_from_record, plus the LoL state_v2::gold_from_record; the clock reads frame_index_from_record / tick_from_record and the forwarded structure_destroyed/v1 / objective_slain/v1 bodies now decoded INSIDE the fold on both titles (the readers' full structure decode, the borrowed objective_read) — with the oracle that both readers agree Ok/Err on every body and no per-side sum panics (the checked add's refusal is the expected outcome); RosterSideV1::from_record with the 5/5 and vocabulary refusals) — MUST exist before the trust boundary moves at the WASM/third-party phase (§18). The run-manifest decoder's target: crates/runtime/fuzz/fuzz_targets/decode_manifest.rs, a standalone cargo-fuzz workspace that is no root-workspace member and runs by an operator's hand, never in CI (the posture crates/grid-edge/fuzz/ set); every libFuzzer input reaches RunManifest::from_canonical_bytes unchanged, as the production callers' file bytes do after a same-cap length check (check_manifest_len, MAX_MANIFEST_BYTES), and the byte-identity oracle above is asserted on every Ok — the no-overflow arm rides on cargo-fuzz's default build, which keeps overflow checks on; its corpus is seeded by examples/seed_corpus.rs from every dev-fixtures run manifest — the all-fields one putting MF-3 stale_threshold_ticks and MF-4 sample_every on the wire among them — plus the accepted side of the Board::MAX_FAMILIES, MAX_LINES and MAX_BOOKMAKERS caps (the widest accepted document among them) and five refused documents (two §12 at-default spellings, one unknown key, two body-level defects), and a bounded local run is the deliverable its README records. The gateway frame codec's targets: crates/gateway/fuzz/fuzz_targets/decode_inbound.rs and decode_outbound.rs, the same standalone-workspace posture, every input reaching the decoder unchanged as a WebSocket Binary message's bytes do, the oracle gateway-protocol §1's one-byte-form identity (the accepted bytes re-encode to themselves through the public encoders) — with the client half holding the reject detail to the seam's character cap (gateway-protocol §7), so no admitted frame is one the server's seam would cut; its corpora seeded by examples/seed_corpus.rs from every gateway-protocol §2 row over real fixture material and a bounded run per target recorded in its README; and, beside this list, the submitted-card reader's (ReportCard::from_canonical_bytes, the §10 gate-wiring reader) as that workspace's third target, under the reader's own bijection with canonical_bytes (gateway-protocol §7). The §4 parser's own target: crates/pricing/fuzz/fuzz_targets/parse_artifact.rs, the same standalone-workspace posture. Its input is a COPY of the fuzzer's bytes RESTAMPED before the one parse — the header's content_hash field rewritten with the identity of the bytes as they are, over the body the header declares (pricing::artifact::restamp, the parser's own §4 rule-1 recipe, the ONE home pricing::artifact::content_hash that the parser verifies with and the encoder stamps with) — because the parser verifies that hash before it reads a body byte and a mutation that keeps a 256-bit hash is none: without the restamp every mutated input would die at ContentHash and the sixteen refusal arms behind it would never be reached. The raw ContentHash arm is the deterministic matrix's (artifact_matrix.rs::content_hash_verified) and every production load's. The oracle is BYTE IDENTITY on every Ok: the parsed artifact rebuilt as a spec (ArtifactSpec::from_artifact, the trees back from their preorder node arrays through the parser's own walk) and re-encoded by the production encoder equals the input — §4's "exactly one valid serialization" as a byte law, gated in CI by the matrix's accepted_bytes_re_encode_to_themselves on five real shapes. Its corpus is seeded by examples/seed_corpus.rs from the two fixture stumps, four boundary shapes through the real encoder (the widest class count, a chain at the depth cap, a negative rescale on the last feature, the floor edge) and one refused seed per ParseError arm but the hash's — two for the class count, its floor and its cap — (an exhaustive match names every arm, and the arm list kept beside it is checked against the seeds both ways; the over-cap BadLength by a probe), and a bounded local run is recorded in its README. The canonical codec, the operator CSV parser and the ingest readers stay deferred under the MUST above, whose parse, run-manifest and gateway-frame-codec entries are discharged; the submitted-card reader's target is the GATEWAY's own (recorded in FUNCTION_MAP's gateway paragraph), not an entry of this list.
  • Two-process determinism (CI): the gate-2 vector sweep re-run in a separate process, byte-identical outputs — the cross-process home of the check whose in-run variant does two in-process parses (§10).
  • LUT freeze test: EXP2_LUT constants hashed in both implementations; test fails if either diverges from the spec hash.

16. Security considerations #

Artifacts are data, so there is no code-execution surface — the risks are parser abuse and resource exhaustion. Parse-time caps (format v1, checked before any evaluation):

  • run manifest ≤ 1 MiB (MAX_MANIFEST_BYTES — checked at both serve edges before the read and by the decoder; a real definition is ~100 bytes + ~40 per bookmaker, ~62 with a family tag) and its title text ≤ 16 bytes (MAX_TITLE_NAME_LEN, refused by length before it is ever cloned into an error — the field that made the manifest's first attacker-sized allocation possible); every family text ≤ 32 bytes (MAX_FAMILY_NAME_LEN — MF-6, each MF-13 board entry's, and each sleeve's BK-4 tag, all through one reader, refused by length before any clone; the board); the MF-13 board array ≤ Board::MAX_FAMILIES = Family::ALL.len entries, by count before any entry is read, each entry a map or refused by index, in canonical order or refused by index; each lines array ≤ 16 items (MAX_LINES) by count before any collect; the bookmaker array ≤ 64 entries (MAX_BOOKMAKERS) by count before any entry is read ; every MAP on the wire held to its CLOSED key set before a field of it is read — the document to MANIFEST_FIELDS (MF-0..17; the MF-17 max_resting_orders a u64 in 1..=2^32 − 1; the MF-16 retail_fee_rule map to {0 version, 1 hash}, its hash exactly 32 bytes; the MF-14 allocation_ref map to {0 epoch_id, 1 allocation_offset}, refused by AllocationRefWire; the MF-15 feed_rule map to {0 tag, 1 stall_ms, 2 continuity}, its tag ≤ 64 bytes (MAX_FEED_TAG_LEN, refused by length before it is cloned, ManifestError::FeedRuleTag; the epoch document's field-78 tags under the same cap, futarchy-system.md §11 W12)), each bookmaker entry to BOOKMAKER_FIELDS (BK-0..4), each MF-13 board entry to FAMILY_WIRE_FIELDS (MF-6..12) — an unknown key refused by name (ManifestError::UnknownField { map, field, at }, the lowest unknown key of that map and the entry's index), never ignored: the encoder writes the listed keys only, so an unknown key is a hand-authored or forged document, and an ignored one would be a byte the derived run_id does NOT cover — run_id is BLAKE3 of the RE-ENCODED canonical document — so two distinct files would alias one run identity (the walk itself is stream_core::body::unknown_key, the one home the gateway codec's closed maps share); and the last alias class is closed at decode: after validate the input is held to the canonical document and the lowest differing top-level field refused (ManifestError::NonCanonical { field }, §12) — one extra construction of the canonical DOCUMENT from the ALREADY-validated struct (a Value map compared key by key, no second encode and no second decode) bounded by the caps (MAX_BOOKMAKERS entries plus Board::MAX_FAMILIES entries of at most MAX_LINES triples — a document whose byte form is under 16 KiB), independent of the input's size. The 1 MiB cap bounds the decode's amplification as the artifact clause's does: a hostile document of that size decodes to roughly 200 k map entries — tens of MB of transient Value tree — before the closed-set walk runs, bounded and transient; the manifest's deepest value at depth 5 of the codec's 32;
  • artifact ≤ 64 MiB; metadata section ≤ 16 MiB (canonical CBOR decode materializes ~32 bytes of value tree per input byte — the section cap bounds the transient); trees 1..=10 000; nodes per tree ≤ 65 535; depth ≤ 64 (checked via traversal bound); declared n_features 1..=65 535 (consumers size buffers from it — an uncapped width is an allocation amplifier); the class count 2..=8 (§4 — the floor two), refused at parse AND by both serializers (encode_artifact in Rust, EncodeError::BadClassCount, and in the Python exporter, a typed ExportError), so no tool in this repository writes an artifact the parser refuses on that field; the parser's arithmetic is checked where a data-derived value can wrap, the rest bounded by the caps ahead of it (the worst-case score bound, MAX_TREES leaves of |i32::MIN| under a rescale of i64::MAX, is ≈ 2^107 in its u128); child indices validated acyclic in preorder, the ONE walk the parser admits by and the fuzz oracle rebuilds by (walk_preorder, task 115).
  • the live capture's held-round buffer (a state-poll stall holds the pending round's sub-events until the roster arrives or the match ends, risk-engine.md §3 R7 (g)) ≤ 65 536 sub-events (MAX_HELD_SUB_EVENTS) and ≤ 64 MiB (MAX_HELD_BYTES, summed from each held envelope's raw frame length with a checked add — the buffer holds each envelope's raw text frame in an allocation of exactly its length, never its parsed sub-events, and parses it again on the recovery, so the byte cap bounds exactly what is held); past either the capture fails with a message naming the round, after writing nothing further, so a roster that never arrives under a feed that keeps sending ends the capture instead of growing memory without bound.
  • Worst-case score bound (n_trees × max|leaf|, rescaled) verified to fit i64::MAX / 2 (§3 — headroom for the §6 max-subtraction).
  • The blob store is content-addressed: an artifact that doesn't hash to its claimed ModelId is refused at load, every load, not just at registration.
  • Registration is operator/SDK-initiated (trusted boundary). Third-party model submission is out of scope until the WASM/sandbox phase (§18) — the caps above are still enforced now so the parser is hardened before it ever meets untrusted input.

17. ZK provability path #

The artifact discipline makes model inference the easy part of the proving story:

  • One guest program per format, not per model: the zkVM program is the §5 evaluator + §6 softmax + §7 wrapper. Public inputs: artifact commitment (ModelId), feature-stream segment commitment, claimed output segment commitment. Every artifact ever plugged is covered by the same audited, proven program.
  • Integer-only arithmetic and the pinned LUT mean the in-circuit computation is identical to production computation — no float-to-field translation layer, which is where zkML projects usually bleed.
  • Code-plugged models forfeit this: each would need its own guest program and audit. This is the strongest standing argument for pushing every model through an artifact format, and it is why adding a format (§18) is a design event, not a routine one.

18. Scope #

Item Scope Note
Trait, wrapper, gbm-quantized/1 format + evaluator, exact softmax2/normalize, parity gate, registry-as-events, both house models as artifacts In scope the demo proves the layer by using it twice (baseline + trained)
Shadow mode, validation-split report cards In scope it is one tap + one stream; also how the two house models are compared honestly during development
Additional artifact formats (linear, lookup-table, small-NN) Out of scope each new format = new evaluator + new guest program: deliberate, rare
Race-to-N-kills family: lol/race-v1 + dota2/race-v1 (§8) + the lol-race-export / lol-race-schema / dota2-race-export / dota2-race-schema corpus arms (§13) + family dispatch (§12) In scope (both titles through the same RaceTitle seam — the seam's per-match frame reader carrying the roster binding) The first family-specific grid: the lol grid + the live line, each side's kills-to-go and their diff; the extractor drives the same race fold the lifecycle and clearing drive, prices only a live line; rows labeled from the settlement fold, voided lines dropped; the 2-class match-winner regime applies unchanged; the manifest's family binds the race column (settlement-contract SC-8; architecture.md roster)
Kills-over-N-at-minute-M family: lol/minute-v1 + dota2/minute-v1 (§8) + the lol-minute-export / lol-minute-schema / dota2-minute-export / dota2-minute-schema arms (§13), bound under the manifest family kills_at_minute + minute_lines (§12) In scope (both titles; the minute corpora and the gate-4 validation run per title; Dota2's minute house model is the gate-4-admitted candidate; a title whose candidate gate 4 refuses keeps its projection-baseline house model — LoL's lol/minute-baseline-v1) The second line family and the first settled on a NAMED frame (settlement-contract SC-9); the same 2-class regime as the race (class 1 = over); the corpus builders share the race's two passes (trainset::line_dataset)
Side-kills-over-N-at-minute-M family: lol/side-minute-v1 + dota2/side-minute-v1 (§8) + the lol-side-minute-export / lol-side-minute-schema / dota2-side-minute-export / dota2-side-minute-schema arms (§13), bound under the manifest family side_kills_at_minute + side_minute_lines (§12) In scope (both titles; the side-minute corpora and the gate-4 validation run per title) The minute family's fold generic over the judged count (MinuteJudged): one fold, one lines rule, one policy, one source, a second family
Gold-lead-over-N-at-minute-M family: lol/gold-lead-v1 + dota2/gold-lead-v1 (§8) + the lol-gold-lead-export / lol-gold-lead-schema / dota2-gold-lead-export / dota2-gold-lead-schema arms (§13), bound under the manifest family gold_lead_at_minute + MF-10 gold_lead_lines (§12) In scope (both titles; the gold-lead corpora and the gate-4 validation run per title) The minute fold generic over WHAT the frame yields (MinuteJudged::Quantity, FrameSource<Q>) so the family adds a quantity (Gold: LoL total gold, Dota2 net worth) and a line type; the first market judged on gold
Tally-over-N-at-minute-M families — towers on both titles, dragons and barons on LoL: lol/towers-v1 / lol/dragons-v1 / lol/barons-v1 + dota2/towers-v1 (§8, ONE generic grid module per title over the kind tag) + the lol-tally-export --kind / lol-tally-schema --kind / dota2-tally-export / dota2-tally-schema arms (§13), bound under the manifest families towers_at_minute / dragons_at_minute / barons_at_minute + ONE wire field MF-11 tally_lines (§12) In scope (the tally corpora and the gate-4 validation run per grid) The seam (the projectors forward the objective events, the title readers tally them, the fold hands the grid its own frame value) and ONE generic family over the kind — three markets for one family's code; the first quantity no snapshot carries
House-line families — the final total kills and the kill handicap over a line the HOUSE sets: lol/total-v1 / lol/handicap-v1 + dota2/total-v1 / dota2/handicap-v1 (§8 — the house-line grids, the kind's lanes, no minute lanes) + HouseLineSetter<T, K> (the LineSetter seam's implementation over the run's house model — the opening-snapshot sweep, P(OVER) nearest ½, the tie order on the seam) + HouseFeatureExtractor<T, K> + the lol-house-export / dota2-house-export --kind --house-line --stride corpus arms and the *-house-schema documents (§13) + family dispatch under total_kills / kill_handicap over MF-12 house_line (§12) In scope (both titles, both kinds; the served house models are the candidates gate 4 admits on validation — LoL total 28567e08…, LoL handicap 0bd1e07f…, Dota2 total b41a7be6…, Dota2 handicap a4fe3187…, each trained at learning rate 0.05 over 80 rounds; a candidate whose leaves leave the Q15.16 grid is refused by the exporter, so the admitted recipe is the one whose leaves fit) The first grids whose line lane the PRICING layer fills: the same model that prices the market sets its line, so a family's whole product — the line, the quote, the settlement — is one artifact plus the recorded terms
Parameterized outcome dimension (format v2: variable n_classes) In scope: the model-output path; gate-4 infra for the raw-evaluated title (lol-backtest/lol-schema, admit resolving lol/v1 — the §10 evaluator, CLI arms per FUNCTION_MAP.md trainset); the market ECONOMICS width-agnostic; the match-winner MARKET itself (policy / outcome source / router, forwarding sets, SC-7); runtime title dispatch (§12; the headless serve of both house models pinned) format v2 records n_classes (2..=MAX_CLASSES=8 — the floor two: one class is no outcome space, refused at §4 parse; ClassWidthTooNarrow stays as the raw evaluator's own guard, §10) and the artifact parse / evaluator / softmax / backtest RPS operate on it (6 = CS2 kills, 2 = a LoL win/loss model; the backtest RPS's divisor is K − 1) — a 6-class artifact stays byte-identical (ModelId unchanged). The MARKET economics are width-agnostic: the bookmaker prices any 1..=8-class Distribution against the market family's recorded payout vector (PayoutVector — CS2 kills [0,200,…,1000] homed in ingest::cs2::claims, a match-winner family [0,1000]; architecture.md quote fold), distribution/v1 / forecast_submit/v1 carry the family's width (event-catalog §6.4/§6.6, gateway-protocol §5 code 11), and MAX_CLASSES is homed in stream-core; a 6-wide record and the CS2 quote stream are byte-identical. The wrapper kill-floor (enforce) stays the CS2 path; a 2-class model reaches the fold through validate_raw. Promotion (test-eval) runs under the 2-class match-winner regime (§10 promotion regimes). The market: replay::match_winner (one policy for LoL + Dota2 through the WinnerTitle seam; epoch = the match; the projectors forward match_discovered/match_complete), clearing::match_winner (SC-7 finality, [0, 1000]), bookmaker::binary (the router over the shared SideIndex), proven end-to-end on the real GRID captures. The lol/v1/v2 extractor declares the forwarded pair a no-op; the dota2/v1/v2 extractor binds its sides at the forwarded roster_side/v1 and releases them at the completion (§8, event-catalog §6.11.1). The runtime Scheduler binds a title's projector / policy / extractor / router / outcome source from the manifest's title (§12), and the headless serve of both house models over real corpus segments is pinned (trainset/tests/title_serve_e2e.rs). lol/v2 = v1's grid + the lol/baseline-v1 projection (lol-schema --v2) so a LoL gate-4 run against a projection baseline compares a full-grid candidate with a CS2-style projection-confined baseline_v1 (§10)
Dota2 feature schemas (dota2/v1 §8; dota2/v2 = v1 + the dota2/baseline-v1 projection) + corpus builder + training/gate infra (d2-trainset dota2-schema / --v2, admit resolving dota2/v1 and dota2/v2 through the same registry, dota2-backtest via the registry-resolved registry_backtest) In scope: the feature-extraction path, the corpus builder, the training/gate-4 infra, the gate-4 baseline (the dota2/baseline-v1-projection baseline_v1 recipe on dota2/v2, §10), live serving (the dota2 manifest title, §12) dota2/v1 (68 features, hash f559de6e…) and dota2/v2 (same grid + projection, hash cd49d5d0…) hash-pinned in pricing::feature::dota2; trainset::dota2_corpus/dota2_dataset/dota2_cli (d2-trainset dota2-ingest/dota2-export) materialize the betty-dota2 archive's sharded ticks/buildings tables into sealed per-match segments and labeled dota2/v1 CSV rows, keyed on the archive's own match_id (event-catalog §6.11 rule 10) — a same-grid baseline is a near-tautology, which is what dota2/v2's projection answers. last_hits bounds are (0, 2_000) per player and (0, 10_000) per side (five times the per-player ceiling); the archive's real per-player maximum exceeds 1 000, and the hash above binds these bounds. Promotion (test-eval) runs under the 2-class match-winner regime (§10 promotion regimes); the market/settlement is the row above, live serving the dota2 title over the betty FRAME log (event-catalog §6.11 rule 11 / §6.11.1)
Code-plugged (Rust crate) models Escape hatch supported by the trait; forfeits shared-evaluator provability; requires rebuild + registry entry pinning the binary hash
Dynamic loading (.so), WASM-sandboxed third-party models Out of scope WASM (wasmtime, deterministic config) is the vehicle when plugins cross the trust boundary
Parallel model evaluation Out of scope only with deterministic output merge; banned until measured need
Online learning / in-run weight updates Out, permanently a model is immutable within a run; new weights = new artifact = new ModelId — anything else breaks replay, lineage, and the audit story

Growth-path note — this layer's role in the real-world system #

Per the real-world alignment note (gitignored): from the alignment note's phase 1 on, the signed-quote gateway becomes the protocol's pricing boundary, and external underwriters may run fully proprietary engines the protocol never inspects. This layer then serves three roles rather than one:

  1. Reference pricing engines — the house's own models (the alignment note's phases 0–2) run here and submit through the same signed-quote gate external engines use.
  2. The hosted rung of the ladder — a skilled bettor with a model but no quoting infrastructure uploads an artifact and receives the full stack (wrapper, evaluator, bookmaker binding, sleeve). This is how "prove your edge → become the house" works operationally for people without an engineering team.
  3. Certification, not toll gate — the admission gates become optional verification for underwriters who want it: an artifact-based sleeve can offer LPs a ZK-provable claim ("quotes provably came from the committed model hash") that a black-box engine structurally cannot. Proposal markets can price that difference.

The four design principles (§1) generalize beyond models to every adapter class in the system — quoting strategies, claim-family settlement predicates, feeds, taps — with this layer as the template: maximally pluggable content inside a runtime-owned invariant wrapper, admitted through gates, lifecycled as events, bit-exact throughout.


19. Appendix — full visual breakdown of every model in the system #

19.1 Model inventory #

Every model that exists (or is planned) in the system, all flowing through the same plugin machinery — the demo proves the layer by containing nothing that bypasses it:

Model Format Feature schema Lifecycle state Bound to Role
baseline_v1 gbm-quantized/1 (small forest or table) dust2/baseline-v1 projection: current kills, players alive, round time, bomb state, team side (binds the full dust2/v1 schema id, trees confined to the projection — §8 projection-consumption note) Live (frozen forever) Bookmaker A the control group — spec §5's frozen simple model
gbm_v1 gbm-quantized/1 dust2/v1 full: baseline + health, armour, weapons, positions, recent kills, team advantage Live Bookmaker B the hypothesis under test
gbm_v2…N gbm-quantized/1 dust2/v1 Shadow none candidates — parallel timelines, zero market impact
the LoL house model (340619b7… — the baseline_v1 recipe bound to lol/v2) gbm-quantized/1 (format v2, n_classes 2) lol/v2 (trees confined to the lol/baseline-v1 projection) Live under a lol run manifest (§12) the run's house sleeve, quoting the match's one match-winner market the served LoL house model — the per-title fixed point; a candidate replaces it only through §10 test-eval
the Dota2 house model (6e79129a… — the baseline_v1 recipe bound to dota2/v2) gbm-quantized/1 (format v2, n_classes 2) dota2/v2 (trees confined to the dota2/baseline-v1 projection) Live under a dota2 run manifest (§12) the run's house sleeve the served Dota2 house model, same posture
counterfactual runs any registered artifact as declared none (SDK fork) none sdk.replay(model=…) research forks, off-spine by construction

The roster above is the illustrative example (a single-schema dust2/v1 run showing the shadow→promote lifecycle); it is not a live inventory. Its placeholder gbm_v2…N (a v1-schema shadow candidate, likewise the gbm_v2 in the §19.4/§19.6 diagrams and the architecture.md fork examples) is a DIFFERENT referent from the real, promoted dust2/v2 gbm_v2 described next — the token is reused for the "next candidate" slot; the deployment note below is the source of truth for the actual model.

The deployed house schema — dust2/v2 per cadence. The enriched dust2/v2 schema (158 features = the dust2/v1 72 as a verbatim prefix + per-player aim/velocity/firing + team aggregates, §8) is the deployed house schema. gbm_v2 is trained and promoted at three cadences (demo-tick strides 16/8/4 = 4/8/16 Hz), each clearing admission gates 1–4 and the sealed test-split promotion gate. It serves the sandbox house sleeve per cadence: the run binds the gbm_v2 variant trained at the researcher's served stride. This is necessary because a dust2/v2 feature window counted in SAMPLES (the 8-sample recent-kill window) has a wall-time span that scales with the stride, so a model trained at one stride prices another only approximately; binding the matching-stride model keeps the house tuned. Cadence is NOT part of the FeatureSchemaId (a stride-8 and a stride-16 gbm_v2 share the id c801b9d8…); the engine's only binding check is the schema id (§8), so the stride↔model correspondence is an operator-asserted deployment fact (the sandbox FH_HOUSE_MODEL_BY_CADENCE map), recorded here, not engine-verified. Offered cadences beyond the trained three fall back to the nearest trained gbm_v2 (a same-schema best-effort house), so a researcher can serve at a finer stride than any trained house model and still get a valid — if untuned-at-that-stride — opponent.

Two facts the table can't show: one model instance prices both team markets — the wrapper invokes the same artifact once per team per tick with that team's feature vector (team identity is not a feature; the perspective is baked into the vector, e.g. alive_own vs alive_opp). And the baseline is not special-cased anywhere: it is an ordinary artifact that went through the same gates, which is what makes the baseline-vs-trained comparison honest.

19.2 Anatomy of an artifact #

flowchart TB
    subgraph ART["artifact file — canonical bytes, little-endian, exactly one valid serialization"]
        H["HEADER — 96 bytes<br/>magic · format_id · format_version<br/>feature_schema_id · content_hash · section lengths"]
        B["BODY — gbm-quantized/1<br/>n_classes (2..=8; 6=CS2, 2=LoL) · n_trees · n_features · score_rescale<br/>per tree: class_index + preorder node array<br/>per node (16 bytes): feature_index / threshold-or-leaf / children"]
        M["METADATA — CBOR<br/>training lineage: split hash, code commit, hyperparams<br/>golden vectors ≥ 1024"]
    end
    H -->|"content_hash field zeroed"| ID["ModelId = BLAKE3(header ‖ body)<br/><i>identity = behavior + interpretation context<br/>(format and schema included, or identical<br/>trees under two schemas would collide)</i>"]
    B --> ID
    M -->|"BLAKE3(meta)"| MH["meta_hash<br/><i>in the registration event — NOT part of ModelId:<br/>same-length re-annotation does not change what it is<br/>(meta_len sits in the hashed header — §4 precision note)</i>"]
Diagram source
flowchart TB
    subgraph ART["artifact file — canonical bytes, little-endian, exactly one valid serialization"]
        H["HEADER — 96 bytes<br/>magic · format_id · format_version<br/>feature_schema_id · content_hash · section lengths"]
        B["BODY — gbm-quantized/1<br/>n_classes (2..=8; 6=CS2, 2=LoL) · n_trees · n_features · score_rescale<br/>per tree: class_index + preorder node array<br/>per node (16 bytes): feature_index / threshold-or-leaf / children"]
        M["METADATA — CBOR<br/>training lineage: split hash, code commit, hyperparams<br/>golden vectors ≥ 1024"]
    end
    H -->|"content_hash field zeroed"| ID["ModelId = BLAKE3(header ‖ body)<br/><i>identity = behavior + interpretation context<br/>(format and schema included, or identical<br/>trees under two schemas would collide)</i>"]
    B --> ID
    M -->|"BLAKE3(meta)"| MH["meta_hash<br/><i>in the registration event — NOT part of ModelId:<br/>same-length re-annotation does not change what it is<br/>(meta_len sits in the hashed header — §4 precision note)</i>"]

19.3 The inference path, tick by tick #

What happens inside Flow 1's "models price" step, for every live and shadow model, every ~250 ms of replay time:

sequenceDiagram
    autonumber
    participant FX as Feature Extractor
    participant WR as Enforcement Wrapper (runtime-owned)
    participant EVL as Format Evaluator (shared by all artifacts)
    participant PS as Probability Stream (per model)

    FX->>WR: feature vector @ state_version V (schema-checked, fixed-point)
    Note over WR: CS2 shown — invoked once per team market per tick, same artifact, two evaluations. A match-winner title evaluates once per snapshot and skips the truncation step (§7 raw path, §12)
    WR->>EVL: predict(features)
    EVL->>EVL: walk all trees — integer compares on the shared grid, i64 class-score accumulation
    EVL->>EVL: apply score_rescale (Q-multiply, carries the log2 e base conversion)
    EVL->>EVL: softmax2 via pinned EXP2_LUT — no floats, no libm
    EVL->>EVL: normalize_exact (largest remainder) → raw distribution, sum exactly SCALE
    EVL-->>WR: RawDistribution [n_classes × u32]
    WR->>WR: validate — exact sum, bounds (the Distribution law, any 1..=MAX_CLASSES width) — the CS2 enforce path additionally requires the kills family's 6 classes
    WR->>WR: truncate k below current_kills, renormalize over surviving support
    alt output valid
        WR->>PS: constrained distribution + state_version + model_id + wrapper_version
    else invalid or panic
        WR->>WR: emit model_output_invalid (reason) — NO probability event, bound bookmaker stays silent this tick
    end
Diagram source
sequenceDiagram
    autonumber
    participant FX as Feature Extractor
    participant WR as Enforcement Wrapper (runtime-owned)
    participant EVL as Format Evaluator (shared by all artifacts)
    participant PS as Probability Stream (per model)

    FX->>WR: feature vector @ state_version V (schema-checked, fixed-point)
    Note over WR: CS2 shown — invoked once per team market per tick, same artifact, two evaluations. A match-winner title evaluates once per snapshot and skips the truncation step (§7 raw path, §12)
    WR->>EVL: predict(features)
    EVL->>EVL: walk all trees — integer compares on the shared grid, i64 class-score accumulation
    EVL->>EVL: apply score_rescale (Q-multiply, carries the log2 e base conversion)
    EVL->>EVL: softmax2 via pinned EXP2_LUT — no floats, no libm
    EVL->>EVL: normalize_exact (largest remainder) → raw distribution, sum exactly SCALE
    EVL-->>WR: RawDistribution [n_classes × u32]
    WR->>WR: validate — exact sum, bounds (the Distribution law, any 1..=MAX_CLASSES width) — the CS2 enforce path additionally requires the kills family's 6 classes
    WR->>WR: truncate k below current_kills, renormalize over surviving support
    alt output valid
        WR->>PS: constrained distribution + state_version + model_id + wrapper_version
    else invalid or panic
        WR->>WR: emit model_output_invalid (reason) — NO probability event, bound bookmaker stays silent this tick
    end

19.4 Export pipeline — how a model becomes an artifact #

The Python-side bridge from the float training world to the fixed-point runtime world (§13):

flowchart LR
    S["feature schema dust2/v1<br/><i>defines the integer grid</i>"] --> Q1["pre-quantize training data<br/>onto the schema grid"]
    Q1 --> TR["train GBM<br/>LightGBM multiclass, 6 classes<br/><i>(the only float step)</i>"]
    TR --> Q2["quantize thresholds<br/>t_q = floor(t × scale_f)<br/><i>exact: data was already on the grid</i>"]
    TR --> Q3["quantize leaves<br/>leaf_q = round(leaf × 2^16)"]
    TR --> Q4["fold base conversion + lr scaling<br/>into score_rescale"]
    Q2 --> GV["generate ≥1024 golden vectors<br/>via the PYTHON REFERENCE evaluator<br/><i>never the float model</i>"]
    Q3 --> GV
    Q4 --> GV
    GV --> SER["serialize canonical bytes (§4)"]
    SER --> ID["ModelId = BLAKE3(header ‖ body)<br/><i>hash field zeroed in header;<br/>the artifact IS the model from here on</i>"]
Diagram source
flowchart LR
    S["feature schema dust2/v1<br/><i>defines the integer grid</i>"] --> Q1["pre-quantize training data<br/>onto the schema grid"]
    Q1 --> TR["train GBM<br/>LightGBM multiclass, 6 classes<br/><i>(the only float step)</i>"]
    TR --> Q2["quantize thresholds<br/>t_q = floor(t × scale_f)<br/><i>exact: data was already on the grid</i>"]
    TR --> Q3["quantize leaves<br/>leaf_q = round(leaf × 2^16)"]
    TR --> Q4["fold base conversion + lr scaling<br/>into score_rescale"]
    Q2 --> GV["generate ≥1024 golden vectors<br/>via the PYTHON REFERENCE evaluator<br/><i>never the float model</i>"]
    Q3 --> GV
    Q4 --> GV
    GV --> SER["serialize canonical bytes (§4)"]
    SER --> ID["ModelId = BLAKE3(header ‖ body)<br/><i>hash field zeroed in header;<br/>the artifact IS the model from here on</i>"]

19.5 Admission pipeline — the four gates #

flowchart TD
    REG["model_registered<br/><i>artifact stored, hash verified</i>"] --> G1{"Gate 1 — Static<br/>parse · caps · acyclicity ·<br/>10k fuzz vectors well-formed"}
    G1 -->|fail| REJ["Rejected<br/><i>gate event records which gate and why</i>"]
    G1 -->|pass| G2{"Gate 2 — Determinism<br/>two independent processes ·<br/>byte-identical outputs"}
    G2 -->|fail| REJ
    G2 -->|pass| G3{"Gate 3 — Parity<br/>golden vectors bit-for-bit ·<br/>Rust evaluator ≡ Python reference"}
    G3 -->|fail| REJ
    G3 -->|pass| G4{"Gate 4 — Backtest<br/>validation split ONLY · pass = coverage ≥ 95%<br/>RPS vs baseline + calibration recorded on the card"}
    G4 -->|fail| REJ
    G4 -->|"pass + report card"| VAL["Validated<br/><i>eligible for Shadow</i>"]
Diagram source
flowchart TD
    REG["model_registered<br/><i>artifact stored, hash verified</i>"] --> G1{"Gate 1 — Static<br/>parse · caps · acyclicity ·<br/>10k fuzz vectors well-formed"}
    G1 -->|fail| REJ["Rejected<br/><i>gate event records which gate and why</i>"]
    G1 -->|pass| G2{"Gate 2 — Determinism<br/>two independent processes ·<br/>byte-identical outputs"}
    G2 -->|fail| REJ
    G2 -->|pass| G3{"Gate 3 — Parity<br/>golden vectors bit-for-bit ·<br/>Rust evaluator ≡ Python reference"}
    G3 -->|fail| REJ
    G3 -->|pass| G4{"Gate 4 — Backtest<br/>validation split ONLY · pass = coverage ≥ 95%<br/>RPS vs baseline + calibration recorded on the card"}
    G4 -->|fail| REJ
    G4 -->|"pass + report card"| VAL["Validated<br/><i>eligible for Shadow</i>"]

The test split never appears here — it is sealed behind a separate manifest and touched exactly once per frozen candidate for the formal spec-§10 gate.

19.6 Runtime topology — all models wired #

flowchart LR
    CTRL[["control stream<br/><i>register / shadow / promote / suspend<br/>bindings effective at next market_open</i>"]]
    FS[["feature stream<br/>schema dust2/v1"]]
    PRJ["projection<br/>dust2/baseline-v1<br/><i>logical view: the full vector is fed,<br/>the trees are confined to it (§8 note)</i>"]

    subgraph LIVE["Live — bookmaker-bound"]
        W1["wrapper + evaluator<br/><b>baseline_v1</b>"]
        W2["wrapper + evaluator<br/><b>gbm_v1</b>"]
    end
    subgraph SHDW["Shadow — no market impact"]
        W3["wrapper + evaluator<br/><b>gbm_v2</b>"]
    end

    PA[["prob stream A"]]
    PB[["prob stream B"]]
    PC[["prob stream C"]]
    BMA["Bookmaker A"]
    BMB["Bookmaker B"]
    KPI["KPI Evaluator tap<br/><i>grades all three identically</i>"]

    FS --> PRJ --> W1 --> PA --> BMA
    FS --> W2 --> PB --> BMB
    FS --> W3 --> PC
    PA --> KPI
    PB --> KPI
    PC --> KPI
    CTRL -.-> LIVE
    CTRL -.-> SHDW
Diagram source
flowchart LR
    CTRL[["control stream<br/><i>register / shadow / promote / suspend<br/>bindings effective at next market_open</i>"]]
    FS[["feature stream<br/>schema dust2/v1"]]
    PRJ["projection<br/>dust2/baseline-v1<br/><i>logical view: the full vector is fed,<br/>the trees are confined to it (§8 note)</i>"]

    subgraph LIVE["Live — bookmaker-bound"]
        W1["wrapper + evaluator<br/><b>baseline_v1</b>"]
        W2["wrapper + evaluator<br/><b>gbm_v1</b>"]
    end
    subgraph SHDW["Shadow — no market impact"]
        W3["wrapper + evaluator<br/><b>gbm_v2</b>"]
    end

    PA[["prob stream A"]]
    PB[["prob stream B"]]
    PC[["prob stream C"]]
    BMA["Bookmaker A"]
    BMB["Bookmaker B"]
    KPI["KPI Evaluator tap<br/><i>grades all three identically</i>"]

    FS --> PRJ --> W1 --> PA --> BMA
    FS --> W2 --> PB --> BMB
    FS --> W3 --> PC
    PA --> KPI
    PB --> KPI
    PC --> KPI
    CTRL -.-> LIVE
    CTRL -.-> SHDW

Bookmakers bind to probability streams by configuration (a control-stream event), not code — promotion rebinds Bookmaker B to a new stream at the next market open; rollback is re-promotion of the previous ModelId.

19.7 Models as timeline forks #

The same picture in the timeline vocabulary of architecture.md — every model is a parallel probability timeline off the one spine; shadow and research models are tap-in forks that structurally cannot touch it:

flowchart LR
    ML[["match log<br/><i>the unforkable root</i>"]] --> GS[["game-state spine"]] --> FS[["feature stream"]]
    FS --> PA[["prob timeline: baseline_v1<br/><i>consumed by Bookmaker A</i>"]]
    FS --> PB[["prob timeline: gbm_v1<br/><i>consumed by Bookmaker B</i>"]]
    FS -.->|"tap-in fork"| PC[["shadow timeline: gbm_v2<br/><i>no consumer — report card only</i>"]]
    FS -.->|"tap-in fork"| PD[["counterfactual: sdk.replay model=gbm_v3<br/><i>research, off-spine</i>"]]
Diagram source
flowchart LR
    ML[["match log<br/><i>the unforkable root</i>"]] --> GS[["game-state spine"]] --> FS[["feature stream"]]
    FS --> PA[["prob timeline: baseline_v1<br/><i>consumed by Bookmaker A</i>"]]
    FS --> PB[["prob timeline: gbm_v1<br/><i>consumed by Bookmaker B</i>"]]
    FS -.->|"tap-in fork"| PC[["shadow timeline: gbm_v2<br/><i>no consumer — report card only</i>"]]
    FS -.->|"tap-in fork"| PD[["counterfactual: sdk.replay model=gbm_v3<br/><i>research, off-spine</i>"]]

19.8 ZK inheritance — one program, every model #

flowchart TB
    A1["artifact baseline_v1<br/>(committed by hash)"] --> GP
    A2["artifact gbm_v1<br/>(committed by hash)"] --> GP
    A3["artifact gbm_vN<br/>(committed by hash)"] --> GP
    GP["ONE zkVM guest program per FORMAT<br/>= §5 evaluator + §6 softmax2 + §7 wrapper<br/><i>integer-only: in-circuit math ≡ production math</i>"]
    GP --> PRF["proof: probability outputs are correct<br/>for committed artifact + committed feature segment"]
    CP["code-plugged model (escape hatch)"] -.->|"needs its OWN program + audit"| PRF
Diagram source
flowchart TB
    A1["artifact baseline_v1<br/>(committed by hash)"] --> GP
    A2["artifact gbm_v1<br/>(committed by hash)"] --> GP
    A3["artifact gbm_vN<br/>(committed by hash)"] --> GP
    GP["ONE zkVM guest program per FORMAT<br/>= §5 evaluator + §6 softmax2 + §7 wrapper<br/><i>integer-only: in-circuit math ≡ production math</i>"]
    GP --> PRF["proof: probability outputs are correct<br/>for committed artifact + committed feature segment"]
    CP["code-plugged model (escape hatch)"] -.->|"needs its OWN program + audit"| PRF

This is the economy of the whole layer in one picture: every artifact ever plugged inherits one audited execution path and, later, one audited proof path. The escape hatch is drawn dashed because that is exactly what it costs.