Sentinel Suite · NinjaTrader 8 · living document·Docs home →

Sentinel ML — Schema 1.3 Instrumentation + The Offline Lab

Status: SPEC (2026-07-09). Nothing in this doc changes a trading decision. Phase 1 is behavior-neutral instrumentation; Phase 2 is offline Python; Phase 3 is an opt-in, default-OFF consumption flag. Read alongside Docs/SENTINEL_DESIGN_SYSTEM.md §6 (SentinelCore seams) and the council + backtest-fill-resolution-lesson memories. ✅ Verified 2026-07-19 (Core v1.36.0). The DESIGN + thesis are current and the shipped-vs-deferred statuses still hold (vector/EpisodeId/interlock DONE per §11.5; Arc _fleet rekey + weight fit still open; floor 0.20 interim). The dated implementation logs (§11, §11.5, changelog) and the version numbers in them are point-in-time records — do not rewrite them. For CURRENT live versions see memory/NOW.md (Core v1.36 · Council v1.8.x · recorder v2.1.6 / schema 1.4); the live voter count renders via {{voter_count}}.


READ FIRST (2026-07-25) — where this spec stands

Most of this spec is still in use; its end goal is on hold. It specifies how to fit the Council's weights and conviction floor. That fit has since been run, and the fused verdict did not clear the cost bar out of sample — so the fitting step is parked, while the machinery described here is exactly what produced that measurement and carries straight into the current work.

The measurement (full account: SENTINEL_THESIS.md §1b): 3,777 replay fires graded tick-true on a 70/30 time holdout against a 0.12R cost bar (break-even p = 0.560). Neither the fused verdict nor any single sensor cleared it (best AVMA 0.564, Wilson lower bound 0.504; Council base rate 0.498; at n=448 decided the interval is ±5 points, so sample size is a real part of the answer).

SectionWhere it stands
§0 thesis · §5 phases · §7 Model.conf adoption · §8 build orderOn hold — there is not yet a result solid enough to fit against
§3 the label (ATR-scaled barrier, first-touch)In use, unchanged — it is the audition's label too
§6 validation protocol ("decide it before looking at results")The part that made the result trustworthy. Pre-registration + holdout is what kept an in-sample curve from being believed. Carry it into every audition
§2 schema instrumentation · §9 measured-baseline method · §10 identity/roster/fleetCurrent — corpus, vote vector, EpisodeId, scope/lane identity all still apply

⚠ SCHEMA DRIFT — this doc says 1.3; live is 1.5. firePx was Close[0], the synthetic Heikin-Ashi close — a price that never traded — which biased every excursion and first-touch label (recorded "target-first" 52.3% vs 21.1% true; labels disagreed on 44.6% of fires). Fixed in recorder v2.3.0 (row schema 1.5, sidecar ctick.4). ⛔ Schemas 1.3 and 1.4 are CONTAMINATED — never pool them with 1.5. Read every "1.3" below as the shape of the instrumentation, not as a corpus you may train on.

What the work moved to: audition each candidate voter standalone and tick-true across a bar-type matrix, and build up from what holds — same corpus, same labels, same holdout discipline, without assuming fusion helps. The binding constraint is N, which is what the front-month corpus bakes are buying.


0.The thesis in one paragraph

Council computes netScore = Σ(voteᵢ × wᵢ), Bias = sign(netScore) past a deadband, Conviction = |netScore| / declaredW. That is a normalized linear model over signed features with hand-set coefficients. The weights (Eye 1.4, Trend 1.0, CCI 0.8 …), the conviction floor, and the 0.15 deadband are model parameters set by intuition. Fitting them to the suite's own recorded outcomes is the machine-learning project. Everything below exists to make that fit possible, honest, and reversible.

⚠ Roster size (2026-07-14). This spec was written against the original 10-voter roster (EYE TRND CCI ADX ENV BRK CMP IMKT WAE GREV). The Council now fuses 25 voters (incl. the order-flow FLUX voter). The 10-tag lists below are the ORIGINAL set, not the current one; the fit machinery (ridge over the signed vote vector) is unchanged and scales to whatever roster is declared.

⚠ UPDATED 2026-07-10 — this spec predates three changes to the model. Read before trusting any number below. 1. The denominator is declaredW, not Σ(active weight) (Council v1.1.0). It used to be the summed weight of the present, directional voters — so a missing voter did not dilute conviction, it vanished from the denominator. One awake sensor of weight 0.6 scored 0.6/0.6 = 1.0. The fewer sensors awake, the more certain the Council sounded. Absence now dilutes. Every conviction figure recorded before this date is on the old scale and is not comparable. 2. Conviction is now pure agreement (Council v1.2.0). Context modulators (Clock · MTF · Participation · Location · squeeze · breadth) used to multiply into conviction before the floor test; they now scale SizeMult instead. The floor gates on agreement alone. 3. The floor is 0.20, not 0.35 (Council v1.2.1, interim). Under the new scale only 1 of 97 live verdicts cleared 0.35. ⚠ The floor sits on a cliff — 0.25→11% of verdicts, 0.20→41%, 0.15→60%. Fit it; do not nudge it. And the finding that supersedes the floor question entirely: declaredW conflates STATE voters (TRND ADX ENV IMKT — always directional) with TRIGGER voters (EYE BRK CMP WAE GREV — ±1 only on the bar they fire). The triggers carry 4.2 of the 7.80 total weight, parked at zero on a typical bar, pinning conviction near 0.16. A trigger that has not fired is an absence of evidence, not evidence against. The fix — a voter kind in Roster.conf, whose weight joins declaredW only when it fires — is not built, and it should be settled before any weight fit, because it changes what the features mean. The §11 empirical tables below (conviction bands vs win rate) were computed on the old scale and on a corpus now known to be contaminated (pre-scope-keys, pre-as-of-guard). They are kept as method, not as findings.

The blocker: we record the model's output (conviction, convBucket) but never its inputs. The per-voter direction vector lives in Council._votes at decision time and is discarded. Today we can grade the Council; we cannot fit it.


1.Three findings that shaped this spec

1.1 Two Councils cannot run side-by-side. (This answers "can I fork it?")

SentinelCore keys CouncilState by instrument name only:

private static readonly Dictionary<string, CouncilState> _council = …   // key = instrument
public static void SetCouncilState(string instrument, …) { _council[instrument] = s; }

Two Council indicators on the same instrument — say Council_v1_0_0 and a hypothetical SentinelCouncil_v1_1_0 — are last-writer-wins. They stomp each other on every tick, and every consumer (Bridge, GTrader21, Cockpit, the Recorder) reads whichever fired most recently. There is no Source scoping in the key.

So the natural "run the old build while developing the new one" instinct cannot be satisfied by forking the Council. It can, however, be satisfied a better way — see §4.

The Recorder is the opposite case: it writes per-instance files (historically <stamp>__<inst>__<bartype>.jsonl; the current SentinelExcursionRecorder_v2_0_0 writes schema-1.3 rows under Excursions\council\1.3\ + tick sidecars under council\ticks\) and already disambiguates two instances on one chart. Two recorders coexist cleanly.

1.2 The orthogonal axes never reach the training set.

The Council computes _clockPhase, _pRvol, _mtfBias, _lvlInPath and folds them into conviction as modulators. None of them are published on CouncilState, so none reach the JSONL. These are precisely the feature-independent signals — the ones most likely to survive regularization against the collinear price-derived voter block. Omitting them would train a model on the weakest half of the evidence.

1.3 The current schema cannot resolve barrier ORDER.

maxMFE and maxMAE are running maxima to EOD. msToMFE / msToMAE are the times of those maxima, not the first touch of any level. So given maxMFE = 30t, maxMAE = 25t, and a 20-tick barrier, we cannot tell whether the target or the stop was hit first — which is the entire label.

The 1/5/15/60-minute milestone grid partially rescues this (a horizon label at 15 min is well-defined), but ambiguity remains whenever both barriers are breached inside one horizon. Schema 1.3 fixes this directly with a first-touch record.


2.Schema 1.3 — the additive changes

All three changes are additive. Old readers ignore new fields; the trainer reads mixed 1.2/1.3 files and simply has fewer features on old rows.

2.1 SentinelCore v1.14.0 → v1.15.0CouncilState gains the decision vector

SentinelCore is a single static class in a single assembly. It can never be forked. Additive only. Add to CouncilState:

public sealed class CouncilState
{
    …existing fields unchanged…

    // v1.15.0 — the DECISION VECTOR (what the Council actually saw). Machine-readable
    // counterpart to the human-readable Reasons string. Nulls when the publisher predates 1.15.0.
    public Dictionary<string,int> Votes;      // tag → -1/0/+1, ONLY fresh voters (abstainers absent)
    public Dictionary<string,double> VoteW;   // tag → effective weight applied this update
    public double NetScore;                   // Σ(dir × w)  — SIGNED, pre-normalization
    public double ActiveW;                    // Σ(w) over voters that cast a direction
    // modulator context (the orthogonal axes — currently invisible to consumers)
    public int    ClockPhase;                 // -1 unknown · 0 Closed · 1 OpenDrive · 2 Midday · 3 Close
    public double Rvol;                       // ParticipationState rvol, NaN = none
    public int    MtfBias;                    // MtfState consensus (0 = none/agree)
    public bool   LevelInPath;                // a structural level lies in the bias's path
    public string LevelName;                  // that level's name
}

Votes / VoteW keyed by the Council's existing chip tags — EYE TRND CCI ADX ENV BRK CMP IMKT WAE GREV. Use a stable tag order; the trainer treats an absent key as abstain, which is not the same as a zero vote and must not be imputed as one.

Add an overload rather than editing the 11-arg SetCouncilState signature, so any caller that predates this compiles untouched:

public static void SetCouncilState(string instrument, int bias, double conviction, double sizeMult,
                                   int agree, int disagree, int voters, bool vetoed, string vetoReason,
                                   string reasons, string source)
    => SetCouncilState(instrument, bias, conviction, sizeMult, agree, disagree, voters,
                       vetoed, vetoReason, reasons, source, null, null, 0, 0, -1, double.NaN, 0, false, null);

public static void SetCouncilState(string instrument, int bias, double conviction, double sizeMult,
                                   int agree, int disagree, int voters, bool vetoed, string vetoReason,
                                   string reasons, string source,
                                   Dictionary<string,int> votes, Dictionary<string,double> voteW,
                                   double netScore, double activeW,
                                   int clockPhase, double rvol, int mtfBias, bool levelInPath, string levelName)
{ … }

Copy the dictionaries into the state object. The Council reuses _votes every update; handing out a live reference publishes a struct that mutates under the reader's feet on the next tick.

2.2 Council_v1_0_0 — populate the vector (behavior-neutral, IN-PLACE)

AddVote already receives (tag, dir, weight) and already maintains _votes. The change is to carry netScore / activeW through to the publish call and project _votes into the two dictionaries. No change to netScore arithmetic, the deadband, conviction, SizeMult, or any veto. Diffable proof of neutrality: nothing upstream of SetCouncilState moves.

Also add a LabelBarrierTicks property (default 20, [Display]-only — not [NinjaScriptProperty], per the design-system rule about not touching the generated region).

2.3 SentinelExcursionRecorder_v1_4 — schema "1.2""1.3"

⚠ Current recorder (2026-07-14): the live writer is now SentinelExcursionRecorder_v2_0_0 (internal v2.1.2) — Council-only, and it writes schema-1.3 row files to Excursions\council\1.3\ plus per-fire tick-path sidecars to Excursions\council\ticks\ (v2.1.2 streams each row to disk on excursion-window completion, ~60 min, for crash-safety). The field list below is the schema-1.3 definition and still applies.

Emit, in addition to today's fields:

FieldTypeNotes
idstringstable row id: sha1(inst + fireTime + signal + dir)[..12]. Needed to join + to dedupe re-runs.
votesobject{"EYE":1,"TRND":1,"ADX":-1,…}fresh voters only
voteWobject{"EYE":1.4,"CCI":1.2,…} — effective weight (post ×1.5 strong-CCI etc.)
netScorenumbersigned
activeWnumber
clockPhasenumber-1 / 0-3
rvolnumber\null
mtfBiasnumber
levelInPathbool
levelNamestring\null
barrierTicksnumberthe R used for the first-touch fields below
firstTouchnumber+1 target hit first · -1 stop first · 0 neither touched by EOD
msToFirstTouchnumber\null
triggerstring"flip" (today's edge-detect) — reserved for "snap", see §5.3
costTicksnumbercommission + assumed slippage, in ticks, at fire

firstTouch is the single most valuable addition. It is what makes a clean, unambiguous label possible. Implement it in the running-update loop: once fav >= barrierTicks or adv >= barrierTicks, latch whichever crossed first and stop updating it.

Fill realism (non-negotiable). firstTouch is resolved from the same bar stream the recorder already walks. On a coarse bar type it will be optimistic, exactly as CompressionBase was (81% → 37.5% when moved from bar to tick fills — see the backtest-fill-resolution-lesson memory). Record bartype (already present) and train per bartype, or run the recorder on a tick-based series. Do not pool a Renko-labeled dataset with a minute-labeled one.


3.The label

Do not train on "did the trade win." Train on a barrier outcome, which exists for every verdict, including ones no strategy took.

R          = barrierTicks
cost       = costTicks
label y    = 1  if firstTouch == +1 and (R - cost) > 0
             0  if firstTouch == -1
           drop if firstTouch ==  0   (censored — never resolved by EOD)

Censored rows are dropped, not labeled zero. A verdict that drifted sideways to the close is not a loss; treating it as one teaches the model to fear chop that never cost anything.

sample_weight = uniqueness — the reciprocal of how many other rows' label windows overlap this row's [fireTime, fireTime + msToFirstTouch]. Two verdicts 90 seconds apart share most of their forward window and are not independent observations. Without this, effective N is wildly overstated and every significance test lies.


4.The fork question — three lanes, only one of which is in the NT tree

Git branches do not give you isolation here. NinjaTrader compiles the working tree of bin\Custom into one assembly. A branch checkout swaps the files under the running platform; you cannot run master while editing a branch. NT's real branching mechanism is the version-suffixed filename, and even that fails for the Council (§1.1 — shared seam key).

So do not fork the tree. Split by lane:

Lane A — in-tree instrumentation (Phase 1). Merge to head, no fork.

SentinelCore +overload · Council_v1_0_0 populate · Recorder → schema 1.3. Behavior-neutral: no decision path is touched. One F5, then it accumulates training data while you keep trading the build exactly as it is. This is the whole point — the data starts building today and costs you nothing.

Precedent for patching the head in place rather than version-forking: GTrader21 v0.1.7's "in-place hardening: Ledger fill capture" and Deck v0.2.2's "kept IN-PLACE; freeze v0.2.3 once auto-fire passes SIM." Freeze SentinelCouncil_v1_1_0 at the Phase-3 boundary — which is also the right moment to pay the naming-law rename, since that is the change that legitimately breaks serialization identity and drops the indicator off saved charts.

Lane B — the offline Lab (Phase 2). Lives OUTSIDE bin\Custom. Fork freely.

Documents\NinjaTrader 8\Sentinel\Lab\ — Python. NinjaTrader never compiles it, so no CS0101, no ghost errors, no F5, no risk to a live account. Sentinel\ is already the config-git repo, so the Lab gets real branches, real history, and model artifacts versioned next to the configs that produced them.

This is where the parallelism you want actually lives. Iterate on the trainer for weeks while the platform runs untouched.

Update (2026-07-14): the Lab is no longer just a trainer script — it is now a BUILT SQLite data platform (Lab\db\sentinel.db + a live-watch JSONL→SQLite ingester that folds the council\1.3 vote vectors in, + a Streamlit explorer on :8501 and Grafana on :3000). ~5,300 trades ingested, ~99% carrying the decision vector.

Lane C — consumption (Phase 3). Fork by flag, not by file.

Council gains UseLearnedWeights (default OFF) and reads Sentinel\Model.conf. Absent, stale, wrong-instrument, or malformed model ⇒ fall back to the hand-set weights — fail-open, matching the suite's abstention semantics everywhere else.

Precedent: UseCouncilGate in GTrader21 v0.1.7 (default OFF; ON + UseEyeGate OFF = fully decoupled) and hardEnforce on the Gate. The suite already knows how to introduce a new brain behind a switch.

This gives you a true A/B: flip the flag on one chart, leave it off on another instrument, and the Recorder tags every row with which weight set produced it.


5.Phases

5.1 Phase 1 — Calibrate (needs no schema change; start now)

Fit a monotone map conviction → P(win) (isotonic, or Platt with 2 parameters). Two effective parameters, converges on a few hundred rows, and schema 1.2 already has everything it needs.

Payoff: the 0.35 conviction floor stops being a guess. (The 0.35 here predates the conviction rescale; §0's 0.20 interim floor is the authoritative current value — fittable, not yet fitted.) Set it where calibrated expectancy crosses zero after costTicks. Ship as calib.* in Model.conf; the Cockpit's why-line can then say "below floor (0.31 → 47% est.)" instead of a bare number.

5.2 Phase 2 — Fit the weights (needs schema 1.3 + ~3 months of flips)

Ridge-regularized logistic regression on the signed voter vector. L2 is not optional: ADX, CCI, Trend, Envelope and Brick are all price-derived and echo the same OHLC (the Council's own header admits this). Unregularized, collinear coefficients thrash — huge positive on one twin, huge negative on the other, superb in-sample, worthless out.

The fitted coefficients answer the question the suite was built to ask: which voters carry independent information. My prior is that Clock, Participation, Intermarket and Location survive shrinkage better than half the price-derived trend block.

Sample volume, honestly. The Recorder edge-detects on v.Bias != _lastCouncilBias, so it is one row per bias flip. Measured on the existing data (§9): ~150 rows/day on one GC chart — plenty of rows, but the median gap between fires is 0.4 minutes against a 15-minute label window, so effective N is a small fraction of row count. Volume was never the constraint. Independence is. Fit 10 ridge coefficients, not 40, and quote nEffective, never nSamples. (The roster has since grown to 25 voters; the coefficient count is whatever the declared roster holds, but the same independence caution applies, now more so.)

The one genuinely lucky property: the Recorder fires on every HasEdge flip, including verdicts below the conviction floor that no strategy ever traded. The training set contains the counterfactual. That is why the floor can be learned rather than merely validated — and it is worth far more than any model-class upgrade.

5.3 Phase 3 — Regime-conditional weights, and only then trees

Separate weight vectors per coarse context bucket (clockPhase, ADX on/off, rvol high/low). A shallow tree over the orthogonal axes with a linear model at each leaf. Keep buckets few — parameter count multiplies by bucket count and §5.2's N does not.

If you later want more label density for calibration specifically, add trigger:"snap" rows (periodic verdict snapshots). They inflate N without inflating information — heavily autocorrelated — so they are admissible only under uniqueness weighting, and only for the calibration curve, never for weights.

Gradient-boosted trees are the realistic ceiling for tabular market data, not deep learning. But the gain over §5.3 is usually modest and it destroys the Reasons audit string, which the entire Cockpit why-line depends on. Reach for them last, if at all.

5.4 Explicitly out of scope


6.Validation protocol — decide it before looking at results

  1. Purged walk-forward CV with an embargo. Never random k-fold. Overlapping forward windows mean adjacent rows share outcomes; random k-fold leaks the test set into training and is the single most common way people convince themselves they have an edge they don't. (López de Prado, Advances in Financial Machine Learning, ch. 7 — worth reading specifically because it is about this failure mode rather than about models.)
  2. Purge any training row whose label window overlaps the test fold. Embargo a fixed span after each test fold before training resumes.
  3. Uniqueness-weight every sample (§3).
  4. Hold out a final untouched period. Spend it once. Every hyperparameter tried burns significance.
  5. Baseline is the hand-set weights, not a coin flip. If ridge cannot beat the current Council on a purged walk-forward split, the honest report is "the hand-set weights win" — and you still gain the coefficient ranking, which tells you which sensors are dead weight and where to build next.

7.Model.conf — the artifact

Flat key=value, matching the suite's existing .conf idiom (Profiles.conf, Alerts.conf, News.conf) so the C# side needs no JSON parser. Lands in the config-git repo, so every model version is committed and diffable against the P&L it produced.

schema=1
trainedUtc=2026-10-02T14:11:03Z
expiresUtc=2026-11-02T00:00:00Z     # stale ⇒ Council falls back to hand weights (fail-open)
instrument=GC
bartype=SentinelTBars
nSamples=1180
nEffective=412                       # after uniqueness weighting — the honest N
auc=0.578
brier=0.238
baselineAuc=0.561                    # the hand-set weights, same purged split

# fitted voter weights (replaces WeightEye / WeightTrend / …)
w.EYE=1.22
w.TRND=0.61
w.CCI=0.09
w.ADX=0.44
w.ENV=0.12
w.BRK=0.31
w.CMP=0.83
w.IMKT=0.77
w.WAE=0.55
w.GREV=0.68
w.intercept=-0.14

# calibration: P(win) = 1 / (1 + exp(-(a * netScoreNorm + b)))
calib.a=2.31
calib.b=-0.27
calib.floor=0.42                     # conviction where expectancy crosses zero after cost
                                     # (illustrative — the live interim floor is 0.20 per §0, pending the fit)

Guards the Council must enforce before trusting it: schema matches · instrument and bartype match the chart · expiresUtc in the future · every w.* tag present. Any failure ⇒ log once, use hand weights, keep trading. A model file is never allowed to stop the platform.


8.Build order

STATUS (2026-07-12): items 2–4 SHIPPED (CouncilState vector + overload · Council populate · Recorder schema 1.3 + first-touch) — bridge-verified, needs one F5. Item 1 (offline calibration trainer) exists in Sentinel\Lab\. See §11.5.

#LaneItemRiskBlocks
1BTrainer reads schema 1.2 → calibration curvenone (offline)
2ASentinelCore v1.15.0 CouncilState vector + overloadnone (additive)3, 4
3ACouncil_v1_0_0 populate (behavior-neutral)none5
4ARecorder schema 1.3 + firstTouch latchnone (writer only)5
5Accumulate ~1 quarter of flips. Keep trading the build as-is.none6
6BRidge logistic + purged walk-forward vs. hand-weight baselinenone (offline)7
7CUseLearnedWeights flag + Model.conf reader, default OFFgated
8CFreeze SentinelCouncil_v1_1_0 (pay the naming-law rename here)serialization break

Items 1–4 are a single evening and change nothing about how the platform trades. Item 5 is patience. Everything with real risk is item 7, behind a default-OFF flag, on a decoupling pattern the suite has already run twice.


9.Measured baseline — what Sentinel\Excursions\ actually contains (2026-07-09)

1,258 rows across 11 files: 878 CBRK, 332 COUNCIL, 61 OBR. All schema 1.2. Of the Council rows, 308 carry a usable 15-minute milestone. Effectively all of it is GC / 69697v6 (303 rows); NQ has 11 and a second GC bartype has 5. Four facts fell out, and each one is load-bearing:

① The Council's bias chatters. Median gap between fires: 0.4 minutes (~150 rows/day). Against a 15-minute label window every row overlaps ~35 neighbours. 303 rows is nowhere near 303 observations — the honest effective N is in the tens. This is why §3's uniqueness weighting is not a refinement but a prerequisite. It also suggests the Council itself wants a minimum-dwell debounce before it flips bias: netScore hovers near the deadband and oscillates across it. Recommend the Recorder additionally emit msSinceLastFlip, and that a dwell filter be evaluated (offline first — it changes nothing to measure it).

EYE and BRK never vote. Across 332 verdicts the reasons string contains TRND (330), ADX (312), WAE (310), ENV (310), CMP (310), CCI (310), GREV (306), IMKT (305) — and zero occurrences of the Eye or the Brick. 285 of 332 verdicts have exactly 8 fresh voters. So WeightEye = 1.4, nominally "the strongest single voice," has never once been applied on this chart. Before fitting anything: either load the Eye, or accept that its weight is dead code and that any model fitted here estimates 8 of 10 coefficients.

③ Conviction does not currently rank outcomes — it ranks them slightly backwards. On GC/69697v6, P(mfe15 > mae15) by conviction band:

convictionnP(MFE > MAE)
0.00 – 0.2015852.5%
0.20 – 0.359451.1%
0.35 – 1.015143.1%

The band the 0.35 floor admits is the worst-performing band. Given ① the effective N here is tiny and this is a descriptive in-sample peek — it is not evidence the Council is broken. It is evidence that nobody has ever checked, and that the floor was never derived from anything. Exactly the gap Phase 1 closes. Do not act on this table; reproduce it under purged CV with uniqueness weights.

④ The schema-1.2 fallback label is unusable at realistic barriers — empirically, not just in theory. Applying §3's pessimistic both-breached-⇒-loss rule to the 15-minute milestone:

R (ticks)winlosscensoredwin ratebreakeven @ 1.5t cost
538270012.3%65.0%
2075231224.5%53.8%
401231751041.3%51.9%
808910411546.1%50.9%

mae15 exceeds any small R almost always, so the pessimistic rule calls nearly everything a loss. This is not a finding about the Council — it is the direct, measured consequence of §1.3: schema 1.2 cannot resolve barrier order. The firstTouch latch is what converts this table from noise into a label. It is the single highest-value line of C# in this document.

Corollary: run the Recorder on a chart whose bartype gives fine forward resolution, and set barrierTicks from ATR rather than from a constant. The maxMAE distribution here (mean ~60t at 15 min on gold) says a 20-tick barrier is inside the noise.


10.Identity, roster, and the multi-contract fleet

The sensing layer is deliberately provisional. The voters are scaffolding erected to find the right inputs, and they will churn. A design that only works once the voter set settles is the wrong design. What follows makes churn the normal case and attribution the invariant.

10.1 Three identities are currently collapsed into one

SentinelBridge_v0_2_0 submits every order with _tag = "SentinelBridge" — a const. Ledger.Write carries only acct and that free tag. The verdict is recorded as prose in Ledger.Action("bridge-fire", acct, detail). The excursion JSONL lives in a different file with no shared key.

Five distinct things wear one name:

IdentityWhat it answersToday
Actor classwhich code submitted the order"SentinelBridge"
Actor instancewhich running Bridge — this chart's, on this accountnowhere
Decider (model)what produced the verdict — voters, versions, params, weights, floor, deadband, bartypenowhere
Policyhow it acted — TP / SL / size / exit rulesnowhere
Episodewhich decision this fill belongs tonowhere (timestamp proximity only)

The consequence is blunt: Lens cannot join a fill to the verdict that caused it, and no artifact anywhere records what the model was. The claim that the Bridge "records the verdict so Lens can grade the weights" is true only in the sense that it writes a sentence a human can read.

Fix all five. They are cheap, additive, and independent of any ML. §§10.2–10.4 handle the decider and the episode; §§10.9–10.11 handle the actor and the policy, which an earlier draft of this document wrongly treated as solved by modelId. They are not: modelId fingerprints the Council's config, and says nothing about the thing that acted on it.

10.2 EpisodeId — the missing primary key

The Council's verdict updates continuously, but the meaningful unit is the episode: a maximal run of constant Bias. The Bridge fires once per episode. The Recorder opens one Rec per episode. They are already talking about the same object — it just has no name.

Give it one. CouncilState.EpisodeId, e.g. GC-20260709-0042, incremented on each bias change (not each tick), stable across the episode's life.

Then it threads everywhere, and the joins close:

fills → episode → verdict → excursion outcome becomes a real join on a real key. This one field is worth more to Lens than any model.

10.3 ModelId — a content-addressed fingerprint of the decision config

modelId = "gc-tbars-" + sha1(canonical(config))[0..7]

canonical(config) is a decision-relevant allowlist, sorted, stable-serialized:

instrument · bartype · councilVersion · coreVersion
weights[tag→w]  (sorted by tag)
deadband · convictionFloor · staleSec
roster[(voterTag, indicatorType, indicatorVersion, paramHash)]  (sorted)
modulatorsEnabled · vetoSet

It must be an allowlist, never "hash everything." ShowCard, CardCorner, ShowIndicatorLabel, LogChanges are cosmetics; if they enter the hash, every UI tweak orphans your history and the fleet fragments into unjoinable singletons. This rule is the whole game — write it down and enforce it in one function.

Sentinel\Models\<modelId>.json is the model card: the full expansion of that hash. Content-addressed ⇒ write-once, never mutated. Same config ⇒ same id ⇒ same file. Different config ⇒ new id, new file, and every prior row still resolves to the exact model that produced it.

10.4 Declare the roster; record the deviation

This is the deepest problem, and §9's finding ② is its symptom. The Council's roster is emergent — it votes with whatever seams happen to be fresh. So EYE and BRK silently never voted for 332 verdicts and nothing anywhere said so. With a deliberately churning voter set, an emergent roster makes attribution impossible: you cannot distinguish the model from what happened to be loaded on the chart.

Declare it. Sentinel\Models\<INST>\<bartype>\Roster.conf lists the expected voters. On load the Council resolves the declaration against reality:

Then:

Fingerprint the intent; record the reality. Training can then filter on rosterComplete, or better, learn from the abstention structure — which voter was missing is itself information.

The Cockpit gains the line that would have caught finding ② on day one: Roster 8/10 — EYE, BRK missing. Same class of bug as the stale-vs-absent confusion it was built for.

Weight-0 voters are the exploration primitive. Add a candidate sensor to Roster.conf with w = 0. It votes, it is recorded in votes/voteW, and it contributes nothing to netScore or activeW. You accumulate its full history — and can measure exactly what it would have contributed — before it ever influences a single trade. Adding and retiring sensors becomes a config change with zero risk and zero code. This is the workflow a provisional sensing layer actually needs.

10.5 Champion / challenger — how to explore while running

We established (§4) that two Councils cannot coexist: CouncilState is keyed by instrument, so they stomp each other. But you never needed a second Council. Scoring K weight-vectors against one voter vector is K dot products — nanoseconds, no allocation, no new indicator.

So the Council loads Sentinel\Models\<INST>\challengers\*.conf, computes the active verdict from the champion (or the hand weights), and also evaluates every challenger on the identical voter vector. It publishes only the champion — every downstream consumer is untouched — and records all of them:

"challengers": { "gc-tbars-a3f91c2": {"bias": 1, "conv": 0.42},
                 "gc-tbars-77b0e41": {"bias": 0, "conv": 0.11} }

Every live bar now grades every candidate model on real data, at zero capital risk, with no fork and no second chart. And because champion and challenger see the same episode, the offline comparison is paired — it cancels the market-regime variance term entirely. Given §9's brutal effective-N problem, a paired comparison is worth more than several extra months of unpaired data.

Promotion becomes a config edit. Demotion is instant. Neither touches code.

10.6 The fleet: per-instrument artifacts, jointly fitted

You are right that GC, NQ, ES and CL want different models. But the naive reading — four independent pipelines — is the wrong one, and §9 says why: effective N per instrument is already in the tens. Split that four ways and every model is noise.

The standard answer is partial pooling. Fit one joint model with a shared global weight vector plus per-instrument deviations, penalizing the deviations hard:

w_inst = w_global + δ_inst        minimize  loss + λ‖w_global‖² + λ_δ‖δ_inst‖²      (λ_δ ≫ λ)

An instrument with little data is shrunk toward the global consensus; one with lots of data is allowed to depart from it. You get per-instrument models without per-instrument overfitting, and you find out which instruments genuinely differ rather than assuming they all do.

The C# side stays dumb: Model.conf remains per-instrument, flat, fully expanded. Only the fitting is joint. Resolution chain in the Council: (inst, bartype)(inst, *) → hand weights, fail-open, log once.

Cross-contract features must be scale-free. Ticks are not comparable across GC, NQ, ES and CL — neither in size nor in dollar value. Pooling raw tick MFE/MAE is meaningless. Schema 1.3 must therefore also record atrAtFire and tickValue, so barriers and excursions become R-multiples in ATR units. Without this, joint fitting is not merely inaccurate — it is nonsense. It is also, separately, the right way to set barrierTicks (§9 corollary).

10.7 Ledger schema — three fields, additive

Ledger.Write(evt, account, data) gains a context block, emitted on order / action / fill alike:

"strat": "SentinelBridge_v0_2_0",
"model": "gc-tbars-a3f91c2",
"episode": "GC-20260709-0042"

tag stays for backward compatibility; Entry gains three parsed fields. Old rows simply carry nulls. Every actor that submits an order — Bridge, Deck, GTrader21, Copier — passes the same context. Actor, decider, and episode become separable for the first time.

10.9 Actor instance + policy — the two identities modelId does not supply

Walk the collisions. Two Bridges on different instruments are distinguishable today, but only by accident: the Ledger happens to record instr. Two Bridges on the same instrument, different bartype write identical instr (GC 08-26), acct and tag — only modelId separates them, since scope carries bartype. That case §10.3 covers.

But two Bridges on the same scope with different TP/SL — the A/B you most want to run — share a modelId, share an episodeId, and are indistinguishable. So is one Bridge before and after you widen its stop: the Ledger cannot tell the two eras apart.

Two more keys close it:

instanceKey = "SentinelBridge#GC.TBC6-24-69697@SIM-LAB-A"   // class # scope @ account — DERIVED, stable
policyId    = "pol-" + sha1(canonical(execConfig))[0..3]    // tp · sl · baseQty · exitOnCouncilFlip · …

Delimiters are load-bearing. Scope is GC.TBC6-24-69697, not GC|TBC6-24-69697| is Profiles.conf's field separator and cannot appear in a composite key. Account names are governed by Docs/SENTINEL_NAMING_FEDERATION.md §7 (SIM-<LANE>-<SLOT>, charset [A-Z0-9-]), which forbids every character used as a delimiter anywhere in the suite. The Gate verifies the SIM- prefix against the live connection, refuses unprofiled accounts, and treats Sim101 as permanent quarantine.

Splitting policy from model is what makes A/B a comparison rather than a filing system. Two Bridges on one chart running TP=40 and TP=60 share modelId and episodeId and differ only in policyId — which is exactly right: same decision, two policies, a paired comparison. Same statistical trick as champion/challenger (§10.5), one layer down.

policyId obeys the same allowlist discipline as modelId (§10.3): execution parameters in, cosmetics out.

10.10 The name is an interlock, not a label

SentinelCore.RegisterActor(instanceKey, account, instrument) returns false on collision, and the Bridge refuses to arm when it cannot claim its key.

This is not bookkeeping hygiene. Consider what an instanceKey collision is: two Bridges on the same scope and the same account. NT's account position is shared across strategies, and a managed strategy whose account position moves underneath it desyncs and blocks all new entries until disable/re-enable (see the managed-position lesson in CONTRIBUTING.md). The configuration that is ambiguous in the Ledger is the same configuration that is dangerous in the account. Naming and safety are one mechanism; the second falls out free.

Consequence for A/B testing: two armed Bridges on one instrument need separate accounts (Sim101 / Sim102), or one must run shadow-record — evaluating and writing its hypothetical fire to the Ledger without submitting an order. Today a disarmed Bridge records nothing, which throws away the cheapest data in the system. Shadow-record extends champion/challenger from Council weights up to execution policy.

NT mechanics, two real traps.

10.11 Arc has the same disease

private static readonly Dictionary<string, FleetSlot> _fleet;   // key = master instrument name
public sealed class FleetSlot { public string Instrument; public string Strategy; /* a LABEL, not a key */ … }

SlotLive("GC") returns one answer for every GC strategy. Two Bridges on GC share one slot, one plan, one supervision record — so Arc cannot enable one and idle the other, and its FillsToday / DayPnl / Health are silently summed across both. FleetSlot.Strategy already exists as a descriptive label; promote it into the key. _fleet[instanceKey], and SlotLive(instanceKey) with the existing fail-open (no slot ⇒ true) preserved.

10.12 The trader's interface — names, not hashes

A design this structured will be under-used if the interface is not straightforward. The content- addressed IDs above are machine-perfect and human-hostile. No trader will ever type gc-tbars-a3f91c2.

Four rules, in priority order.

① The system must be fully usable with zero naming. Naming is an optional refinement, never a setup step. Every alias has an auto-derived, human-readable default:

ThingAuto-defaultTrader may override to
instanceGC · TBars 6-24 · Sim101"GC Morning"
modelHand weights (or Model.conf's alias=)"Balanced v3"
policyTP40/SL20 (or the lab .conf filename)"Tight"

A trader who never opens the Cockpit still gets readable Ledger rows. A hash is never shown by default.

② Alias ≠ identity. The trader renames freely; nothing re-keys. instanceKey / modelId / policyId are derived and immutable; the alias is a mutable display string bound to one. Rename "GC Morning" → "GC Morning (old)" and every historical row still resolves. Store aliases in Sentinel\Aliases.conf (instanceKey = alias), which lands in the config-git repo. The Ledger writes both — the id as the join key, the alias denormalized alongside it so a raw JSONL line is readable without a lookup.

③ One human name, not three. The trader configures a chart. Call that a Setup. The Setup name is the single durable human thread; modelId and policyId are automatically-versioned eras inside it. Change the stop on "GC Morning" and the policy fingerprint changes while the Setup name does not — so Lens reports "GC Morning, policy changed 2026-08-14", which is exactly how a trader already thinks about it. This is the whole UX thesis: humans name the thing they can see; the machine versions everything under it.

④ Name where uniqueness is visible. Uniqueness is obvious when you can see the whole fleet at once, and invisible in a per-chart property grid. So:

Precedence: Aliases.conf > F6 Setup name > auto-derived default.

Collision behaviour differs by kind, and this distinction matters:

┌──────────────────────────────┐
│ GC MORNING            ● LIVE │   setup alias      (human)
│ GC · TBars 6-24 · Sim101     │   derived scope    (machine, shown small)
│ Brain   Balanced v3          │   model alias
│ Play    Tight 20/40          │   policy alias
│ ──────────────────────────── │
│ LONG    conv 0.62   size 1×  │
│ Roster  8/10  ⚠ EYE, BRK     │
│        [ ARM BRIDGE ]        │
└──────────────────────────────┘

This extends the Federated Naming Law with a fourth axis. The law governs class / file / display / namespace — all build-time names. Setup name is a runtime instance alias, a different kind of thing, and every actor in the suite (Bridge, Deck, GTrader21, Copier) should expose it identically.

10.13 Revised build order

STATUS (2026-07-12): 0a–0c (scope keys · as-of guard · corpus cut) done earlier. This session shipped item 1 (EpisodeId), item 3 (Ledger episode/instance context), the collision-refusal half of item 4 (RegisterActor + Bridge instanceKey refuse-to-arm — which also settles the §10.11 hazard), and item 9's vector (schema-1.3 votes/voteW/netScore/activeW; atrAtFire/tickValue still pending). Item 8 (Arc _fleet[instanceKey]) DEFERRED as a genuine Arc+GTrader21 redesign. See §11.5.

Items 2–4 of §8 absorb this; the identity work is a prerequisite to Phase 2, not a follow-on.

#LaneItemWhy it comes first
0aAScope-key every seam (`GC\TBC6-24-69697`), ambiguity-guarded compat shimtwo charts on one instrument currently overwrite each other
0bAAs-of guardBarTimeUtc + gate recording on State.Realtimehistorical bars are stamped with realtime verdicts
0cRegenerate the excursion corpusthe existing rows are contaminated by 0a + 0b
1AEpisodeId on CouncilState; Recorder + Bridge stamp itwithout it there is no join key at all
2A_tag → derived instanceKey (one line; no Core change)80% of attribution for 10 minutes of work
3ALedger strat/instance/model/policy/episode contextseparates the five identities of §10.1
4ARegisterActor interlock + refuse-to-arm on collisionthe ambiguous config is the dangerous config
5ARoster.conf + rosterMask + RosterCompletemakes the model declared rather than emergent
6AmodelId + policyId fingerprints + write-once model cardmakes history addressable
7AAliases.conf + Setup name + Cockpit fleet rosterthe interface — without it none of this gets used
8AArc _fleet[instanceKey] (§10.11)Arc cannot currently supervise two GC charts
9Aschema 1.3 vector + firstTouch + atrAtFire + tickValuethe training set
10Achallenger + shadow-record loop (no orders)paired grading at zero risk
11Bjoint partial-pooled fit → per-instrument Model.confthe fleet
12CUseLearnedWeights, default OFFthe switch

Items 0a–8 carry no ML at all and are worth doing even if the modelling is abandoned entirely: 0a–0c are outright correctness fixes, and 1–8 turn the Ledger from a diary into a database. Item 7 is not decoration — a design this structured is under-used if the interface is not straightforward, and the trader must never meet a hash.


11.IMPLEMENTATION LOG — what actually shipped (2026-07-09)

Phases 0 and 1.1–1.3 are built and F5-verified. Three corrections to this spec fell out of building it.

11.1 The real bar tag is 69697v6x24, not TBC6-24-69697

TBC6-24-69697 is a legacy filename tag. The Recorder's own comment explains why it changed: a custom bar type's name resolves inconsistently by load state, so it now uses the numeric (int)BarsPeriodType id. The canonical tag is <typeId>v<Value>[x<Value2>].

SentinelCore.BarTag(bp) additionally folds in Value2, which the Recorder's private version omitted — so TBars 6-24 and TBars 6-48 produced an identical tag and would have shared a scope. Real scopes look like:

GC.69697v6x24        // instrument . typeId v Value x Value2
GC.0v150             // 150-tick

11.2 Scope is necessary but not sufficient — hence contention detection

Scope separates GC from NQ, and GC-TBars from GC-150-tick. It cannot separate two charts that share instrument and bartype — which is exactly what was live: two GC charts, both TBars 6-24, differing only in which sensors were loaded, therefore computing different verdicts into one key.

Rather than invent a synthetic chart id, apply §10.10's principle: two publishers claiming one key is a misconfiguration to surface, never to silently permit. Each Council carries a per-instance publisher id (Council#a3f9); SetCouncilState logs once when a fresh (<5 s) entry from a different source is overwritten:

SCOPE CONTENTION: two live publishers for 'GC.69697v6x24' … consumers read whichever wrote last.

ClearCouncilScope(scope, source) lets a publisher release its own entry on teardown, so a closed chart never leaves a stale verdict and an F5 doesn't false-trip the detector against its own replacement.

Open decision: different bar types · close one · or fold the roster + Setup name into the scope (§10.4, §10.12) — which is what Phase 3 was already going to build, and is the principled answer.

11.3 The as-of guard, and the proof it was needed

SetCouncilState stamps UpdatedUtc = DateTime.UtcNow unconditionally, including while the Council replays historical bars. So the freshness gate could never distinguish a replayed verdict from a live one, and the Recorder stamped whatever the live seam happened to hold onto bars from days earlier.

Measured proof: in 20260709T002037__GC__0v150.jsonl, five COUNCIL rows with fire times spanning three days all carry conviction = 0.1541 — identical to four decimals. A freshly-computed verdict cannot do that.

Fix: CouncilState gains BarTimeUtc + IsHistorical; the Recorder records Council fires only in State.Realtime, with the IsHistorical flag as belt-and-braces. The existing corpus is unsalvageable — no field distinguishes the contaminated rows — so it gets archived at step 1.5, not repaired.

11.4 Bugs surfaced while building

#BugConsequence
1Eye_v1_1_0 throws on OnStateChange (AddDataSeries: no implementation for this BarsPeriod)it never publishes ⇒ EYE appears in ZERO of 332 verdicts. WeightEye = 1.4, the heaviest voter, has never voted
2SentinelBridge never calls SizedQuantity()Profiles.conf size= and the governor's RecommendedSize() are silently ignored
3ContractLimit rejects rather than clampsa BaseQty above the limit hard-blocks every entry rather than trading small
4164 ALERT-CRIT Ledger rows carry acct=""every NAKED POSITION alert is un-attributable
5live prop account's loss stop is advisoryit ran to −$3,230 against a −$1,500 stop, alerting eight times

Bug 1 vindicates §10.4: a crashed sensor is indistinguishable from a quiet one under fail-open abstention. The declared roster (RosterComplete, Roster 8/10 — EYE, BRK missing) would have caught it on day one.


11.5 IMPLEMENTATION LOG — 2026-07-12 (Phase 2 instrumentation SHIPPED, bridge-verified)

Everything below compiles clean against NinjaTrader's own compiler (driven headless via the cli-nt-bridge AddOn — authoritative F5-equivalent, no ghost errors) and is additive / behaviour-neutral except the Bridge arm interlock. It needs one F5 in the NinjaScript editor to hot-load (generated regions were stripped on disk during editing; NT regenerates one clean copy on F5). No type renames → saved charts/workspaces are unaffected.

Live versions after this session: SentinelCore v1.25.0 · Council v1.4.0 · ExcursionRecorder v1_4 (schema 1.3, +vote vector) + v2_0_0 · SentinelBridge v0.2.3 · CompressionBase v1.3.2. (Point-in-time snapshot; kept as the historical log. Current as of 2026-07-14: SentinelCore v1.31.0, Council v1.6.3 fusing 22 voters incl. FLUX. The live recorder is now SentinelExcursionRecorder_v2_0_0 (internal v2.1.2) — Council-only, schema-1.3 rows in Excursions\council\1.3\ + per-fire tick sidecars in council\ticks\.)

§2.1–2.3 — the decision VECTOR (DONE). CouncilState now carries Votes/VoteW/NetScore/ActiveW + the modulator context (ClockPhase/Rvol/MtfBias/LevelInPath/LevelName). A new full SetCouncilState overload carries them; every prior overload delegates with vector defaults (Core v1.24.0). The Council projects _votes→dicts at publish and caches them for the OnMarketData heartbeat republish. Both recorders emit votes/voteW/netScore/activeW + modulators into the schema-1.3 JSONL. The training set now contains the model's INPUTS — the weight fit (§5.2), not just the floor calibration (§5.1), is unblocked. One deviation from §2.2: the vestigial LabelBarrierTicks constant was not added — the recorder's ATR-scaled FirstTouchBarrier() (the §10.6 corollary's own recommendation) supersedes it.

§10.2 — EpisodeId (DONE). CouncilState.EpisodeId = "<inst>-<yyyymmdd>-<NNNN>", bumped only on a bias flip; published + recorded (episodeId) by both recorders.

§10.9–10.10 — the actor INTERLOCK (DONE, Core v1.25.0). RegisterActor/UnregisterActor/AllActors back an ActorReg registry keyed by instanceKey. The Bridge (v0.2.3) derives InstanceKey() = SentinelBridge#<scope>@<account> and its ARM button REFUSES to arm on a collision ("NAME TAKEN"), with a reference-checked release on disarm/Terminated (the re-enable race). This closes the exact hazard §10.11 targets (two actors on one scope+account = managed-position desync) — so the safety goal is met without the Arc rekey.

§10.7 — Ledger context (DONE). Ledger.Order/Action/Fill gained optional episode/instance params (additive); the Bridge stamps both on every fire. fill → episode → verdict is now a real join.

Bug #2 (SizedQuantity) — was ALREADY FIXED in Bridge v0.2.2 (2026-07-10); verified this session.

CBRK baseline → schema 1.3 (DONE, CompressionBase v1.3.2). The CBRK per-sensor baseline now writes the ATR-scaled first-touch label (barrierTicks/barsToTargetR/barsToStopR/firstTouch/ftAmbig), matching the Council corpus so the two are comparable. Writer-only; RecordExcursions still default OFF; still lands in Excursions\_baselines\cbrk\<schema>\.

⚠ CORRECTION — VolEnvelope has NO excursion writer. Earlier notes ("VolEnvelope still 1.2") are wrong: VolEnvelope_v0_2_0 records nothing; the only per-sensor baseline writer in the tree is CompressionBase (CBRK). Giving VolEnvelope first-touch baselines would be a net-new recorder, not an uplift — an open decision, not done.

DEFERRED — Arc _fleet[instanceKey] (§10.11). Not a mechanical rekey: Arc's fleet plan is instrument+strategy-scoped while instanceKey is scope+account-scoped, so a faithful change reworks Arc's config model and GTrader21's SlotLive() consult (order-adjacent). Its own focused step. The safety motive is already covered by the actor interlock.


11.6 Housekeeping — train.py fault recording (2026-07-25)

No modelling change. train.py's one silent handler (train.recorded_baseline_weights, a (TypeError, ValueError) guard around parsing recorded baseline weights) now calls lab_faults.swallow() before its pass, so a malformed weights value is recorded instead of discarded. Control flow is unchanged and the fitting path is untouched — this is listed only because this doc tracks: Sentinel/Lab/train.py and the docs audit correctly flagged the file as newer than the doc. Rationale and contract: SENTINEL_DATA_PLATFORM_SPEC.md §14.

Worth noting against §6 (the validation protocol this doc is proudest of): a silently-swallowed parse failure in a baseline-weights loader is precisely the class of defect that turns a pre-registered comparison into an accidental one, because the baseline quietly becomes something other than what was declared. Recording it costs nothing and removes that failure mode.


Changelog