From cb7fe88875f0c5e645ec3b58d89cdfe6f54e5a80 Mon Sep 17 00:00:00 2001 From: joelteply Date: Sun, 1 Nov 2026 18:11:45 -0600 Subject: [PATCH 1/2] server: learn a past example: /learn's messages form serves its prompt through the ordinary completion path first, then steps A reviewed correction settles minutes or hours after its turn, when her slot no longer holds that prompt, and /learn refused it ("serve the prompt again, then learn"); the dream had no way to learn a past example (continuum S4 (f), card 13b76412; Fable's finding (f) on the S4 map). - The messages form of /learn now becomes a completion of the served prompt, one token, carrying the template-rendered reply as its learn and marked learn_only. update_slots serves it as it serves anything (the slot's own admission, the prompt cache, its checkpoints), and learn_if_asked steps as it finishes; send_final_response returns the learn outcome as its whole reply. When the slot still holds the prompt the cache makes this free; when not, it is one prefill. One prefill path, one learn path; the slot is the caller's choice (whose cached prompt it replaces). The token forms (reply_tokens / reply after n_prefix), which carry no prompt, keep requiring the slot to hold it. - The lost-reply binding (#88) is unchanged: the step still names its execution, base, engine and input. Tests (IntelMac CPU, Qwen2.5-0.5B, --jinja, fresh servers): learn-every-format.py --past-example serves an unrelated prompt on the slot, then learns a conversation it does not hold (step 1, 11 labels, loss 2.093), then learns it again with the whole 37-token prompt kept from cache (step 2, loss 1.299); --template-replies (text and tool-call replies learn, the reasoning reply refused as expected) still passes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- tools/server/server-context.cpp | 39 ++++++++++++++++++++++++ tools/server/server-task.h | 2 ++ tools/server/tests/learn-every-format.py | 19 ++++++++++++ 3 files changed, 60 insertions(+) diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index 15aaf35e55a..f790f9a2bcc 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -2429,6 +2429,18 @@ struct server_context_impl { } void send_final_response(server_slot & slot) { + if (slot.task->params.learn_only) { + // a /learn that served its prompt first: the learn outcome is its whole reply + auto res = std::make_unique(); + res->id = slot.task->id; + if (slot.learned) { + res->outcome = *slot.learned; + } else { + res->outcome.failure = "the served prompt finished without its learn step"; + } + queue_results.send(std::move(res)); + return; + } auto res = std::make_unique(); res->id = slot.task->id; @@ -6514,6 +6526,33 @@ void server_routes::init_routes() { return res; } task.learn.reply.assign(tokens.begin() + prefix, tokens.end()); + // A past example (S4 (f)): the slot may no longer hold this prompt. Serve it first + // through the ordinary completion path (prompt cache, checkpoints, the slot's own + // admission), one token, and learn as it finishes: when the slot still holds it, the + // cache makes that free; when it does not, it is one prefill. One prefill path, one + // learn path. The slot is the caller's choice: whose cached prompt it replaces. + server_task serve(SERVER_TASK_TYPE_COMPLETION); + serve.tokens = server_tokens(task.learn.prompt, false); + serve.params = server_schema::eval_llama_cmpl_schema(ctx_server.vocab, params, meta->logit_bias_eog, + json { { "n_predict", 1 }, { "temperature", 0.0 }, { "cache_prompt", true } }); + serve.params.learn = task.learn; + serve.params.learn_only = true; + serve.params.res_type = TASK_RESPONSE_TYPE_NONE; + serve.id_slot = task.learn.id_slot; + auto & rd = res->rd; + serve.id = rd.get_new_id(); + rd.post_task(std::move(serve)); + auto result = rd.next(req.should_stop); + if (!result) { + GGML_ASSERT(req.should_stop()); + return res; + } + if (result->is_error()) { + res->error(result->to_json()); + return res; + } + res->ok(result->to_json()); + return res; } else if (body.contains("reply_tokens")) { task.learn.reply = body.at("reply_tokens").get>(); } else { diff --git a/tools/server/server-task.h b/tools/server/server-task.h index 365ba0433ba..401a14d15ee 100644 --- a/tools/server/server-task.h +++ b/tools/server/server-task.h @@ -183,6 +183,8 @@ struct task_params { // learn a reply on this request's slot when it finishes, before the slot is released (no other // request can take the slot in between): POST /learn's work, atomic with the turn std::optional learn; + // this completion exists only to serve its prompt for `learn`: its reply is the learn outcome + bool learn_only = false; }; // struct for tracking the state of a task (e.g., for streaming) diff --git a/tools/server/tests/learn-every-format.py b/tools/server/tests/learn-every-format.py index a1d9711ef32..4d346e91001 100755 --- a/tools/server/tests/learn-every-format.py +++ b/tools/server/tests/learn-every-format.py @@ -116,6 +116,25 @@ def landed(lora, outcome): "next": nxt["step"], "stale_generation": stale_gen["failure"], "resumed": resumed["step"]})) sys.exit(0) +# A past example (S4 (f); needs --jinja): learn a conversation the slot no longer holds. The engine +# serves its prompt first through the ordinary completion path, then steps; a second learn of a +# conversation the slot now holds reuses the cached prompt. +if "--past-example" in sys.argv: + raw("/completion", {"prompt": "Something unrelated entirely: the weather in Lisbon", "id_slot": 0, "n_predict": 1, "temperature": 0}) + def lesson(lora, messages): + step = fenced(lora) + return {"messages": messages, "id_slot": 0, "lora_id": lora, "lr": step["lr"], "execution": step["execution"], + "base_step": step["base_step"], "engine": ENGINE, "input": step["input"], + "chat_template_kwargs": {"enable_thinking": False}} + past = [{"role": "user", "content": "Which city is the capital of Kansas?"}, + {"role": "assistant", "content": "Topeka is the capital of Kansas."}] + first = landed(sid, json.loads(raw("/learn", lesson(sid, past)))) + assert first["success"] and first["n_labels"] > 1 and first["step"] == 1, first + again = landed(sid, json.loads(raw("/learn", lesson(sid, past)))) + assert again["success"] and again["step"] == 2 and again["loss"] < first["loss"], (first, again) + print(json.dumps({"past_example": first, "again_cached": again})) + sys.exit(0) + # Two minds on one serving context: each shadow keeps its own optimizer while the other learns in # between (needs shadow-05.gguf and a copy of it, shadow-06.gguf, in --train-dir). if "--two-minds" in sys.argv: From d778756354b6fd2f4d64c9c27b8242656b850cc4 Mon Sep 17 00:00:00 2001 From: joelteply Date: Sat, 7 Nov 2026 13:09:25 -0600 Subject: [PATCH 2/2] learn: a past example is served on her slot under her stable set, never the server-wide default (Fable on #89) The messages-form /learn built its serve task from {n_predict, temperature, cache_prompt} only, so params.lora was empty and resolved to the server-wide adapter set: a mind with a stable gene learned her past example against the wrong model, and with id_slot defaulting to -1 the slot picker could take another mind's idle slot and evict her cached prefix. Now the body's "lora" (her stable set, as a completion sends it) is forwarded into the serve params; a messages-form learn without it is refused by name whenever any adapter besides the shadow serves at a non-zero scale (read from the set the main loop publishes, never params_base on the HTTP thread); and id_slot is required. Test: learn-every-format.py --past-example-stable-set. Her gene is a real snapshot (a shadow trained two steps, then /lora-adapters/snapshot; a fresh shadow is an identity adapter and would prove nothing). A missing slot is refused; the learn under her gene has a different loss (15.82) than one without it (2.09), so her set reaches the serve; once the gene serves at a non-zero scale, a learn naming no set is refused. --past-example passes unchanged (CPU, Qwen2.5-0.5B, --no-repack). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FRQWzgSfo79JtnwZywHKrE --- tools/server/server-context.cpp | 29 +++++++++++++++-- tools/server/tests/learn-every-format.py | 41 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index f790f9a2bcc..ac91f195374 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -6462,6 +6462,28 @@ void server_routes::init_routes() { task.learn.engine = json_value(body, "engine", std::string()); task.learn.input = json_value(body, "input", std::string()); if (body.contains("messages")) { + // A past example is served on HER slot under HER stable set (ONE-RESIDENT §61, §71, §227): + // the slot picker would otherwise take another mind's idle slot and evict her cached prefix, + // and the server-wide default set would learn a correction against the wrong model (Fable + // on fork #89). Her slot is required; her set is required whenever the server holds any + // non-zero adapter besides the shadow being trained. + if (task.learn.id_slot < 0) { + res->error(format_error_response("a messages-form learn names her slot (\"id_slot\"): the slot whose cached prompt it serves", + ERROR_TYPE_INVALID_REQUEST)); + return res; + } + if (!body.contains("lora")) { + // the set the main loop published (this is an HTTP thread: never params_base itself) + for (const auto & adapter : ctx_server.lora_report_snapshot()) { + const int32_t id = adapter.value("id", -1); + if (id != task.learn.lora_id && adapter.value("scale", 0.0f) != 0.0f) { + res->error(format_error_response("a messages-form learn names her stable adapter set (\"lora\", as a completion " + "sends it): adapter " + std::to_string(id) + " serves at a non-zero scale here", + ERROR_TYPE_INVALID_REQUEST)); + return res; + } + } + } // the conversation she was served, its last message her reply (or the correction). The // served prompt is every earlier message through the SAME parser and template arguments // /v1/chat/completions used (chat_template_kwargs included: a thinking template renders a @@ -6533,8 +6555,11 @@ void server_routes::init_routes() { // learn path. The slot is the caller's choice: whose cached prompt it replaces. server_task serve(SERVER_TASK_TYPE_COMPLETION); serve.tokens = server_tokens(task.learn.prompt, false); - serve.params = server_schema::eval_llama_cmpl_schema(ctx_server.vocab, params, meta->logit_bias_eog, - json { { "n_predict", 1 }, { "temperature", 0.0 }, { "cache_prompt", true } }); + json serve_body = { { "n_predict", 1 }, { "temperature", 0.0 }, { "cache_prompt", true } }; + if (body.contains("lora")) { + serve_body["lora"] = body.at("lora"); // her stable set: the prompt is served, and the shadow learns, beside it + } + serve.params = server_schema::eval_llama_cmpl_schema(ctx_server.vocab, params, meta->logit_bias_eog, serve_body); serve.params.learn = task.learn; serve.params.learn_only = true; serve.params.res_type = TASK_RESPONSE_TYPE_NONE; diff --git a/tools/server/tests/learn-every-format.py b/tools/server/tests/learn-every-format.py index 4d346e91001..484a2784bd1 100755 --- a/tools/server/tests/learn-every-format.py +++ b/tools/server/tests/learn-every-format.py @@ -135,6 +135,47 @@ def lesson(lora, messages): print(json.dumps({"past_example": first, "again_cached": again})) sys.exit(0) +# A past example is learned under HER stable set, on HER slot (needs shadow-05.gguf and fresh copies +# shadow-06.gguf, shadow-07.gguf in --train-dir). What this catches (Fable on fork #89): the serve +# task carried no adapter set, so a mind with a stable gene learned her past example against the +# server-wide default (the wrong model) and on whatever idle slot the picker chose. +if "--past-example-stable-set" in sys.argv: + import urllib.error + # her gene: a shadow trained two steps and snapshotted (a fresh shadow is an identity adapter) + turn = {"prompt": "The capital of Kansas is", "id_slot": 0, "n_predict": 1, "temperature": 0} + for _ in range(2): + assert landed(sid, json.loads(raw("/completion", dict(turn, learn=fenced(sid, lr=0.05))))["learn"])["success"] + assert json.loads(raw("/lora-adapters/snapshot", {"id": sid, "name": "gene-test.gguf"}))["success"] + gene = json.loads(raw("/lora-adapters/load", {"name": "gene-test.gguf"}))["id"] + with_gene, without_gene = (json.loads(raw("/lora-adapters/load", {"name": n}))["id"] for n in ("shadow-06.gguf", "shadow-07.gguf")) + past = [{"role": "user", "content": "Which city is the capital of Kansas?"}, + {"role": "assistant", "content": "Topeka is the capital of Kansas."}] + def lesson(shadow, **extra): + step = fenced(shadow) + body = {"messages": past, "id_slot": 0, "lora_id": shadow, "lr": step["lr"], "execution": step["execution"], + "base_step": step["base_step"], "engine": ENGINE, "input": step["input"], + "chat_template_kwargs": {"enable_thinking": False}} + body.update(extra) + return body + def refused(body, needle): + try: + raw("/learn", body) + except urllib.error.HTTPError as error: + detail = error.read().decode() + assert error.code == 400 and needle in detail, (error.code, detail) + return detail + raise AssertionError("not refused: " + needle) + refused(lesson(with_gene, id_slot=-1), "id_slot") + under = landed(with_gene, json.loads(raw("/learn", lesson(with_gene, lora=[{"id": gene, "scale": 1.0}])))) + plain = landed(without_gene, json.loads(raw("/learn", lesson(without_gene)))) # the gene serves at scale 0 here + assert under["success"] and plain["success"], (under, plain) + assert abs(under["loss"] - plain["loss"]) > 1e-4, ("her gene never reached the serve", under, plain) + # once the gene serves server-wide, a learn that does not name her set is refused, never guessed + raw("/lora-adapters", [{"id": gene, "scale": 1.0}]) + why = refused(lesson(without_gene), "names her stable adapter set") + print(json.dumps({"under_her_gene": under["loss"], "without": plain["loss"], "refused": why[:120]})) + sys.exit(0) + # Two minds on one serving context: each shadow keeps its own optimizer while the other learns in # between (needs shadow-05.gguf and a copy of it, shadow-06.gguf, in --train-dir). if "--two-minds" in sys.argv: