13 KiB
Refactor + audit — branch refactor/solid-dry
Baseline before any change: 0 errors, 0 warnings (commit 4f28165).
Every commit on this branch compiles 0/0 independently and was verified before
being committed.
Compiling from C:\Users\admin\Documents\Workspaces\Warrior_EA still fails with
error 313: invalid resource path 'Network.cl'; the build is done by copying the
tree into MQL5\Experts\ first, per the documented workaround.
A. Real bugs found and fixed
A1. Non-atomic sidecar writes — .stats, .arrows, .cfg
CNet::Save staged the .nnw through a temp file + rename because
FileOpen(FILE_WRITE) truncates on open. The three sidecars written beside it
did not get that treatment.
- An interrupted write published a truncated sidecar. Worst case is
.cfg:LoadAndCompareTopologyConfiguration()reads a short file as a mismatch, which discards the trained model and restarts from era 0. - The reader-side share flags were inert. Windows 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 carryFILE_SHARE_READ|FILE_SHARE_WRITEprecisely so a tester agent can read them while a live chart runs; an exclusive writer on the same path defeated that.
Fixed by extracting CNet::Save's pattern into System\AtomicFile.mqh
(AtomicWriteBegin / AtomicWriteEnd) and routing all four writers through it.
That also encodes the FileMove trap once: the destination location comes
from FILE_COMMON inside the 4th argument and is not inherited from the
source — getting it wrong silently moves the file to the wrong sandbox.
A2. Handle leak on every .cfg write-error path
SaveTopologyConfiguration had 13 copy-pasted 6-line error blocks that each
return false without FileClose(handle). Collapsed into one ok-chain that
closes exactly once. The on-disk field order and types are unchanged — asserted
mechanically during the rewrite — so existing .cfg files still load.
A3. SaveChartSignals documented a guarantee it did not provide
The comment said pruning runs only after a successful write ("a failed write above leaves both the file AND the chart untouched"), but no write result was ever checked, so a partial write still deleted the chart objects and lost those arrows in both places. Results are checked now.
A4. Topology drift between LSTM and HYBRID
CSignalHYBRID guarded the LSTM step with MathMax(1, historyBars/2);
CSignalLSTM divided unguarded. A historyBars of 1 gave two different steps
for what the comments describe as the same layer. Unified on the guarded form.
A5. Descriptor leak in topology construction
On a failed topology.Add() the CLayerDescription was neither owned by the
array nor deleted. Fixed in the extracted stages.
B. Dead code removed
CNet::SaveCheckpoint/CNet::LoadCheckpoint(123 lines). Superseded by the in-memoryCaptureWeights/RestoreWeightspair;Network.mqh:1312already said so. Zero call sites — every remaining mention was a comment. The five comments that referenced them were 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.
Not removed, deliberately
TCIndexOkand friends inSystem\TradeChecks.mqhare an intentional guard library, documented against MQL5 runtime error codes. Unused entries are API.getPrevOutIndexis one member of a symmetricget*Index()accessor family.- Stale worktree
.claude/worktrees/agent-abfbb5952bdcac100+ branchworktree-agent-abfbb5952bdcac100: fully merged intomain, zero unique commits. Removal was blocked by the permission classifier (forced delete), so it is left for you:git worktree remove .claude/worktrees/agent-abfbb5952bdcac100 --force git branch -D worktree-agent-abfbb5952bdcac100
Checked and clean
A scan for new-into-local with early returns flagged 17 candidates; all were
inspected and all are correct — every failure path in the neuron-construction
switch deletes both the neuron and the partially built layer. Allocation
discipline in this codebase is good. Likewise, every
FileOpen(...FILE_READ...) already carried the required share flags: zero
violations of that project rule.
C. Structure — CExpertSignalAIBase split by responsibility
The class was 8216 lines: declaration plus 87 method bodies covering training,
labelling, features, persistence, chart drawing, online learning, the GA tuner
and inference, all in one file. Train() alone is 1492 lines.
Bodies moved to Expert\AIBase\, included at the bottom of the original after
the class declaration:
| file | lines | holds |
|---|---|---|
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 |
This was verified to be a pure relocation, mechanically rather than by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span. No declaration moved, no signature changed, no code rewritten — behaviour is unchanged by construction.
Topologies are now compositions
AddConvPoolStage() / AddLstmStage() on the base class; the three overrides
became:
CONV = AddConvPoolStage
LSTM = AddLstmStage
HYBRID = AddConvPoolStage && AddLstmStage
HYBRID's "matches the standalone CONV front-end exactly, then adds LSTM" is now enforced by construction instead of by comment — which is what let A4 drift in the first place.
D. The MLP-slowness investigation — correcting what I told you
I previously said MLP is slowest because its first dense layer is the largest
weight matrix. The arithmetic does not support that as an explanation for a
10× gap. Parameter counts at the shipping defaults (ind_Periods=20,
21 features → input 420, InitialNeurons=500, RF_70, MinNeurons=20,
ConvFilters=16, LstmHidden=32, pool 3/2):
| topology | first dense | total weights |
|---|---|---|
| MLP 3 dense | 420→500 = 210,500 | ~292,600 |
| MLP 4 dense | 420→500 = 210,500 | ~293,400 |
| CONV 2 dense | 160→500 = 80,000 | ~156,000 |
| LSTM 2 dense | 33→500 = 16,500 | ~150,100 |
- MLP_3L and MLP_4L are within 0.3% of each other. The 4th dense layer is 45→20 (900 weights) and it shrinks the output layer's fan-in. Nothing about topology can make 3L 10× slower than 4L.
- MLP vs CONV/LSTM is ~2×, not 10×.
Your two observations together (OpenCL: all similar; CPU DLL: MLP 10× slower) point at CPU DLL dispatch, not network shape. On OpenCL these matrices are far too small to saturate the device, so every topology is dispatch-latency-bound and should look identical — which is exactly what you see. The CPU tier is the only one where real per-weight throughput shows, and there the gap is ~5× larger than the weights justify.
Not diagnosed. This needs profiling, not more static reading. Concrete next
step: log wall-clock per feedForward/backProp call per layer index on the
CPU tier, MLP_3L vs MLP_4L. If one layer transition dominates, it is a
dispatch/threading bug in WarriorCPU.cpp. If cost is spread evenly, suspect
thread-pool oversubscription — SetCpuLoadPercent is applied per network,
and a run builds several (main + EMA shadow + sim/self-check clones).
One relevant note found while checking: AIType selects exactly one
architecture (EnablePAI/CONV/LSTM/HYBRID are mutually exclusive, derived from
it at Warrior_EA.mq5:805-808), so a four-topology comparison is four EA
instances, each with its own CPU-DLL thread pool on the same box.
E. Still to do (not done here)
- Rebuild both DLLs (
build.bat/build_cpu.bat). The.cppsign-flip removal from the previous session requires it, and that rebuild also picks up the uncommitted D3D12 barrier fix that clears the DirectML LSTMfeedForwarderrors. - Redeploy the
.ex5and retrain. - The MLP CPU-DLL profiling in §D.
2026-07-29 (later): root cause of the collapse, and the architectural gap
F. A .nnw pins the ARCHITECTURE, not just the weights — fixed in 1aa7df9
CNeuronBaseOCL::Save writes (int)activation per neuron; Load reads it
straight back. So the activation chosen in BuildFreshTopology() only ever
reached a brand-new topology — every reload restored the file's value and
the next save wrote it back out. A wrong value could never heal on its own,
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 directly (parser recipe is in the memory note):
848cb42c.nnw era=5 layer 4: BaseOCL act=NONE out=3 <- was training
2e754b43.nnw era=5 layer 5: BaseOCL act=NONE out=3 <- was training
9d16f2c6.nnw era=0 layer 4: BaseOCL act=SIGMOID out=3 <- genuinely reset
In the log it showed as negative OOS raw out values — impossible under a
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.
Second half of the trap: reset-weights only cleans the current fingerprint.
Changing any fingerprinted input moves the filename, and the EA then adopts
whatever stale .nnw already sits at the new name — resurrecting an obsolete
architecture the reset had just eliminated. Reset after an input change,
never before.
Fix: OutputLayerActivation() is now the single source of truth, called by both
BuildFreshTopology() and a new load-time repair that re-asserts it and logs
loudly when the file disagreed.
G. Batch normalization — 30206cb, 65dc1bd
Audit against references/MQL5/Experts/NeuroNet_DNG/NeuroNet.mqh and
references/neuronetworksbook.pdf (extract with pdftotext -layout) found two
genuine gaps, and they turn out to be the same gap:
- No inter-layer normalization (book ch. 6.1, reference class
CNeuronBatchNormOCL). Inputs are ATR-normalized thoroughly, but every hidden stage is PRELU — the sigmoid head was the only bounded stage in the forward path. The observed collapse ordered exactly by depth, which is the signature of internal covariate shift. - No dedicated SoftMax layer. The reference runs
Dense(None) -> CNeuronSoftMaxOCL -> CCEwith the full Jacobian; this engine runsDense(SIGMOID) -> x6 -> softmaxwith the gradient short-circuited to(target - softmax). Defensible as an anti-saturation heuristic, but it caps expressible logits at [0,6] and forces saturation to reach confidence.
They are linked: the 07-27 attempt to unbound the head failed because nothing
upstream constrained scale. Batch norm before the head is the precondition for
ever retrying that (and would also need CLASS_LOGIT_SCALE -> 1.0 and
BIAS_MAGNITUDE -> ~0.5).
Implemented as AI/NeuronBatchNorm.mqh, host-side rather than a fourth copy of
a kernel across Network.cl + WarriorCPU.cpp + WarriorDML.cpp — the math is
elementwise O(n), so this way all four tiers behave identically, no DLL rebuild
is needed, and the backends cannot drift apart. Statistics are exponential
moving (training is pure online SGD; there is no mini-batch). gamma/beta are
excluded from weight decay.
Verified without a live run: analytic gradients match finite differences to 1.5e-7 relative over 200 random cases, and a faithful Python port of the whole forward/backward chain collapses to the 33.3% floor by era 4 without the layer and holds 36-43% with it.
Do not port the reference's batch-norm kernels verbatim if that is ever
revisited: UpdateBatchOptionsAdam reads the momentum slots (+5..6) where it
needs the second-momentum slots (+7..8), and carries the same sign-agreement
gate this project already removed as a downward ratchet.