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:
- Design principles
- The model contract
- Fixed-point number system
- Artifact format specification
- The GBM evaluator
- Deterministic softmax and exact-sum normalization
- The enforcement wrapper
- Feature schema versioning
- Registry and control stream
- Admission pipeline
- Shadow mode, promotion, and binding semantics
- Runtime execution model
- Python SDK and the exporter
- Failure modes and guard rails
- Testing strategy
- Security considerations
- ZK provability path
- Scope
- 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.
predictmust 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 asmodel_output_invalidwith apanicreason 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_hashverified, 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 (includingscore_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):
- Invoke
predict(panics caught → invalid, reasonpanic). - Validate raw output: sum ==
SCALEexactly, all<= SCALE, width 1..=MAX_CLASSES— theDistributionlaw, one home. Two paths : the CS2 kill-market path (enforce) additionally requires the kills family'sN_CLASSES= 6 entries, because step 3's truncation is CS2-ordinal; a non-kills family (e.g. a 2-class match-winner model) takes thevalidate_rawpath — steps 1, 2, 4, 5 with no step 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), thennormalize_exactover the surviving support. If the surviving mass is zero → invalid, reasonempty_support. - Emit to the model's probability stream: constrained distribution +
state_version+model_id+wrapper_version. - 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-v1projection (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'sfeature_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 requiresK ≥ 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 —enforcefixes 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 variances²; 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 recordeddataset_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:
- 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).
- 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 andrun_id; a document spelling the fieldcs2explicitly is refused, §12 below —lol,dota2, orsynth(event-catalog §6.18: the match-winner market over thesynth/v1one-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 — nodyn, no allocation, the CS2 path byte-identical): the projector (cs2 state/v1|v2; lolstate/v2, event-catalog §6.15.3; dota2 the betty FRAME projector, §6.11.1; synthsynth_state/v1forwarded 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' recordedfeature_schema_idthrough 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:enforcewith the kill floor read off a cs2 state record,validate_rawon 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'sNone | Some(MatchWinner)arms — the column's family is the one difference) and settled by the same SC-7 barrier, named<title>/match_winneron a board; ALONE it is refused (ManifestError::BoardDefaultAlone— at validate, so neithercanonical_bytesnor 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 noFamily, so a cs2 board refuses byFamilyNotOffered; synth offers no family beyond its match-winner default, so a synth board is refused the same two ways —BoardDefaultAlonealone,FamilyNotOfferedwith 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, itsfamilytag the one difference): the SAME projector (the frame-kills seam'sFrameTitle::Projector—lol/state/v2's fold on LoL, the identity frame fold on Dota2), thenRaceToKillsPolicy<T>/ the race extractor (LolRaceFeatureExtractoron thelol/race-v1grid,Dota2RaceFeatureExtractorondota2/race-v1— each resolved through ITS title's RACE registry, §8) /BinaryRouter<Race<T>>/RaceOutcomeSource<T>(SC-8), all over the lines theFamilyLines::Racevariant carries (MF-7race_lineson the wire); or, underkills_at_minute,KillsAtMinutePolicy<T>/ the minute extractor (LolMinuteFeatureExtractoronlol/minute-v1,Dota2MinuteFeatureExtractorondota2/minute-v1— each resolved through ITS title's MINUTE registry) /BinaryRouter<KillsAtMinute<T>>/KillsAtMinuteOutcomeSource<T>(SC-9), all over the linesFamilyLines::Minutecarries (MF-8minute_lineson the wire); or, underside_kills_at_minute,SideKillsAtMinutePolicy<T>/ the side-minute extractor (LolSideMinuteFeatureExtractoronlol/side-minute-v1,Dota2SideMinuteFeatureExtractorondota2/side-minute-v1— each resolved through ITS title's SIDE-MINUTE registry) /BinaryRouter<SideKillsAtMinute<T>>/SideKillsAtMinuteOutcomeSource<T>(SC-10), all over the linesFamilyLines::SideMinutecarries (MF-9side_minute_lineson the wire); or, undergold_lead_at_minute,GoldLeadAtMinutePolicy<T>/ the gold-lead extractor (LolGoldLeadFeatureExtractoronlol/gold-lead-v1,Dota2GoldLeadFeatureExtractorondota2/gold-lead-v1— each resolved through ITS title's GOLD-LEAD registry) /BinaryRouter<GoldLeadAtMinute<T>>/GoldLeadAtMinuteOutcomeSource<T>(SC-11), all over the linesFamilyLines::GoldLeadcarries (MF-10gold_lead_lines,[minute, gold, team]triples on the wire — the fold reading GOLD through the title'sFrameSource<Gold>); or, undertowers_at_minute/dragons_at_minute/barons_at_minute,TallyAtMinutePolicy<T, K>/ the tally extractor (LolTallyFeatureExtractor<K>onlol/<kind>-v1,Dota2TallyFeatureExtractor<Towers>ondota2/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 linesFamilyLines::Towers/Dragons/Baronscarry (ONE wire field, MF-11tally_lines,[minute, count, team]triples — the family text names the kind; the fold reading the TALLY through the title'sFrameSource<Tally>; dragons and barons refused ondota2byFamily::offered_by); or, undertotal_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 TERMSFamilyLines::Total/Handicapcarry (ONE wire field, MF-12house_line, ONE[open_minute, lo, hi]triple — the family text names the kind), the bind constructing ONEHouseLineSetterover 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::HouseModelUnbenchedwhen 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'sboard: Boardlists 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, inScheduler::newbefore 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 (BinaryRouterindexes only its own family'smarket_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::newsorts the operator's spelling and refuses a duplicate,ManifestError::BoardDuplicateFamily; more thanBoard::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: 16or5: "cs2"(MF-14allocation_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 byAllocationRefWire) — 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 aftervalidateso an invalid document keeps its own refusal — so a stored file's bytes are the bytes itsrun_idderives 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 andrun_idsince unchanged — pinned over the committed fixture manifests byruntime/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 atvalidate(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 secondrun_id) —, a board of two or more rides MF-13boardalone: 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-4family, 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 (ModelSleeveNeedsFamilyotherwise — 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'shello_ackquoting 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'ssleeve_column, whichScheduler::newloads intobookmaker_column— read byScheduler::sleeve_coversandsleeve_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.rsan_external_only_board_column_is_a_valid_definition). The ops kit refuses such a board (house_models_for_titleresolves 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; sinceRunManifest::validateholds a CONSTRUCTED manifest to the same bound, so every encoding path produces bytes its own decoder accepts).d2-manifesttakes--familymore than once, each occurrence opening the group its lines flag belongs to (the same lines flag twice in one group refuses; amatch_winneroccurrence opens and closes a group with NO lines flag — a lines flag beside it refusesLinesWithoutFamily), 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_boardbinds ONE COLUMN per board entry (FamilyColumn— its ownLifecycleFold, 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::boardin 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: noneUnknownFeatureSchema, severalAmbiguousSchema; two sleeve models of one column on different gridsMixedColumnSchemas; a later column asking for another projector than the first'sMixedColumnProjectors, 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 byset_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 indexesmarket_createdand the column that did is the market's (market_column—market_classes/payout_ofanswer 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 recordedLive{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 ⇒UnknownFeatureSchemaat the fold, two ⇒AmbiguousSchema— the gateway refuses such a registration at the edge first, gateway-protocol §5 code 16unknown_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 (thewinnersuffix 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_titlehands every model tod2-gateway/d2-runtime([model.d2m ...]), readiness and the determinism sweep run the whole board,deploy.shmigrates a family-serve menu into boards once (migrate-boards.sh); the SDK app bridge routes eachsettlement_completeto ITS family's markets (SC_FAMILY, the engine'scoversrule) and/healthlists the claimfamiliesthe run has created markets for so far; pinned over the real three-familylol_boardfixture (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.8settlement_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 throughSettlementComplete::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;MatchWinnerthe 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—Nonefor 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--familybeside its lines flag — by the ONE ruleFamilyLines::pair(ManifestError::LinesWithoutFamily { lines, family }names the lines field given and the run's family — every lines field besidematch_winneris foreign —,LinesMissing(family)a lines-bearing family with no lines; being the decoder's, these precedevalidate'sFamilyNotOffered— a hand-authored cs2 +race_to_killsfile 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) in0..=200, at most 16 — each line's owncheckadding its threshold cap (kills in0..=500for 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— besidecheck_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'sTallyTitle::TALLY_KINDS_OFFERED—Family::offered_bynever re-spells it); and — MF-12house_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 throughreplay::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 byvalidateotherwise (ManifestError::HouseLineNeedsHouseModel { family }; on a board the sleeve tagged to that family, above). -
The feed rule: MF-15
feed_ruleis the closed map {0tag: bytes — a match-log lineage tag the ingest crate's tag registry declares, ending in/vfollowed by digits, at most 64 bytes (MAX_FEED_TAG_LEN, §16); 1stall_ms: u64 above 0; 2continuity: 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 itsrun_id. Present it is whole — a partial map refused by its missing key, an unknown key by name, and a bad tag, a zerostall_msand a continuity outside {0, 1} byManifestError::FeedRuleTag,FeedRuleZeroStallandFeedRuleContinuity; the tag's title is the manifest's (ManifestError::FeedTagTitle, the registry's row naming the title).check_match_logrequires 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 {0version: u64, 1hash: 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 tableManifestError::RetailFeeRulechecks against:version canonical rule text hash 1 no fee: an admitted subaccount instruction carries no fee term and no fill moves a feeBLAKE3 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 isorder_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):
- 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.
- 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_classas the artifact'sn_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'sfeature_map; the artifact binds the full schema id per the §8 projection-consumption note). - Quantize thresholds: for each split on feature f,
t_q = floor(t * scale_f). Because training data was pre-quantized,x <= tandx_q <= t_qagree on every representable input — traversal is exact, not approximate. - Quantize leaves:
leaf_q = round(leaf * 2^16)into i32 (range-checked). - 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). - 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. - 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 CS2exportand this share one CSV/manifest/hash implementation). The chronological split keys on the RiotgameId(monotonic per routing region —ingest::lol::lineage; a title-specific exception totrainset::split's CS2-derived "match ids are NOT a chronology proxy" warning — Dota2 deviates differently, keying on the archivematch_idwith no chronology claim). The corpus-CLI ingest refusal taxonomy (lol-ingest, pinned bytrainset/tests/lol_corpus_cli.rs; the Dota2 twin pinned bytrainset/tests/dota2_corpus_cli.rs): the pair directory is walked sorted. Three classes ABORT the run: a.match.jsonwith no sibling.timeline.json(a corpus-layout defect — nothing further is sealed); a corpus file over riot-edge's 16 MiBMAX_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, notIneligible— 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 —SegmentWriteriscreate_new, so leaving it would abort the run; the refusal path therefore has nothing left to clean up (the CS2ingestarm'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 becausedota2-edge's parquet readers cap the whole table file themselves (source::MAX_TABLE_BYTES). Its abort classes are the shard-pairing defect (aticks/<name>with nobuildings/<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 typedMatchRefusals (Frame,RosterSide, andNoMetadata— kept as its own variant though structurally unreachable onceRosterSidesucceeded, 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 (variablen_classes; which this corpus preceded) now recordsn_classesand 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_exactis 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_LUThandling. Instantiation: the harness is thed2-trainset eval/python -m d2m.differentialpair 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 forparse(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 throughMinuteLines::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 throughSideMinuteLines::newwith the team in{0, 1}; and —gold_lead_lines, MF-10, an array of[minute, gold, team]uint triples throughGoldLeadLines::newwith the gold in0..=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 throughTallyLines::<K>::newwith the count in0..=20; and — —house_line, MF-12, ONE[open_minute, lo, hi]uint triple throughHouseTerms::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 oneFamilyLinesvalue — 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 atBoard::MAX_FAMILIESbefore any collect and refused out of canonical order; plus the per-sleeve BK-4 family text through the sameread_text, and the bookmaker array'sMAX_BOOKMAKERScount cap); its oracle is BYTE IDENTITY on everyOk—canonical_bytesof the decoded value equals the input: aftervalidatethe 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 noOkre-encodes to other bytes; the one shape that did — an omit-at-default field spelled AT its default,4: 16or5: "cs2", which kept therun_idand changed the bytes — is refused (the board's at-default spelling, a board of one naming the default market, was already refused atvalidate,BoardDefaultAlone, §12), and a stored file's bytes are the bytes itsrun_idderives from; and the operator CSV parser since leg 3 — ONE generic body,LinesAtMinute<L>::parse_csvoverMinuteJudged::parse_item, so a single target parameterized overLcoversm: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-neutralraw_tally_lines_csv) — whose oracle is:Ok⇒ at most 16 items andnewagrees,Err⇒ the named item clamped to 16 bytes) — and, theingest::dota2frame and roster decoders (PlayerFrameV1::from_record/kills_from_record— andnet_worth_from_record, plus the LoLstate_v2::gold_from_record; the clock readsframe_index_from_record/tick_from_recordand the forwardedstructure_destroyed/v1/objective_slain/v1bodies now decoded INSIDE the fold on both titles (the readers' full structure decode, the borrowedobjective_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_recordwith 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 posturecrates/grid-edge/fuzz/set); every libFuzzer input reachesRunManifest::from_canonical_bytesunchanged, 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 everyOk— the no-overflow arm rides on cargo-fuzz's default build, which keeps overflow checks on; its corpus is seeded byexamples/seed_corpus.rsfrom every dev-fixtures run manifest — the all-fields one putting MF-3stale_threshold_ticksand MF-4sample_everyon the wire among them — plus the accepted side of theBoard::MAX_FAMILIES,MAX_LINESandMAX_BOOKMAKERScaps (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.rsanddecode_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 byexamples/seed_corpus.rsfrom 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 withcanonical_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'scontent_hashfield 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 homepricing::artifact::content_hashthat 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 atContentHashand the sixteen refusal arms behind it would never be reached. The rawContentHasharm is the deterministic matrix's (artifact_matrix.rs::content_hash_verified) and every production load's. The oracle is BYTE IDENTITY on everyOk: 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'saccepted_bytes_re_encode_to_themselveson five real shapes. Its corpus is seeded byexamples/seed_corpus.rsfrom 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 perParseErrorarm 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-capBadLengthby 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, whoseparse, 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_LUTconstants 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-13boardarray ≤Board::MAX_FAMILIES=Family::ALL.lenentries, 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 toMANIFEST_FIELDS(MF-0..17; the MF-17max_resting_ordersa u64 in 1..=2^32 − 1; the MF-16retail_fee_rulemap to {0 version, 1 hash}, its hash exactly 32 bytes; the MF-14allocation_refmap to {0 epoch_id, 1 allocation_offset}, refused byAllocationRefWire; the MF-15feed_rulemap 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 toBOOKMAKER_FIELDS(BK-0..4), each MF-13 board entry toFAMILY_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 derivedrun_iddoes NOT cover —run_idis BLAKE3 of the RE-ENCODED canonical document — so two distinct files would alias one run identity (the walk itself isstream_core::body::unknown_key, the one home the gateway codec's closed maps share); and the last alias class is closed at decode: aftervalidatethe 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 (aValuemap compared key by key, no second encode and no second decode) bounded by the caps (MAX_BOOKMAKERSentries plusBoard::MAX_FAMILIESentries of at mostMAX_LINEStriples — 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 transientValuetree — 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_features1..=65 535 (consumers size buffers from it — an uncapped width is an allocation amplifier); the class count2..=8(§4 — the floor two), refused at parse AND by both serializers (encode_artifactin Rust,EncodeError::BadClassCount, and in the Python exporter, a typedExportError), 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_TREESleaves of|i32::MIN|under a rescale ofi64::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:
- 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.
- 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.
- 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
end19.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 -.-> SHDWBookmakers 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"| PRFThis 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.