diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index 15aaf35e55a..ac91f195374 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; @@ -6450,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 @@ -6514,6 +6548,36 @@ 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); + 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; + 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..484a2784bd1 100755 --- a/tools/server/tests/learn-every-format.py +++ b/tools/server/tests/learn-every-format.py @@ -116,6 +116,66 @@ 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) + +# 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: