Skip to content

Commit f5257ef

Browse files
fix(neural): wire LearningScheduler→force_cycle + RBF restore at init (#275)
## Root cause (issue #274, epic #273) Ground-truth audit found the 8 neural organs were **wired and consumed but UNFED**: `Hypatia.LearningScheduler` (supervised, 5-min poll) ingested outcomes but never called `Hypatia.Neural.Coordinator.force_cycle/0`; `Coordinator.init/1` never called `Persistence.load_all/0` (zero callers). Networks ran on init-defaults forever and `pattern_analyzer` defensively ignored their untrained output — the root cause of the token-cost/scattergun symptom. ## Change - **`learning_scheduler.ex`** — `run_learning_cycle` now triggers the Neural Coordinator cycle (N6) *after* ingesting outcomes, so ESN/RBF train on accumulated trajectories every poll (~5s after boot, then every 5 min). Guarded + non-fatal. - **`neural/coordinator.ex`** — `init/1` best-effort restores the RBF network from persisted state (the one cleanly-invertible learned network; trust rebuilt per cycle, MoE/ESN/LSM train per cycle, ESN/LSM persistence is summary-only by design). Safe fallback to defaults on any mismatch — never crashes the supervisor. ## Validation Training data was already sufficient (`recipe-shell-quote-vars` 166-pt series ≥60; `registry.json` 9 patterns ≥5) — only the trigger was missing. Targeted tests green: neural (28), training_pipeline, cross_repo_learning (11). Compiles cleanly. ## Out of scope / follow-ups - Pre-existing `--warnings-as-errors` drift (`prover_recommender.ex:123` unused default arg; `vcl/client.ex:133` ungrouped `handle_call/3`) — unrelated, not touched. - Lossless ESN/LSM persistence (currently summary-only, so reservoir not restored across restarts; retrains on first cycle) — tracked follow-up. Refs #274 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 19fb955 commit f5257ef

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

lib/learning_scheduler.ex

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ defmodule Hypatia.LearningScheduler do
117117
# echidnabot immediately influence the next prover hint recommendation.
118118
proof_model_updated = retrain_prover_recommender()
119119

120+
# N6: Trigger the Neural Coordinator's learning cycle so the organs
121+
# (ESN/RBF) actually train on the outcomes just ingested. Without this
122+
# the Coordinator's networks run on init-defaults forever and
123+
# pattern_analyzer defensively ignores their (untrained) output -- the
124+
# root cause of the token-cost/scattergun symptom (issue #274).
125+
neural_cycled = trigger_neural_cycle()
126+
120127
now = DateTime.utc_now()
121128
total = outcomes_count + fleet_outcomes_count
122129

@@ -140,6 +147,13 @@ defmodule Hypatia.LearningScheduler do
140147
Logger.info("LearningScheduler: prover recommender retrained from VeriSimDB proof_attempts")
141148
end
142149

150+
if neural_cycled do
151+
Logger.info(
152+
"LearningScheduler: neural learning cycle triggered " <>
153+
"(ESN/RBF train on #{total} new + accumulated outcomes)"
154+
)
155+
end
156+
143157
%{
144158
state
145159
| last_run: now,
@@ -197,6 +211,29 @@ defmodule Hypatia.LearningScheduler do
197211
end
198212
end
199213

214+
# --- N6: Neural Coordinator learning-cycle trigger -------------------
215+
216+
# Casts :force_cycle to the Neural Coordinator so it trains ESN on the
217+
# accumulated confidence trajectories and RBF on the pattern registry,
218+
# then persists all neural states. Cast is fire-and-forget (training
219+
# runs inside the Coordinator process); we only confirm the process is
220+
# alive. Non-fatal: the scheduler must keep running even if the neural
221+
# layer is down.
222+
defp trigger_neural_cycle do
223+
case Process.whereis(Hypatia.Neural.Coordinator) do
224+
nil ->
225+
false
226+
227+
_pid ->
228+
Hypatia.Neural.Coordinator.force_cycle()
229+
true
230+
end
231+
rescue
232+
e ->
233+
Logger.warning("LearningScheduler: neural cycle trigger failed: #{inspect(e)}")
234+
false
235+
end
236+
200237
# --- N4: Strategy-shift detection + re-queueing ----------------------
201238

202239
# Runs StrategyDrift.check_all_shifts/1 on each tick. For each shift

lib/neural/coordinator.ex

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,22 +87,73 @@ defmodule Hypatia.Neural.Coordinator do
8787

8888
@impl true
8989
def init(_opts) do
90+
persisted = safe_load_persisted()
91+
rbf = hydrate_rbf(RadialNeuralNetwork.init(), persisted)
92+
9093
state = %__MODULE__{
9194
trust_graph: GraphOfTrust.build(),
9295
moe: MixtureOfExperts.init(),
9396
lsm: LiquidStateMachine.init(),
9497
esn: EchoStateNetwork.init(),
95-
rbf: RadialNeuralNetwork.init()
98+
rbf: rbf
9699
}
97100

98101
Logger.info(
99102
"Neural Coordinator initialised: blackboard architecture, " <>
100-
"#{@network_count} networks, #{@phase_count} phases"
103+
"#{@network_count} networks, #{@phase_count} phases" <>
104+
rbf_restore_note(rbf)
101105
)
102106

103107
{:ok, state}
104108
end
105109

110+
# Best-effort load of persisted neural state. Never raises: a missing or
111+
# malformed store must not crash the supervisor -- networks fall back to
112+
# init-defaults and are retrained by the first LearningScheduler cycle.
113+
defp safe_load_persisted do
114+
Hypatia.Neural.Persistence.load_all()
115+
rescue
116+
e ->
117+
Logger.warning(
118+
"Neural Coordinator: persisted-state load failed (#{inspect(e)}); using defaults"
119+
)
120+
121+
%{}
122+
end
123+
124+
# RBF is the only network restored at init: it is genuinely learned,
125+
# plainly serialisable (Persistence.save_rbf/1 is a 1:1 of the struct),
126+
# and otherwise unusable until a full training cycle runs. trust is
127+
# rebuilt from source every cycle; MoE/ESN/LSM are continuously /
128+
# per-cycle trained (ESN/LSM persistence is summary-only by design).
129+
defp hydrate_rbf(default_rbf, %{rbf: {:ok, data}}) when is_map(data) do
130+
centers = Map.get(data, "centers", [])
131+
weights = Map.get(data, "output_weights", [])
132+
133+
if data["trained"] == true and is_list(centers) and centers != [] and
134+
is_list(weights) and weights != [] do
135+
%{
136+
default_rbf
137+
| centers: centers,
138+
widths: Map.get(data, "widths", []),
139+
output_weights: weights,
140+
num_inputs: Map.get(data, "num_inputs", default_rbf.num_inputs),
141+
num_centers: Map.get(data, "num_centers", default_rbf.num_centers),
142+
trained: true,
143+
training_stats: Map.get(data, "training_stats", %{})
144+
}
145+
else
146+
default_rbf
147+
end
148+
rescue
149+
_ -> default_rbf
150+
end
151+
152+
defp hydrate_rbf(default_rbf, _), do: default_rbf
153+
154+
defp rbf_restore_note(%{trained: true}), do: " (RBF restored from persisted state)"
155+
defp rbf_restore_note(_), do: " (RBF cold; trains on first learning cycle)"
156+
106157
# ── Public API ──────────────────────────────────────────
107158

108159
@doc """

0 commit comments

Comments
 (0)