Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions tools/server/server-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<server_task_result_learn>();
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<server_task_result_cmpl_final>();

res->id = slot.task->id;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<std::vector<llama_token>>();
} else {
Expand Down
2 changes: 2 additions & 0 deletions tools/server/server-task.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_request> 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)
Expand Down
60 changes: 60 additions & 0 deletions tools/server/tests/learn-every-format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading