32880f3 warned whenever either side of the EMA blend returned no weight
block. Both-empty is normal: a dense layer whose successor owns the weight
matrix has none by design (CNeuronBaseOCL::Init only allocates Weights when
numOutputs > 0), which is the same reason LayerLearningReport prints
NOWEIGHTS for every dense layer in these topologies.
Result on the first run with it: "5 weight block(s) could not be blended"
on CONV and 6 on LSTM/HYBRID, type 30851 = defNeuronBaseOCL - all of them
the weightless dense layers. Pure noise, and it pointed at the wrong thing.
Only an ASYMMETRY is a defect: live has a block, shadow does not. That is
the HYBRID case the file sizes showed (shadow 791,120 bytes short, exactly
the LSTM weight block plus Adam moments), and it still trips the warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every branch of the EMA shadow blend is `if(both sides return weights)
{ blend }` with no else. That silent degrade is deliberate - a partial
topology mismatch should not corrupt unrelated layers - but it also hid a
real defect for 343 eras on SP500 H1.
The HYBRID shadow's LSTM layer had never run a forward pass, so its
WeightsLSTM was still NULL and getWeightsLSTM() returned 0. The blend
skipped a 24,704-weight layer on every single era and returned true. The
only trace was on disk: the shadow .nnw is 791,120 bytes smaller than its
live net - 4 x 24,704 doubles, exactly the LSTM weight block plus its Adam
moments - while the CONV, LSTM and PAI shadows byte-match their live nets.
This matters beyond training: the shadow is the net live inference and
deployment read, so those layers are not tracking the trained model at all.
Counts skipped blocks and warns once per net, naming the layer index and
neuron type. Reporting only - the blend behaviour is unchanged, and the
underlying cause still needs a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-era `dW/W` line measured the change in each layer's weight NORM.
That statistic cannot separate "this layer only shrank under weight decay"
from "this layer moved somewhere useful" - a rotation at constant norm and
pure decay can print the same number.
It matters right now: on SP500 H1 the LSTM layers print a near-constant
~1.05%/era that exactly equals their geometric norm decay over 318 eras
(HYB lstm2 12.966 -> 0.755, monotone, never once up), while a sibling conv
oscillates around a much slower drift. Norm-change can only hint at that.
Now prints norm(d|W|% / |dW|%). Under decay alone the two are equal; any
gradient component adds in quadrature to the second, so a learning layer
shows the second clearly larger. Diagnostic only - no training behaviour
changes, fingerprint untouched.
Also removes two log lines that described machinery deleted in 397b0ea:
the plateau ladder's terminal stage still claimed "after a warm restart
AND full gamma anneal", and the CONVERGED line "across a warm restart and
a full focal-gamma anneal". Both printed on every CONV/LSTM convergence
today. Same failure mode as the oversampling line 397b0ea fixed: a log
that describes what an older version would have done is confirming
evidence for a false hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The imbalance section offered nine controls for one job. Audited against the
code, five of them did not do what their names said at the shipped defaults:
AILogitPriorStrength DEAD - Inference.mqh's post-hoc prior early-returns
whenever the adjusted loss is on, which is default.
OversampleParity DEAD in training - Training.mqh gated the replay loop
on !useLogitAdjustedLoss (correctly, citing Buda et
al. 2018). Live only in the online-learning path.
EnableMinorityReplay DEAD as replay. It survived ONLY as a focal-gamma
damper - "replay minority bars through pass-2
oversampling" was a focal-loss switch.
ConstrainReplay DEAD as a cap; it only chose damper 0.125 vs 0.25.
UseStaticPrior An exact duplicate of FreezePriorCalibration - the two
were OR'd together in the single place either is read.
So they were not five mechanisms fighting; they were one mechanism plus eight
knobs that mostly described machinery that no longer ran. That is worse than
a real conflict, because the log agreed with the names: the label-cache line
printed "reps up to 28x (90% parity) (seeding era 0's class-balance
oversampling)" on every run, describing an oversampling pass that had been
switched off. It is fixed here too - it cost this session a wrong diagnosis.
The one genuine redundancy was focal loss, running at gamma*0.125 alongside
the adjusted loss: two corrections on the same axis, the exact stacking
failure this file already cited Buda et al. for in two other places, damped
by a replay flag whose replay path was itself dead. Removed rather than
re-tuned. The plateau ladder is unaffected - its escape is the learning-rate
warm restart; the gamma anneal beside it only ever stepped toward zero.
WHAT REMAINS is logit-adjusted loss (Menon et al. 2021) plus a prior freeze:
LogitAdjustTau 0 = off; replaces the separate EnableLogitAdjusted-
Loss boolean, since a strength dial where 0 already
means off does not need an on/off switch beside it.
FreezePriorCalibration unchanged.
It is the only one of the six corrections with a consistency guarantee, and
it is consistent for exactly the balanced-error metric checkpoint selection
already ranks on - so the loss and the deploy decision optimize one thing.
The online continual-learning path keeps its own alpha-balanced focal weight,
now as constants pinned to the removed inputs' shipped defaults, so its
behaviour is unchanged. It legitimately needs its own correction:
ApplyLogitAdjustment() only runs inside a training run, so a deployed model
that was reloaded carries no logit offsets and would otherwise stream 31:1
data into itself uncorrected.
The weights-filename fingerprint is BYTE-IDENTICAL. The focal slot was a
double fed to a %d conversion and had always emitted a literal 0; the |MR:
segment is written as the constant its shipped defaults produced. Dropping
either would have re-keyed every model and forced a from-scratch retrain of
the one topology currently converged and trading.
Also removed as orphans: FOCAL_GAMMA_PRESET, MAX_OVERSAMPLE_REPLICAS,
OVERSAMPLE_PARITY_FRACTION, PLATEAU_GAMMA_STEP, and the now-unreachable
"neutralized by prior correction" diagnostic.
Both builds compile 0 errors, 0 warnings. No retrain forced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stops keyed to the recent swing extreme make a trade's risk a function of
how far the last swing happens to sit rather than of current volatility. On
a shallow pullback the swing sits close to the fill, so the stop is tight
enough to be taken out by noise on setups that then run to target - which is
what the Perceptron's signals were showing.
SL: lowest_low/highest_high -/+ mult*ATR -> entry -/+ mult*ATR
TP: TP_PREV_SWING (opposite swing) -> removed; ATR-from-entry
SL_PREV_SWING, TP_PREV_SWING -> removed from the enums
The SL anchors to `price` (the resolved entry), not to base_price: with a
pending entry those differ by the whole entry offset, and the risk Money
sizes against is entry-to-stop.
MIN_SL_ATR_MULTIPLIER 2.0 -> 0.5. That floor existed because a swing-
anchored stop could land arbitrarily close to the entry and needed a bound
unrelated to the chosen multiple. An entry-anchored stop is exactly
mult*ATR by construction and cannot collapse, so leaving it at 2.0 would
have silently overridden SL_ATR_x1 to 2*ATR - making the input a lie AND
forcing TP >= 4*ATR just to clear the default 1:2 rejection filter. The
broker's own stop level is enforced separately and precisely by
TCAdjustStops(), so this is now a pure sanity net.
Default TP_Mode TP_PREV_SWING -> TP_ATR_x3, so SL_ATR_x1 + TP_ATR_x3 gives a
realised 3:1 against the 1:2 filter. TP_ATR_x2 would sit EXACTLY on the 2.0
boundary where price-normalization rounding alone can reject the setup; the
default leaves a deliberate gap. This is the same interaction that once
rejected 100% of setups on every symbol (see TP_INTELLIGENT_BASE_RR).
Swing validity guards now reject only when the configuration actually uses a
swing - i.e. ENTRY_PREV_SWING. Previously an unsynced or thin history
rejected EVERY trade, including configurations whose levels no longer
reference a swing at all. The guards are kept, not deleted: a bad swing must
still never reach an entry price, and iLow/iHigh are no longer called with a
possibly-negative index.
TP_INTELLIGENT stays risk-relative. Now that risk is exactly mult*ATR the
risk- and ATR-relative forms coincide, but risk-relative keeps its
reward:risk guarantee exact after the floor or TCAdjustStops widens a stop.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONV and LSTM were signaling at 4eae763. Two changes I made after it each
broke one of them, and neither was caught by the metric I was reading.
CONV, same data one hour apart on SP500 H1:
19:44 window = 1 bar era 5: dir-precision 18%, 45 live fires, best bal 3.1 -> 4.5
20:46 window = 2 bars era 5: no directional calls, 0 live fires, best bal frozen at era 1
LSTM: the sequence rewrite has been live since 18:48 (the `lstm 420->64`
config line only derives that way under the new per-timestep budget) and has
been OOS recall Neutral:100% with a flat IS error in every era since, past
era 100.
Both are reverted behind a switch rather than deleted, because both
DIAGNOSES stand: a conv with a one-bar window is a 1x1 conv that cannot mix
across time, and the old LSTM really did apply one gate step to the whole
flattened input. What does not stand is shipping either on the strength of
an offline correctness proof.
CONV_RECEPTIVE_FIELD_BARS 2 -> 1 (also drops pool + conv2 via
HasSecondConvStage, restoring the
exact 4eae763 front-end)
LSTM_SEQUENCE_MODE 0 (single-timestep layer; sizing follows,
since a recurrence budgets on the
per-step width and this one does not)
Kept, because they are correct independent of the above:
- per-layer dW/W era line (18f63c7) - the instrument that should have
caught both of these in one era instead of a retrain cycle each
- CNet conv/pool sizing-cursor fix (a pool stacked on a conv would have
sized against a width window_out times too small)
- .nnw architecture guard - forces the retrain this revert needs, since
models trained since 20:46 carry a 2-bar conv tensor
- LSTM_FORGET_BIAS_INIT and both offline checks (gradcheck 2.3e-10,
flowcheck) - dormant at LSTM_SEQUENCE_MODE 0, correct when re-enabled
The lesson is in the two #define comments: a gradient check proves the math,
not that the layer trains in situ. Re-land each behind the dW/W line.
Both builds compile 0 errors, 0 warnings. Forces a CONV/LSTM/HYBRID retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds "dW/W dense1:0.412(0.31%) conv1:0.088(0.000%) ..." to the era line:
each layer's weight L2 norm and its relative change since the previous era.
Why: a frozen stage and a badly-suited architecture look identical from the
outside. Both give a flat metric and a retreat to the majority class, and
neither the loss, the accuracy nor the per-class recall can tell them apart.
This session cost two full retrain cycles guessing between them - a forget-
gate bias (a real bug, measured, but not the cause of the observed failure)
and a conv receptive field (which turned out to be a regression, not a fix).
A layer sitting at ~0.000% era after era while its neighbours move is
receiving no gradient, and no amount of retraining or hyperparameter work
will change that. A net where every layer moves and the output still
collapses is a genuine architecture or objective problem. The distinction is
one glance at the log instead of a redeploy-and-wait cycle per hypothesis.
Costs one host-side buffer read per layer per era, off the training path.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONV's convolution used window = step = one bar, which is a per-bar
projection - a 1x1 conv with a temporal receptive field of ONE BAR. It never
mixed information across time, so "convolutional" described the layer type
and nothing about what it computed. Same finding that sank HYBRID's LSTM.
Pooling was removed on 2026-07-29 for being misconfigured against the conv
output's memory layout. That removal was right; leaving the conv at a
one-bar window was not. The two belong together: the NeuroNet_DNG reference
(references\MQL5\Experts\EDL\Trajectory.mqh layers 2-5, kernels
byte-identical to ours) pairs conv(window=2, step=1, window_out=4) with
pool(window=4, step=4), and the pool only earns its place because a conv
with a real receptive field sits above it.
The input is bar-major (BufferTempData appends m_neuronsCount contiguous
features per bar), so a flat window of k*m_neuronsCount spans exactly k
bars - the receptive field needed NO kernel change. The conv output is
position-major, so window == step == window_out is a clean
max-over-channels, which is what the reference does and what the existing
pool kernels already implement correctly.
New chain at H1 defaults (420 = 20 bars x 21):
conv1 w=42 s=21 out=8 -> 19 pos x 8 = 152
pool w=8 s=8 -> 19
conv2 w=2 s=1 out=8 -> 18 pos x 8 = 144 (effective field: 3 bars)
We deliberately stop before the reference's SECOND pool: a channel pool
emits one scalar per position, so a trailing pool would hand the dense stack
18 values and force it to fan out 18 -> 64. That is a bottleneck below every
learnable layer - the same class of mistake the 2026-07-29 removal was about.
Fixes a latent sizing bug this exposed: CNet's conv/pool position cursor
tracked sliding POSITIONS, but a conv's real width is units_count *
window_out. Any pool stacked on a conv would therefore have sized against a
width window_out times too small and silently built the wrong shape. Both
branches now read the built layer's actual Neurons(), which is what the
batch-norm branch already did for the same reason.
Also closes the architecture-pinning trap: a .nnw persists the window each
conv was built with, so an existing CONV/HYBRID model would have loaded
cleanly and gone on training under the OLD architecture. The conv weight
tensor is (window+1)*window_out, so this cannot be repaired in place -
EnforceTopologyContract now detects it, reports both shapes, and retrains.
Conv chain shape is derived in one place (ConvReceptiveFieldBars /
ConvFirstStagePositions / HasSecondConvStage / ConvOutputPositions /
ConvOutputWidth) and consumed by AddConvStage, LstmFanIn and the startup
config line, so what is built and what is logged cannot drift.
Both builds compile 0 errors, 0 warnings. Forces a CONV and HYBRID retrain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sequence rewrite made LSTM/HYBRID recurrences over 20 bars, but every
weight - including the gate biases - is initialized around zero. That puts
the forget gate at sigmoid(0) = 0.5, so the cell state is halved every step:
the first bar survives into the output scaled by ~0.5^20, and the gradient
reaches it scaled by the same factor.
The layer was therefore a one-bar model wearing a 20-bar interface. A one-bar
model has no signal on this task, so the head learned the base rate and
emitted Neutral everywhere - the flat 0.34 IS error across twelve eras and
OOS recall Neutral:100% seen on SP500 H1.
Measured at the shipped H1 shapes (H=64, stepInputs=21, T=20) - influence of
bar 0 on the output relative to bar 19:
bias 0.0 -> 3.0e-05 forward, 3.3e-05 backward (dead)
bias 1.0 -> 1.2e-02 forward, 1.4e-02 backward
bias 2.0 -> 2.5e-01 forward, 2.7e-01 backward (a real 20-bar field)
This is the standard fix, not a tuned knob: Gers/Schmidhuber/Cummins (2000)
introduced the forget gate with a positive bias, and Jozefowicz/Zaremba/
Sutskever (ICML 2015) recommend a bias of 1 as a default (whence Keras'
unit_forget_bias). Both 1 and 2 are standard; the sweep picks 2 because at a
20-step window a bias of 1 still leaves the oldest bar at ~1% influence.
lstm_seq_flowcheck.cpp is added as a permanent regression check and asserts
the shipped constant keeps >=5% reach in both directions. It complements
lstm_seq_gradcheck.cpp: that one proves the BPTT is CORRECT, this one proves
it is USABLE. The gradient check passed at 2.3e-10 throughout - correct math
over a recurrence that carries nothing looks exactly like a bad architecture.
Both builds compile 0 errors, 0 warnings. Initialization only, so the .nnw
format is unchanged; LSTM and HYBRID must retrain to pick it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the gap left by 7a08197, which refused sequence mode under OpenCL. That
was defensible for a private build and not for a shipped one: the release path
includes an OpenCL laptop, and a customer with a GPU would have found LSTM and
HYBRID simply unavailable.
One launch PER TIMESTEP rather than a single kernel looping with barrier().
Every hidden unit's gates read all of h_{t-1}, OpenCL barriers only span a
work-group, and nothing here constrains how the runtime partitions the global
size - so an in-kernel loop would be correct only by luck of the partitioning.
Host-driven launches make each step an implicit global barrier: more enqueues,
correct on every device.
Backward reuses the buffers the single-timestep path leaves idle in sequence
mode - ConcatenatedGradient (4H) for gate gradients, HiddenCache (H) for dh,
Memory (2H) for dc - so BPTT costs no extra allocations. dW is zeroed once and
accumulated across steps, matching the fused DLL kernel.
Verification available on this machine has limits worth recording. The math is
the same as CPU_LSTMSeqForward/Backward, which is gradient-checked to 2.3e-10;
the kernels are syntax/type-checked offline (DirectML\opencl_seq_syntax_check.cpp,
compiled as C++ with OpenCL shims) because there is no OpenCL device or ICD
here. That check exists because a typo in Network.cl fails the WHOLE program
build, which would take the dense and conv kernels down with it - not just the
new ones. KernelCreate results are now checked and reported for these four for
the same reason; a build failure degrades to "LSTM/HYBRID unavailable on this
device" instead of an Execute error mid-training.
STILL NEEDS A RUN ON REAL OPENCL HARDWARE before release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CNeuronLSTMOCL consumed the whole flattened input in ONE gate computation and
back-propagated a single timestep, which its own class comment stated. Combined
with a conv stage whose window=step=neuronsCount gives it a receptive field of
exactly one bar, no stage in HYBRID mixed information across time - the bars
reached the dense stack as an unordered flat vector, the same thing the plain
MLP sees. That predicted the measured ranking (MLP 31.5%, CONV 32.5%, LSTM
30.6%, HYBRID 14.4%): each extra bottleneck cost accuracy and bought nothing.
The layer now unrolls m_historyBars timesteps, sharing one gate block across
them and carrying h/c forward, with real BPTT carrying dh and dc backward.
Per-step width comes from CLayerDescription::window, which CNet passes to the
new SetStepWidth() - previously dead metadata.
Consequences worth naming:
- Weight count drops from 4H(H+420+1) to 4H(H+21+1). Weight sharing is the
point of a recurrence, so ComputeLstmHiddenSize now budgets on the per-step
width; H goes 16 -> 64 at H1 defaults, and the model is still smaller.
- h/c start at zero per sample. The old buffers persisted across forward
passes, so under shuffled training each sample inherited an unrelated
sample's state.
- .nnw LSTM records are versioned (LSTM_SEQ_SAVE_TAG). The old weight block is
a different shape, so Load REFUSES pre-rewrite models rather than misreading
one and throwing off every later layer's offset. LSTM and HYBRID must retrain.
- Sequence mode has no Network.cl kernel, so it refuses the OpenCL tier loudly
instead of quietly running a different architecture there than on the DLL
tier - the two would train different models from one .cfg.
Legacy single-timestep path kept intact for step width <= 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-step entry points cannot express a sequence model. CPU_LSTMGates takes
the ENTIRE flattened input as one timestep, and CPU_LSTMGateGradient has no
parameter for dc arriving from the following step - so the recurrent gradient
path does not exist and cannot be assembled from these primitives at any call
pattern. The layer built on them is a gated dense layer that the class comment
already described honestly: "single-timestep-truncated BPTT".
Adds CPU_LSTMSeqForward / CPU_LSTMSeqBackward: the whole unrolled sequence in
one call each, weights shared across timesteps, dW accumulated over all of them
(the per-step CPU_LSTMWeightsGradient assigns rather than accumulates, so it
could not have been reused even with the dc term). Fused rather than dispatched
per step because the recurrence is sequential - T round trips would serialise T
lock/dispatch pairs for a few thousand FLOPs each.
h_{-1} and c_{-1} are zero per sample. The old layer carried its cell state
across forward passes, so under shuffled training every sample inherited the
state of an unrelated one.
DirectML gets the same math host-side (readback, compute in double, upload)
rather than HLSL: the recurrence needs a barrier per timestep, the GPU buffers
are float and BPTT accumulation is where that hurts most, and no D3D12 device
exists on this machine to test a shader against. Documented at the definition.
Verified with lstm_seq_gradcheck.cpp - central-difference check of dW and dX
against an asymmetric loss over the final hidden state. Max relative error
2.3e-10 on both, with a non-trivial gradient magnitude asserted so the check
cannot pass on an all-zero result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plateau/regression line printed balancedOosEra as the current value while
comparing against m_bestBalancedOos, which has held the SELECTION score since
a142749. Two different metrics in one sentence, so HYBRID logged "regressed
from best 14.4% to 34.0%" a hundred times - a regression to a higher number,
which is not a thing. The comparison itself was right (selectionScore, coverage
weighted, genuinely below best); only the print was wrong. 1039ad9 relabelled
these strings but missed that this site passes the wrong variable.
The startup config line had the same shape of gap: it printed the dense taper
and called itself self-verifying while the DERIVED conv and recurrent stages -
the ones that dominate CONV/LSTM/HYBRID - were invisible. It now shows the
width into and out of each front-end stage, and flags the case where the dense
stack is wider than the vector reaching it (a linear fan-out cannot recover
what the bottleneck discarded; it only adds parameters). Flagged, not silently
reshaped - that would re-key trained topologies mid-comparison.
UsesConvStage()/UsesLstmStage() replace HasConvBeforeLstm() as the primitive,
so each subclass declares its composition once and both the capacity budget and
the config line derive from it rather than restating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Hybrid 3L [HYB-9369] - learning (era 4, 12%)" leads with a fingerprint hash
that means nothing to an owner. The tag earns its place in the journal and
the State\ folders, where telling one chart's model files from another's is
the whole point - but the default panel is the commercial surface and should
not open with a debug token.
New DisplayName() strips the bracketed suffix; the two plain-language panels
(training and live/idle) use it. Logs, the VerboseMode panels and the
auto-tune line keep the full ID, so nothing needed for diagnosis is lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ResetWeights already deletes the whole model set - .nnw, .cfg, _ckpt.tmp,
.stats, _shadow.nnw - and clears both the .arrows sidecar and the drawn
chart objects. What undid it was PersistWeightsOnShutdown: detaching the EA
after a reset but before an era completed re-created a .nnw from the
freshly-built, never-run net, so the next attach loaded an era-0 stub
instead of starting clean. For LSTM/HYBRID that stub is worse than nothing -
a layer that has never run a forward pass has m_iInputs<=0, so Save omits
every LSTM buffer (see 413ff7e). Skip the save when no era completed and no
model was loaded; that is exactly the post-reset and first-attach state.
Also sweep _shadowclone.tmp, which the reset did not cover.
Separately, ComputeLstmHiddenSize budgeted every topology against the
flattened input (historyBars x neuronsCount). True for LSTM, wrong for
HYBRID, where AddConvStage runs first and the LSTM is fed the conv feature
map - historyBars x convFilterCount, 160 rather than 420 at H1 defaults.
The quadratic is dominated by the inputs term, so overstating the fan-in
2.6x cost a full ladder step (16 units where the budget affords 32). New
virtual HasConvBeforeLstm() feeds LstmFanIn(), so composition decides this
rather than an AIType check. desc.window is advisory only - CNet never
passes it to the layer - but is now truthful for the same reason.
Derived values stay out of the weights-filename fingerprint and are adopted
from the .cfg, so existing models keep their saved width; only fresh ones
pick up the corrected budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CNeuronLSTMOCL::Save early-returns when m_iInputs<=0, writing no LSTM
buffers at all - correct, since a layer that has never run a forward pass
has no weights to persist. But Load mirrored that early return BEFORE
allocating Memory (the c_prev cell state), and SetInputs - the lazy sizing
path that runs on the next feedForward - allocates the weight buffers but
never Memory. Both Init overloads allocate it unconditionally, so only the
save-then-load round trip could produce the gap.
Net effect: any net serialized before its first feedForward came back with
Memory==NULL. LSTMGates then failed on every call, short-circuiting the ||
before LSTMState could dereference the null buffer, so instead of crashing
the layer computed nothing forever. Observed on HYBRID after a
weights-reset-then-detach: all three class outputs pinned at exactly 1.000
(spread 0.0000), IS error stuck at 0.78, Buy/Sell recall 0%, and 636k
"Error of execution DirectML LSTM feedForward" lines in one 55MB journal.
The model looked like a converged Neutral collapse; it was a dead layer.
Allocate Memory in Load ahead of the early return, and harden SetInputs to
guarantee every buffer LSTMGates/LSTMState touch exists before it returns -
loudly, since the failure it replaces was indistinguishable from a healthy
net that simply never fires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the 2026-07-30 run exposed.
1. Every user-facing message still called the selection metric "balanced
accuracy". It has ranked on directional precision since a142749, so
"CONVERGED ... balanced accuracy 32.5%" was reporting a 32.5%
PRECISION as if it were macro-recall, while the same era logged an
actual balanced accuracy of 49%. Two different numbers under one
name, in the line that announces a deploy. Relabelled at every site,
including the stage-3 refusal, which still described the per-class
recall floor that stopped being the gate.
2. Precision is now bucketed by confidence tier and logged per era,
both per-tier and cumulatively from each tier upward:
| tier prec T0:19%(410)[>=28%/1204] T1:31%(520)[>=34%/794] ...
The per-tier number says whether confidence is calibrated to
correctness at all; if it does not rise T0->T3, raising the floor
buys nothing and that is the finding. The ">=" number is what a floor
would actually deliver, with its fire count, so the coverage cost is
visible in the same line. Tier weights are 25/50/75/100, so for an
AI-only config Min_Vote_Open maps straight across: 50 = ">=T1",
75 = ">=T2", 100 = ">=T3".
Bucketing happens at the existing live-fired accounting site, so it
measures exactly the population that trades - not the raw argmax.
Both builds compile 0 errors, 0 warnings. No retrain needed for either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-07-30 run caught a bug in the precision-led selection metric
within 8 eras. HYBRID made exactly ONE directional call in era 7, got it
right, scored 100% precision, and locked that in as best-ever. Nothing
can beat 100%, so the checkpoint froze on a single sample and the run
could only burn to the era cap deploying it.
The coverage floor already existed and already blocked that era from
being DEPLOYABLE - but the ranking ignored coverage entirely whenever no
era had qualified yet, which is precisely the phase where the ranking is
the only thing steering the run.
Precision is now discounted by coverage/floor, capped at 1.0. Continuous
rather than a threshold: an era at half the floor scores half its
precision, so coverage and precision both improve rank and neither can
be traded away. Above the floor the credit saturates, so ranking among
genuinely deployable eras is unchanged pure precision.
Also: the startup config line printed "tau 1.00" while every chart was
actually running the capped 0.35 - the effective value depends on the
measured class priors and is not knowable at init. Now reads
"1.00 requested"; ApplyLogitAdjustment still logs the real figure.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the derived-topology work. Three inputs removed.
AIType loses its depth suffix - AI_MLP/AI_CONV/AI_LSTM/AI_HYBRID, five
entries instead of eight. Depth is now derived from the two endpoints
the taper already has to connect (derived first-layer width, output-tied
final width) at a 2x per-layer compression target, clamped [2..5].
Asking a user to pick a layer count while the code derives the widths
those layers taper between was asking for half a decision: at 64 units
tapering to 12, four layers compress by 1.4x per step and five by 1.3x,
so the extra depth bought no abstraction. On the shipping H1/10y default
the derivation lands on 3 layers - the depth that actually won Run 2.
StudyPeriods removed. There is no case for training on less data than
the broker provides at a ~6% directional base rate; the honest
generalization read comes from the OOS holdout, not from withholding
history. Training now starts at the earliest available bar, floored by
MinTrainYear, which answers a different question (excluding dubious
pre-history) and stays.
That required closing the hazard the old code documented: the capacity
budget now MEASURES the symbol's real bar count, and a topology derived
from a measurement would widen as history downloads. Both ends are now
pinned. Every derived value left the weights-filename fingerprint -
keying a filename on a measured quantity means the EA looks for a file
that does not exist, starts from era 0 and orphans a trained model,
silently, because a missing cache is the normal first-run state. The
shape lives in the .cfg instead, where LoadAndCompare now ADOPTS the
four derived fields rather than diffing them; a mismatch there would
discard a fully-trained model over nothing the user did. Two fields
appended to the .cfg for the conv/LSTM stages, length-guarded on read
because FileReadInteger past EOF returns 0 with no error.
ForceHiddenLayers, a compile-time constant like DebuggingMode, pins
depth for diagnostic comparisons. It joins the fingerprint only when
non-zero, so forced depths get their own files - sequential comparisons
only, not simultaneous from one .ex5.
Derived shape, H1/10y defaults (21 features x 20 bars): first layer 64,
3 dense, 8 conv filters, 16 LSTM units. The LSTM block halves from
~58k to ~28k weights.
Both builds compile 0 errors, 0 warnings. Re-keys existing models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same defect the first-layer width had before 2026-07-29: both were
inputs whose defaults were fixed constants picked with no reference to
the input they sit on, which is the only thing that decides whether
either number is sane.
The conv layer is a per-bar projection - AddConvStage sets
window = step = one bar's features - so its filter count should be read
against the per-bar feature count. Sixteen filters COMPRESSED a
50-feature configuration 3x but EXPANDED a minimal 4-feature one 4x, and
the expanding case adds parameters below every learnable layer without
adding information. Now derived as half the per-bar feature count,
snapped down a power-of-two ladder.
The LSTM stage was the bigger miss. Its weight count is exactly
4*H*(H+inputs+1) (CNeuronLSTMOCL::SetInputs) and AddLstmStage feeds it
the whole flattened vector, so the shipped 32 units against a 540-wide
input is ~73k weights - more than DOUBLE the entire derived dense taper
it feeds. It was the one stage the capacity budget never covered, which
is why deriving the dense stack alone did not stop LSTM and HYBRID from
being over-parameterized. Now solved from the same
one-weight-per-in-sample-bar budget the first layer spends.
Factored EstimatedInSampleBars() out of ComputeFirstLayerWidth so all
three decisions spend one budget rather than each guessing at the
training-set size separately. Both new values are assigned alongside the
first-layer width, before the fingerprint that hashes them, and are
functions of inputs already in that hash - so they need no entry of
their own, and the same reasoning removes them from the DB config key.
Both builds compile 0 errors, 0 warnings. Re-keys existing models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate reports from one deploy.
1. CONV, LSTM and HYBRID all came back tagged [4109]. The weights
fingerprint omits the topology type on purpose - the file path already
separates it (State\CONV\ vs State\LSTM\ vs State\HYB\) and hashing a
value that is constant within a folder buys nothing while re-keying
every trained model into a forced retrain. So the files were never at
risk, but the tag could not do its one job. Prefixing the short id
makes it unique on the display side only; the hex half still greps
straight to the .nnw inside the folder the prefix names.
2. The default panel read like a training console. Six lines down to
three, each answering a question an owner actually has. The deploy
internals (best score, eras-since-best, ladder stage) were developer
diagnostics describing a recall floor that no longer decides anything,
and were already in the era-end journal line. In-sample accuracy left
the panel too: it grades the model on bars it trained on, so it always
flatters, and showing it beside the honest number invites reading the
wrong one. New compile-time DebuggingMode constant - deliberately not
an input - carries the IS/OOS pair and the resolved model path into
the journal instead. No extra Inputs row, no extra Market description
line, no user-reachable firehose.
3. Panel drag and buttons stuttered under training load, exactly as the
2026-07-26 note raising the chunk budget to 200ms warned they might.
Backed off to the documented 120ms - worst-case click latency is that
budget - and the derived topology (~292k weights to ~29k) makes the
throughput this costs far cheaper than when that note was written.
Also halved the panel redraw rate to 2.5 Hz: ChartRedraw repaints the
whole chart, so its cost scales with accumulated arrows, and 5 Hz was
the larger half of the stutter. Era-end still force-refreshes.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EnableBatchNorm and BatchNormWindow demoted from inputs to constants. Batch
norm is required, not optional: measured on identical MLP_3L topologies it
was worth +11.3 points of balanced accuracy (57.0% with, 45.7% without),
stable across 150+ and 200+ eras, and the no-BN control converged to ~5% IS
and OOS accuracy with no chart signals at all. A user cannot make a good
decision here and can easily make a ruinous one, so the choice is not
offered. BatchNormWindow goes with it - a running-statistics window in
samples has no meaningful setting a trader could reason about, and its only
other reachable state (<=1) silently disables the layer.
Kept as named constants rather than deleted: the topology builder, the
weights fingerprint and the .cfg guard all read them, and a constant keeps
those paths - and the ability to flip one for a diagnostic rebuild - intact.
Fewer knobs also means a shorter Market description and less room for a
buyer to misconfigure.
EXPERIMENTS.md records runs 2 and 3, since the MT5 logs are wiped between
runs and these measurements are what the design decisions rest on. Run 3
(12h, uncapped tau=1.0) is a write-off: zero eras out of 1,993 across the
five batch-norm charts ever called a direction on fewer than half of all
bars, at a median precision equal to the ~6.1% base rate. The damage was
present at era 1 and never recovered over 292-766 eras.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Balanced accuracy is maximized by exactly the model this system must never
deploy. Measured frontier at fixed signal strength, base rate 6.1%:
tau 0.00 -> calls 0.2% of bars at 27.3% precision, balanced 34.0%
tau 0.35 -> calls 2.0% of bars at 15.5% precision, balanced 36.3%
tau 1.00 -> calls 49.6% of bars at 6.4% precision, balanced 53.5%
It rises monotonically as the model calls MORE and is right LESS, because
two of its three terms are directional recalls that a call-everything model
drives to ~95%, while the Neutral term it sacrifices counts for only a
third. The 2026-07-29 run landed exactly there: balanced 58-64% while
calling a direction on ~100% of bars at a 5-7% win rate against a ~6% base
rate. Only the per-class recall floor stopped those deploying - a guard
doing the job the objective should have been doing - and that same guard
also rejected the genuinely useful sparse-but-precise checkpoints.
Ranking is now DIRECTIONAL PRECISION: of the bars called Buy or Sell, how
many were right. That is what a trading edge is. Two anti-degenerate floors
bracket it, since precision alone is trivially maximized by calling almost
nothing: coverage must reach a fraction of the true directional base rate
(derived, not configured - it adapts to any symbol/timeframe/label rule),
and precision must at least beat that base rate.
Against the same frontier the deploy order inverts from
tau 1.00 > 0.50 > 0.35 > 0.15 (old, worst model first)
to
tau 0.35 > 0.50 > 1.00 (new; 0.00/0.15 rejected on coverage)
Balanced accuracy is kept in the log as a diagnostic and marked as such, so
a run where the two disagree - the signature of an over-caller - is visible
at a glance. MinRecall no longer decides what ships; it now only drives the
diagnostic recall line and is a candidate for removal.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tau=1.0 inverted the collapse instead of curing it. The head is SIGMOID, so
each output is bounded to [0,1] and the widest logit gap the net can express
between two classes is CLASS_LOGIT_SCALE * (1-0) = 6. The offsets are
tau*log(prior_c), whose spread on this 30:1 imbalance is 3.42 - so tau=1.0
spent 57% of the ENTIRE expressible range on the prior correction.
The network did the only thing available to it: saturate Buy/Sell outputs to
1.0 to overcome a -3.42 training handicap. The offsets are absent at
inference, so that surplus made every bar directional. Measured across all
five still-training charts: Neutral recall 0%, directional calls on ~100% of
bars, win rate 5-7% against a ~6% base rate - no information whatsoever -
while balanced accuracy read a flattering 58-64% because two of its three
terms sat near 95%. OOS accuracy 6%.
Menon et al. assume an unbounded logit head where a 3.42 shift is negligible
against the reachable range. It is not negligible here, so the strength is
now expressed RELATIVE to the range actually available:
tau_eff = min(tau_cfg, LOGIT_ADJUST_MAX_RANGE_FRACTION * SCALE / spread)
At 20% that gives tau 0.35 on this data. Deliberately a fraction rather than
a tau ceiling: it stays correct if CLASS_LOGIT_SCALE changes, if the head
becomes unbounded, or on any symbol whose imbalance differs. The input
remains effective below the cap, so dialling it down needs no rebuild.
Simulated at a signal strength where the task is genuinely learnable, the
precision/recall frontier is monotone: tau 1.0 -> 49.6% call rate at 6.4%
precision (base rate 6.1%, i.e. worthless); tau 0.35 -> 2.0% at 15.5%;
tau 0.15 -> 0.2% at 33.3%. The capped value lands in the same regime the
pre-logit-adjustment run occupied (1-6% of bars at 20-35% win rate).
Also logs the measured priors, the spread, and whether the cap bound.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A multi-chart comparison is only valid if every chart is identical except
the axis under test, and a drifted setting was previously invisible: the
model filename carries a HASH, so two charts that should match and do not
look merely "different" with no indication of which field moved.
Each signal now logs its effective config plus the raw fingerprint string,
unconditionally (not gated on VerboseMode). The six lines diff directly, so
an accidental divergence in study period, feature set, focal gamma or
anything else feeding training shows up at startup rather than as an
unexplained result hours later.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous note claimed the pooling stage was unfixable in the topology.
That is only true of TIME-axis pooling. The NeuroNet_DNG reference - whose
conv and pool kernels are byte-identical to ours - ties the pool to the
filter count (window = step = window_out), producing a clean
non-overlapping max-over-channels emitting one value per bar. So a correct
channel-pooling configuration does exist and needs no kernel change.
The real defect was that our window/step were never tied to window_out:
3/2 against 16 filters overlapped across the filter axis and straddled bar
boundaries.
Removal still stands, for a different and narrower reason: max-over-channels
at 16 filters reduces 320 conv outputs to 20 - one scalar per bar for a
420-wide input - and the first dense layer would fan OUT 20 -> 64 instead of
funnelling. The reference could afford that at window_out=4.
Comment-only. Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FeedForwardConv emits POSITION-MAJOR output, matrix_o[out + window_out * i],
so one bar's window_out filter responses are contiguous and consecutive bars
sit window_out apart. Both pooling implementations (FeedForwardProof and
CPU_FeedForwardProof) slide FLAT over that buffer - pos = i * step, reducing
`window` CONSECUTIVE elements. On a position-major layout those neighbours
are different FILTERS of the same bar, never one filter across time.
At the shipped 3/2 the pool computed max(bar0_f0, bar0_f1, bar0_f2), then
max(bar0_f2, bar0_f3, bar0_f4), with every 8th window straddling a bar
boundary. So it collapsed unrelated feature detectors into whichever fired
hardest, passed gradient to that winner only, and halved the feature map
while doing it - all below every learnable layer, where nothing above can
recover it. The removed inputs' own labels ("3 Bars") show time-axis pooling
was the intent throughout.
Measured cost: CONV sat pinned at ~40% balanced accuracy for 510 eras with
Sell recall 0%, while plain MLPs on the same data reached 57-61%. HYBRID,
which also carried this stage, came second-worst of the batch-norm group.
Not fixable in the topology: pooling one filter across time needs a stride
of window_out BETWEEN samples within a window, which a consecutive-window
kernel cannot express at any window/step. That needs a stride-aware kernel
in Network.cl + WarriorCPU.cpp + WarriorDML.cpp and a DLL rebuild, and is
only worth doing if a conv front-end earns its place without downsampling
first - with 20 sliding positions there is little to gain by halving them.
ConvPoolWindow/ConvPoolStep and their enums are removed with it, along with
the |CP: fingerprint term added earlier today.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add
tau*log(prior_c) to each class logit inside the training gradient. Softmax
CE on adjusted logits is consistent for BALANCED error - the metric
checkpoint selection already ranks on - so the loss and the deploy decision
finally optimize the same thing.
The engine already computed a true softmax + categorical-CE gradient and
wrote it over the per-neuron sigmoid delta, so this is an offset added to
three logits in the two places that gradient is built (backProp scalar path
and backPropOCL). No backend, kernel or DLL change; the forward pass and
every inference path are untouched, which is the point - the network learns
to absorb the offset, so its raw argmax becomes the balanced-optimal
decision with nothing applied at inference.
Replaces rather than stacks. Minority replay is disabled while this is on,
and the post-hoc inference prior is forced off. Stacking is not a
theoretical worry: simulated on the measured 1118/1119/34298 distribution
in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced,
Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance -
and BOTH together score 45.4% with Neutral recall at 0%, worse than either
alone. Buda et al. 2018 predicts exactly that.
Motivation from the six-chart run: every topology took one direction to
~50% recall and abandoned the other, the direction chosen arbitrarily (the
batch-norm control went Buy 1% / Sell 42%, the inverse of the other five).
One era in 1,301 cleared the per-class recall floor.
Fingerprinted conditionally, so the converged 60.7% models on disk keep
their filenames and stay loadable as the fallback.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dividing a machine budget by the live chart count was wrong twice over.
The count is a snapshot taken when each net's pool is built, and charts
attach one at a time: five charts measured 10/6/5/4/4% of the same budget,
because the first only ever saw itself and the last saw all five. So the
earliest chart got several times the threads of the latest - skewing any
cross-topology comparison run on those charts, which is the exact thing
the setting existed to make fair. Nothing rebalanced afterwards either,
and rebalancing would mean tearing down a DLL context under a live trainer.
Both problems disappear once the answer stops depending on how many charts
are running. Each net now asks for a fixed 2 worker threads, converted to
the percentage the DLL wants from the detected core count.
Two is not a compromise: since the topology became data-derived the widest
dense layer is 64 units, so each ParallelFor has almost nothing to split
and per-dispatch overhead dominates. An MLP era cost ~66s at a wildly
oversubscribed 12 threads and ~80s at 1 thread - a 20% spread across a 12x
difference in thread count. Two per net also lands six concurrent charts
exactly on a 12-core box.
Removing the input costs nothing on the product side: a Market build has no
DLL tier at all, so it was already compiled out to a constant there and no
buyer could reach it.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dense-depth tag separates MLP_3L from MLP_4L but not two charts that
differ by anything else - the batch-norm control is 3L on both sides, so
it put two identical "Perceptron 3L" streams in the log. Any config
difference at all changes the fingerprint by construction, so it is the
only discriminator that cannot go stale as inputs are added. The 4 hex
digits match the model filename's first 4, so a log line greps straight
to its .nnw.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audited every input in Variables\Inputs.mqh against the filename hash.
Five changed the trained weights without changing the filename, so
switching any of them silently re-adopted a model trained under the old
value - the .cfg guard only catches it when the topology also differs, and
says nothing at all when it does not.
ConvPoolWindow / ConvPoolStep the Pool layer's window/step set how many
neurons it emits, resizing every dense
matrix above it
EnableMinorityReplay gates the replay loop and scales focal
gamma
OversampleParity sets the minority replica count
ConstrainReplay caps replicas and gamma
VolumeData tick vs real feeds different numbers into
the same input slot
PeriodMA / PeriodRSI seed the indicator tuner exactly as
MA_Type does - MA_Type was already hashed,
these two were not
Pool geometry is unconditional, matching how m_convFilterCount and
m_lstmHiddenSize are already treated. The replay knobs nest under
EnableMinorityReplay so turning replay off cannot re-key a model over a
parity value nothing reads. The three feature-value inputs are conditional
on the AI feature that consumes them, following the MACD/Ichimoku rule -
they also drive the classic MA/RSI votes, which are inference-only.
Re-keys existing models. Deliberate and free this cycle: the derived
first-layer width and the |BN: term already re-keyed everything, so this
is the cheapest moment it will ever cost.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MLP_3L and MLP_4L both identify as "Perceptron", so running them side by
side writes two interleaved streams of identically-prefixed lines and the
log cannot be split back apart afterwards - half a comparison run lost to
a naming collision rather than anything technical.
The dense layer count is exactly what AIType varies between them, so the
name now carries it: "Perceptron 3L", "Perceptron 4L", "Convolutional 2L".
Display only. m_id (the State\<id>\ folder) and the config fingerprint are
untouched, so no model file is re-keyed. Idempotent, because a failed init
leaves m_isInitialized false and this point can be reached twice on one
object.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two charts with the same AIType and the same retrain-affecting inputs
resolve to one .nnw/.cfg/.stats/checkpoint set. Both train and both save,
so whichever writes last wins and the other's eras vanish - silently,
because every individual file operation succeeds.
A five-chart comparison run hit this today: one chart was left at the
AIType default (HYBRID_2L), so two Hybrids shared State\HYB\...nnw and
the intended MLP_3L never ran. The only evidence anywhere in the log was
that model path appearing twice as often as the others.
The claim is a terminal-wide temporary global variable keyed on an FNV-1a
hash of the resolved filename - which is the correct lock identity, since
every retrain-affecting input is already folded into that name.
GlobalVariableTemp() gives an atomic create-if-absent, and a temporary
variable dies with the terminal, so a crash cannot leave a stale lock
blocking the next start. Within a session, a claim whose owning chart no
longer runs an expert is taken over; a chart reclaims its own entry across
a parameter change or recompile. Live charts only - tester and optimizer
agents are separate processes writing sandboxed _optcache copies, and
running one config across many agents is the point of an optimization.
Both builds compile 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CPU-DLL thread pool was sized from TargetCPULoad undivided, on the
reasoning that only one pool is ever actively computing at a time. That is
true WITHIN a chart - MQL5 gives one chart's EA a single execution thread,
and every WarriorCPU.dll entry point blocks it until its ParallelFor()
completes, so the live net, the EMA shadow and HYBRID's fused pair take
turns. It does not hold ACROSS charts, which each get their own execution
thread and really do run their pools simultaneously.
At the 100% default on a 12-core box, five training charts asked for 12
threads each: 60 threads contending for 12 cores. Measured today, dropping
to ~2 threads apiece made every chart train "super fast". This had
previously been read as one architecture being mysteriously 10x slower than
another on the CPU-DLL tier while identical on OpenCL - oversubscription of
that degree degrades superlinearly and punishes whichever model issues the
most small sequential dispatches, which fits an MLP being the victim.
TargetCPULoad now means the budget for the whole machine, divided by the
number of charts running this EA. Counting charts is the correct axis:
concurrency here is one execution thread per chart, not one per CNet, and
the within-chart division that was previously removed stays removed.
Snapshot at pool-creation time on purpose - attaching another chart later
does not resize pools that already exist, because that would mean tearing
down a DLL context underneath a live training run. Skipped entirely in the
tester/optimizer, where the terminal already pins one strategy per agent.
This matters most for buyers: a Market build compiles the input out and
pins it to 100%, so they cannot reach the setting at all and would have hit
the pathological case with no way to diagnose or fix it.
The tier log line now reports the split rather than just the result.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deriving the first layer's width left NeuronsReduction and MinNeuronsCount
behind as inputs calibrated for something that no longer exists. Against a
hand-picked 500-wide first layer "keep 30%, floor at 20" produced a genuine
funnel - 500 -> 150 -> 45. Against the derived 64 it degenerates to
64 -> 20 -> 20: the reduction factor stops mattering after one step, and
"minimum neurons per layer" silently becomes the width of every layer but
the first. Two knobs whose labels no longer describe what they do.
The taper now runs geometrically from the derived first-layer width down to
a final hidden layer sized off the output count, spread evenly over however
many layers the chosen AIType implies:
MLP_3L 64 -> 28 -> 12 -> 3 29,151 dense weights
MLP_4L 64 -> 37 -> 21 -> 12 -> 3 30,450
CONV/LSTM/HYBRID_2L 64 -> 12 -> 3 27,763
and it stays a funnel at the floor, where the old rule could not:
D1 (first layer floored to 16) 16 -> 14 -> 12 -> 3
Both inputs are removed. With the width derived there is no freedom left in
the taper, so keeping either would only let the user contradict the
derivation. The layer COUNT stays selectable, because it is bundled into
AIType alongside the conv/LSTM front-end - depth is an architecture choice,
not a data-derived quantity, and pairing them means the two cannot
contradict each other.
m_minNeuronsCount / m_neuronsReduction survive as frozen members: nothing
reads them to build a topology any more, but they hold positional slots in
the .cfg sidecar and the weights fingerprint, and changing either value
would re-key every model on disk for no behavioural reason.
The DB config fingerprint drops both terms.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InitialNeurons was an input whose only defensible value depends on two
things the user cannot see when picking from a dropdown: how wide the input
vector ended up after feature selection, and how much in-sample data the
study period actually yields. Left to a hand-picked constant it was badly
wrong - 500 units against a 420-wide input is 210,500 weights, 72% of a
292,583-weight model, against ~36,500 training bars of which only ~2,236
are directional. That is 6.6 weights per training bar, and it EXPANDS a set
of highly correlated inputs rather than compressing them.
The symptom was already in the logs and had been read as a depth problem:
the shallowest topology consistently beat the deepest (perceptron 52.7%
balanced, hybrid 41.3%). Over-parameterization predicts that ordering just
as well as covariate shift does, and only one of the two had been addressed.
ComputeFirstLayerWidth() budgets roughly one first-layer weight per
in-sample bar. Measured across the configurations in use:
M15 10y -> 256 units, 129,071 weights, 0.73 per bar
H1 10y -> 64 units, 28,727 weights, 0.65 per bar
H4 10y -> 16 units, 7,559 weights, 0.68 per bar
Two design points that matter:
- It estimates in-sample bars from the STUDY PERIOD and timeframe, not
from Bars(). What is downloaded grows over a terminal's lifetime, and a
topology that widened as history filled in would re-key its own weights
file and discard a trained model.
- The result is snapped down to a coarse power-of-two ladder, so the
estimate would have to be wrong by ~2x to change the answer.
Every field it reads is already part of the weights-filename fingerprint,
so the derived value needs no fingerprint entry of its own. The public
setter is removed - it could only have been called after construction, and
would either be ignored or silently re-key the model mid-run.
Where the data cannot support even the floor (D1 over 10 years is under
2,000 bars) it now says so and names the fixes, rather than quietly
training a model with more weights than examples.
The DB config fingerprint drops the term too, which re-keys existing
pattern databases once - correct, since a model an order of magnitude
smaller should not inherit the old one's win-rate history.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With normalization enabled a forward pass is not a pure function of its
input - it also advances the running mean/variance. ValidateCpuInference
compares the live backend net against a throwaway pure-MQL5 clone loaded
from the just-saved .nnw, so its own reference pass left the live model one
EMA step ahead of the file the clone reads. The check would then have been
measuring its own side effect, and a marginal result decides whether
buyers' backtests are allowed to run DLL-free.
Adds CNet::SetBatchNormFrozen / CNeuronBatchNormOCL::SetStatsFrozen -
classic batch-norm inference semantics, statistics used but not updated -
and freezes both sides for the duration of the comparison. Not persisted:
it is a transient evaluation mode, not model state. Default stays
adaptive, which is what the rest of the system (online continual learning)
is built around.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only bounded stage in the entire forward path was the sigmoid
classification head - every hidden stage is PRELU. That is a network with
no internal scale control, and the failure ordered exactly by depth: on
SP500 H1 the shallow perceptron held ~52% balanced accuracy while the
deepest topology sat on the 33.3% one-class floor, with the per-bar logit
spread decaying monotonically (0.45 -> 0.38 over ~200 eras) until the
evidence tilt fell under the class-prior tilt. That is the signature of
internal covariate shift, which chapter 6.1 of the reference book is
entirely about and which the NeuroNet_DNG engine addresses with a layer
this project never had.
Two mechanisms make this the right fix rather than more hyperparameter
nudging:
- it decouples WEIGHT_DECAY from the learned function (van Laarhoven
2017) - with a normalized layer downstream, decay can no longer grind
the discriminative signal away, it only rescales the effective
learning rate;
- it is the precondition for ever running an unbounded logit head here.
The 2026-07-27 attempt blew up (IS error 5.6e15) precisely because
nothing upstream constrained scale.
Implementation notes:
- CNeuronBatchNormOCL computes host-side rather than as a fourth copy of
a kernel across Network.cl + WarriorCPU.cpp + WarriorDML.cpp. The math
is elementwise O(n); this way it behaves identically on all four
compute tiers, needs no DLL rebuild, and cannot drift between
backends. Same precedent as the softmax+CCE gradient and the
per-sample loss weighting, both computed in MQL5 for that reason.
- Statistics are exponential moving, not a stored mini-batch: training
is pure online SGD, one update per sample, so there is no batch to
average over. BatchNormWindow is an EMA window length.
- gamma/beta are excluded from weight decay, deliberately - decaying
gamma toward zero is the exact pathology being fixed.
- The layer self-sizes from whatever sits below it, because a conv/pool
stage's output width is derived inside the CNet constructor and is not
knowable to the topology builder.
- Checkpoint capture/restore/blend carry gamma/beta and the running
statistics alongside the dense matrix, so the plateau ladder cannot
restore a mismatched pair.
- SeedOutputLayerBias accepted only an exact defNeuronBaseOCL as the
weight-carrying penultimate layer; with normalization enabled that is
the batch-norm layer, so the cold-start bias seed would have silently
stopped being applied.
- Refuses to build, loudly, if a topology asks for normalization with no
compute backend at all - rather than quietly training a different
architecture than the one requested.
EnableBatchNorm (default on) and BatchNormWindow (1000 samples) are
inputs so the effect can be A/B'd without a recompile. Both feed the
weights-filename fingerprint, appended conditionally so existing non-BN
configs keep their fingerprints and are not forced to retrain.
Verified: analytic gradients match finite differences to 1.5e-7 relative
over 200 random cases; a faithful port of the full forward/backward chain
collapses to the 33.3% floor by era 4 without this layer and holds
36-43% with it. Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A .nnw persists the ARCHITECTURE, not just the weights: Save writes
(int)activation per neuron and Load reads it straight back. The activation
chosen in BuildFreshTopology() therefore only ever reached a brand-new
topology - every reload restored the file's value and the next save wrote it
back out, so a wrong value could never heal while the source read as though
it were already fixed.
That is how five models kept training with an unbounded NONE classification
head for a full day after the 07-28 revert to SIGMOID. Confirmed by parsing
the binaries: 848cb42c.nnw / 2e754b43.nnw carry `act=NONE` on the 3-neuron
output layer, while a genuinely reset model of the same config carries
act=SIGMOID. In the log it showed as negative "OOS raw out" values -
impossible under sigmoid - escalating to a 4.14e13 logit spread with all
three classes numerically identical (input-independent output) and balanced
accuracy pinned on the 33.3% one-class floor.
- OutputLayerActivation() is now the single source of truth, called by both
BuildFreshTopology() and the new load-time repair, so the two can no
longer diverge the way a duplicated literal did.
- CNet::EnforceOutputActivation() re-asserts it after Load and reports the
stale value; CExpertSignalAIBase::EnforceTopologyContract() logs the
repair loudly, since weights learned under the old head may not be worth
keeping even once the head is corrected.
- Hidden layers are deliberately left alone: they legitimately differ per
stage (PRELU dense/conv, NONE pool, TANH LSTM).
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arrow cleanup existed on two paths - the panel's reset-weights, and the
topology-mismatch discard - but both are gated on there being a saved .nnw to
delete. The third case had no cleanup at all: a fresh topology at era 0 with no
weights behind it, which is what a changed config produces. A new fingerprint
makes a new m_fileName, so the previous model's files are not "discarded", they
are simply not this model's files, and nothing ever cleared the chart.
That is not cosmetic. Arrows outlive the model that drew them twice over:
1. The chart objects live in the CHART, not the sidecar, so they survive a
remove/re-add, a recompile, a restart and a fresh deploy no matter what
happens to any file on disk.
2. SaveChartSignals() rebuilds the sidecar by SCANNING the chart for
SIG_ARROW_PREFIX objects. So the first save of the fresh run adopts the
dead model's calls and writes them out under the NEW model's filename -
laundering them into the new model's history where nothing can separate
them afterwards.
Extracted the duplicated cleanup into ClearPersistedChartSignals(reason) - it
cancels the deferred restore queue, deletes m_fileName + ".arrows", clears the
namespaced chart objects and logs why - and called it from all three paths.
The call sits at the BuildFreshTopology() call site, not inside it: the genetic
tuner rebuilds a throwaway topology per candidate (AutoTune.mqh) and must never
touch the chart. All three sites run after m_fileName has its config fingerprint
appended, so they target the right sidecar.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Evidence (MQL5\Logs, SP500 H1, 2026-07-29):
Perceptron era 61 Buy 32% Sell 27% Neut 94% bal 51%
LSTM era 160 Buy 16% Sell 11% Neut 98% bal 42% (peaked 49% @ era 44)
Hybrid era 179 Buy 5% Sell 2% Neut 99% bal 35% (peaked 41%)
CONV era 228 Buy 2% Sell 4% Neut 99% bal 35% (peaked 40% @ era 122)
Every model peaks early then decays monotonically toward Neutral, and nothing
stops it: the restore-best-weights + decay-eta handler is gated on
m_bestPassedRecall, which stays false forever when no checkpoint ever clears the
per-class floor. CONV ran 228 eras with eta pinned at its 0.000300 start. The
plateau ladder cannot end such a run either (stage 3 refuses to deploy without a
recall pass, so it resets ~27 times), making it a 1000-era one-way trip.
The gate's own justification had expired. It was written when the pre-pass
tiebreak was blended-accuracy-only, where "best" really did mean "called Neutral
most confidently". The balanced-selection change replaced that with
`balancedOosEra > m_bestBalancedOos` plus an isFullyCollapsedEra exclusion, so a
Neutral-only era now scores ~33% - the FLOOR of the balanced metric - and cannot
anchor the checkpoint at all. Pre-pass "best" now means "most class-balanced so
far", which is worth defending; and isWorseEra is itself a balanced-accuracy
regression, so it cannot fire merely for trading Neutral calls for Buy/Sell.
The original concern still holds while the best-so-far IS near-collapse, so the
escape is margin-guarded: defend the checkpoint only once balanced accuracy sits
more than BALANCED_WORTH_DEFENDING_MARGIN_PCT (5pp) above the one-class floor of
100/3. Against the run above that engages for all three stuck topologies
(42.3/41.3/50.0 vs a 38.3 threshold) while a genuinely collapsed run still
explores freely.
Two inputs restored to the regime that actually produced a deploy:
- MinRecall 60 -> 40. The one successful auto-deploy in the logs (Hybrid, 28th
00:50, best balanced 66.0%) ran against a 40% floor. 60 has never been shown
reachable here - a floor above what the config can reach is the same "target
set too high" failure the surrounding comment already warns about.
- OversampleParity 60 -> 90. 60 overcorrected. Runs now START Neutral-dominant
(Buy 0-11% recall at era 1) and call Buy/Sell on 0-4% of bars against a ~6%
true base rate - under-calling, with no headroom to converge down from. The
deploying run began at Buy 90% / Sell 36%, 24% of bars called, and settled into
the floor from above. Raw over-calling is the intended starting condition; live
calls are base-rate-calibrated by AILogitPriorStrength, which is why the input's
own note says to judge over-calling by live-fired precision, not raw counts.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The simple panel showed "Buy/Sell accuracy: IS x% OOS y%" from m_cumIs*/
m_cumOos*, which are monotonic lifetime counters: never reset per era (only on
reset-weights) and restored from .stats across restarts. So the number is the
average over EVERY era ever trained. At era 217 one more era moves it by well
under a percent - it reads flat whether training is healthy or dead, and a model
that started badly and has since recovered still shows low.
That is the only number the non-verbose panel offered, so there was no way to
tell "still improving" from "stuck" while watching four charts.
Added the actual gate. The plateau ladder only auto-deploys a checkpoint that
cleared m_minDirectionalRecallPct on EVERY class (Buy AND Sell AND Neutral,
default MinRecall=60%). If nothing ever clears it, stage 3 deliberately refuses
to deploy, resets the ladder and keeps training to the era cap - correct
anti-collapse behaviour, but externally indistinguishable from being stuck.
Panel now shows:
- era against the cap, not just the era number
- the accuracy line explicitly labelled "(lifetime avg)"
- best balanced accuracy vs the per-class floor it must clear
- eras since best + ladder stage, so plateau escapes are visible
Display only - no training, selection or convergence logic touched.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
REFACTOR_NOTES.md records what was found, what was changed, what was
deliberately left alone, and the one investigation that is still open (the
MLP CPU-DLL slowdown, with the parameter counts that rule out my earlier
"largest weight matrix" explanation).
Also restores the missing opening rule on ReInitADIndicators' comment banner.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87
method bodies covering training, labelling, feature extraction, persistence,
chart drawing, online learning, the GA auto-tuner and inference, all in one
file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling
past the era loop.
Moved the bodies into Expert\AIBase\, included at the bottom of the original
after the class declaration:
Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy
Features.mqh 1093 indicator creation + per-bar input feature vector
ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup
Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy
OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator
Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild
AutoTune.mqh 275 genetic tuner (population, crossover, halving)
Inference.mqh 235 softmax, prior calibration, class priors
ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only)
This is a pure relocation - verified mechanically, not by eye: HEAD's file
reconstructed from the eight partials plus the surviving remainder is
byte-identical to HEAD, span for span (scratchpad verify_split.py). No
declaration moved, no signature changed, no code rewritten, so behaviour is
unchanged by construction.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DRY - topology construction
---------------------------
CSignalCONV and CSignalHYBRID each built the Conv+Pool front-end from scratch;
CSignalLSTM and CSignalHYBRID each built the LSTM stage from scratch. The
duplicates had already drifted: HYBRID guarded the LSTM step with
MathMax(1, historyBars/2), CSignalLSTM divided unguarded, so a historyBars of 1
gave two different steps for what is documented as the same layer.
Extracted AddConvPoolStage() and AddLstmStage() onto CExpertSignalAIBase. The
three overrides are now compositions:
CONV = AddConvPoolStage
LSTM = AddLstmStage
HYBRID = AddConvPoolStage && AddLstmStage
HYBRID's "matches the standalone CONV front-end exactly, then adds LSTM" is
enforced by construction instead of by comment. Took the guarded step for both.
Also fixed a descriptor leak the duplicates shared: on a failed topology.Add()
the CLayerDescription was neither owned by the array nor deleted.
Dead code
---------
- CNet::SaveCheckpoint / CNet::LoadCheckpoint (123 lines). Superseded by the
in-memory CaptureWeights/RestoreWeights pair; Network.mqh:1312 already said so
("This replaces the file-based SaveCheckpoint/LoadCheckpoint"). Zero call
sites - every remaining mention was a comment. The five comments that
referenced them have been reworded rather than left dangling.
- CExpertSignalCustom::CheckForDuplicateTrade / FindLastTradeIndex /
UpdateTradeStatusAndExit: declared, never defined anywhere, never called.
They only made it look as though duplicate-trade detection existed.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FileOpen(FILE_WRITE) truncates its target on open. CNet::Save already staged
the .nnw through a temp file + rename for that reason, but the three sidecars
written beside it did not:
.stats ExpertSignalAIBase.mqh:5918
.arrows ExpertSignalAIBase.mqh:6224
.cfg ExpertSignalAIBase.mqh:7329
Two defects followed.
1. An interrupted write published a truncated sidecar. For .cfg that is the
worst case: LoadAndCompareTopologyConfiguration() reads a short file as a
mismatch, which discards the trained model and restarts from era 0.
2. Windows file sharing is a mutual contract - a writer opened with no
FILE_SHARE_* blocks every concurrent open regardless of the reader's flags.
All three read paths carry FILE_SHARE_READ|FILE_SHARE_WRITE specifically so
a tester agent can read them while a live chart runs; an exclusive writer on
the same path defeated that.
Extracted CNet::Save's proven pattern into System\AtomicFile.mqh
(AtomicWriteBegin/AtomicWriteEnd) and routed all four writers through it. This
also encodes the FileMove gotcha once instead of per call site: the destination
location comes from FILE_COMMON inside the 4th arg, NOT inherited from the
source, and getting it wrong moves the file to the wrong sandbox silently.
Also fixed while in these functions:
- SaveTopologyConfiguration had 13 copy-pasted 6-line error blocks that each
returned WITHOUT FileClose(handle), leaking the handle on every write
failure. Collapsed to one ok-chain that closes exactly once. The on-disk
field order and types are unchanged (asserted during the rewrite) so existing
.cfg files still load.
- SaveChartSignals documented that pruning runs only after a successful write
("a failed write above leaves both the file AND the chart untouched") but
never checked any write result, so a partial write still deleted the chart
objects. Results are checked now, making the existing comment true.
Compiles 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DFA (Direct Feedback Alignment) option was never a correct implementation:
it deterministically flipped the sign of half of all gradients based on
connection index parity, causing permanent gradient ascent for those weights
and guaranteed divergence. The backward pass was also incompatible with the
OpenCL/DirectML neuron model (layer.Total() == 1). This change removes all DFA
logic, including the enum value and `DfaFeedbackSignal` method, and replaces it
with plain gradient descent in all momentum update kernels. The `optimizer`
kernel argument is retained for binary compatibility but is no longer used.
The DFA optimizer was never real Direct Feedback Alignment — it flipped
gradient signs for half of each weight tensor deterministically, causing
permanent gradient ascent and divergence. Its backward pass was also
incompatible with the OpenCL/DirectML layer model. Remove the enum
entry, all related logic in the OpenCL kernel weight update functions,
and the `DfaFeedbackSignal` method. The `optimizer` kernel argument is
retained (unused) to preserve binary compatibility with existing DLLs.