#include "server-decision.h" #include #include #include #include static const char * decision_question_type_name(server_decision_question_type type) { switch (type) { case SERVER_DECISION_QUESTION_CHOICE: return "choice"; case SERVER_DECISION_QUESTION_SCORE: return "score"; case SERVER_DECISION_QUESTION_NOUL: return "noul"; } return ""; } // lev reads noul from a rating scale: 0 = certainly no, 8 = certainly yes static const size_t DECISION_LEV_N_RATINGS = 9; static std::string decision_meta_str(const llama_model * model, const std::string & key) { char buf[256]; const int32_t n = llama_model_meta_val_str(model, key.c_str(), buf, sizeof(buf)); return n < 0 ? "" : std::string(buf); } // // model-specific setup // void server_decision_context::init(const llama_model * model) { *this = server_decision_context(); // the model can be reloaded const common_decision_type model_type = common_get_decision_type(model); if (model_type == COMMON_DECISION_TYPE_NONE) { return; } const std::string prefix = decision_meta_str(model, "general.architecture") + ".decision."; const std::string type_name = decision_meta_str(model, prefix + "type"); vocab = llama_model_get_vocab(model); const char * tmpl_src = llama_model_chat_template(model, "systemone"); if (tmpl_src == nullptr) { throw std::runtime_error("decision model has no \"systemone\" template"); } tmpl = std::make_shared(tmpl_src, "", ""); const std::string prefix_temp = prefix + "temperature."; for (int32_t i = 0; i < llama_model_meta_count(model); i++) { char key[256]; char val[64]; if (llama_model_meta_key_by_index(model, i, key, sizeof(key)) < 0 || !string_starts_with(key, prefix_temp)) { continue; } if (llama_model_meta_val_str_by_index(model, i, val, sizeof(val)) < 0) { continue; } const float temp = std::strtof(val, nullptr); if (temp <= 0.0f) { throw std::runtime_error(string_format("invalid decision temperature: %s = %s", key, val)); } temperatures[key + prefix_temp.size()] = temp; } if (model_type == COMMON_DECISION_TYPE_OPENJEV) { // one letter per option, each must be a single token const std::string letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; for (const char c : letters) { const auto toks = common_tokenize(vocab, std::string(1, c), false, false); if (toks.size() != 1) { throw std::runtime_error(string_format("decision label '%c' is not a single token", c)); } labels.push_back(toks[0]); } n_options_max = labels.size(); noul_true_first = true; } else if (model_type == COMMON_DECISION_TYPE_LEV) { // label codes are A..Z then AA..ZZ, only the ones that are a single token are used std::vector codes; for (char a = 'A'; a <= 'Z'; a++) { codes.push_back(std::string(1, a)); } for (char a = 'A'; a <= 'Z'; a++) { for (char b = 'A'; b <= 'Z'; b++) { codes.push_back(std::string{a, b}); } } for (const auto & code : codes) { const auto toks = common_tokenize(vocab, code, false, false); if (toks.size() == 1 && labels.size() < 255) { labels.push_back(toks[0]); label_texts.push_back(code); } } n_options_max = labels.size(); } else if (model_type == COMMON_DECISION_TYPE_KEV) { // the hidden state of an option is read at the token that ends it const auto toks = common_tokenize(vocab, "<|box_end|>", false, true); if (toks.size() != 1) { throw std::runtime_error("decision model has no <|box_end|> token"); } token_marker = toks[0]; n_options_max = 255; } else if (model_type == COMMON_DECISION_TYPE_LAYA) { token_marker = llama_vocab_mask(vocab); token_sep = llama_vocab_sep(vocab); if (token_marker == LLAMA_TOKEN_NULL || token_sep == LLAMA_TOKEN_NULL) { throw std::runtime_error("decision model has no mask or sep token"); } text_marker = common_token_to_piece(vocab, token_marker, true); const std::string val = decision_meta_str(model, prefix + "max_head_tokens"); max_head_tokens = std::strtoul(val.c_str(), nullptr, 10); if (max_head_tokens == 0) { throw std::runtime_error("decision model has no valid max_head_tokens"); } n_options_max = 255; } else { throw std::runtime_error("unsupported decision model type: " + type_name); } type = model_type; SRV_INF("decision model type: %s\n", type_name.c_str()); } // // request parsing // std::vector server_decision_context::parse_questions(const json & body) const { if (!body.contains("state") || body.at("state").is_null()) { throw std::invalid_argument("\"state\" must be provided"); } if (!body.contains("questions") || !body.at("questions").is_object() || body.at("questions").empty()) { throw std::invalid_argument("\"questions\" must be a non-empty object"); } std::vector questions; for (const auto & [id, q] : body.at("questions").items()) { auto err = [&id = id](const std::string & msg) { return std::invalid_argument("questions." + id + ": " + msg); }; if (!q.is_object()) { throw err("must be an object"); } if (!q.contains("instructions") || q.at("instructions").is_null()) { throw err("\"instructions\" must be provided"); } server_decision_question question; question.id = id; question.instructions = q.at("instructions"); const std::string type_name = json_value(q, "type", std::string()); const json criteria = q.contains("criteria") ? q.at("criteria") : json(); if (type_name == "choice") { question.type = SERVER_DECISION_QUESTION_CHOICE; if (!criteria.is_object() || criteria.empty()) { throw err("\"criteria\" must be a non-empty object"); } for (const auto & [key, description] : criteria.items()) { question.options.push_back({key, description}); } } else if (type_name == "score") { question.type = SERVER_DECISION_QUESTION_SCORE; if (!criteria.is_array() || criteria.size() < 2 || criteria.size() > 10) { throw err("\"criteria\" must be an array of 2 to 10 levels"); } for (size_t i = 0; i < criteria.size(); i++) { question.options.push_back({std::to_string(i), criteria.at(i)}); } } else if (type_name == "noul") { question.type = SERVER_DECISION_QUESTION_NOUL; if (!criteria.is_null() && !criteria.is_object()) { throw err("\"criteria\" must be an object"); } for (const char * key : {"false", "true"}) { question.options.push_back({key, criteria.is_object() && criteria.contains(key) ? criteria.at(key) : json()}); } if (noul_true_first) { std::swap(question.options[0], question.options[1]); } } else { throw err("\"type\" must be one of: choice, score, noul"); } if (question.options.size() > n_options_max) { throw err(string_format("too many options (%zu), this model supports at most %zu", question.options.size(), n_options_max)); } questions.push_back(std::move(question)); } return questions; } // // images // static const size_t DECISION_MAX_IMAGES = 8; static void decision_load_image(const json & url, std::vector & files) { if (!url.is_string() || !string_starts_with(url.get(), "data:image/")) { throw std::invalid_argument("images must be data URLs (data:image/...;base64,...)"); } if (files.size() >= DECISION_MAX_IMAGES) { throw std::invalid_argument(string_format("too many images, the maximum is %zu", DECISION_MAX_IMAGES)); } handle_media(files, url.get(), ""); } json server_decision_context::parse_state(const json & body, std::vector & files) const { if (body.contains("images") && !body.at("images").is_null()) { if (!body.at("images").is_array()) { throw std::invalid_argument("\"images\" must be an array"); } for (const auto & url : body.at("images")) { decision_load_image(url, files); } } const json & state = body.at("state"); const bool is_wrapped = state.is_object() && state.contains("messages"); const json & messages = is_wrapped ? state.at("messages") : state; if (!messages.is_array()) { return state; } // chat messages: take the image parts out of the content json messages_out = json::array(); for (const auto & msg : messages) { if (!msg.is_object() || !msg.contains("content") || !msg.at("content").is_array()) { messages_out.push_back(msg); continue; } json content = json::array(); for (const auto & part : msg.at("content")) { if (part.is_object() && json_value(part, "type", std::string()) == "image_url" && part.contains("image_url")) { const json & image_url = part.at("image_url"); decision_load_image(image_url.is_object() && image_url.contains("url") ? image_url.at("url") : image_url, files); } else { content.push_back(part); } } json msg_out = msg; msg_out["content"] = content; messages_out.push_back(msg_out); } if (!is_wrapped) { return messages_out; } json state_out = state; state_out["messages"] = messages_out; return state_out; } // // prompt // // replace text in all strings of a JSON value static json decision_replace_text(const json & val, const std::string & search, const std::string & replace) { if (val.is_string()) { std::string str = val.get(); string_replace_all(str, search, replace); return str; } if (val.is_array()) { json out = json::array(); for (const auto & item : val) { out.push_back(decision_replace_text(item, search, replace)); } return out; } if (val.is_object()) { json out = json::object(); for (const auto & [key, item] : val.items()) { out[key] = decision_replace_text(item, search, replace); } return out; } return val; } // sort the keys of all objects of a JSON value static json decision_sort_keys(const json & val) { if (val.is_array()) { json out = json::array(); for (const auto & item : val) { out.push_back(decision_sort_keys(item)); } return out; } if (val.is_object()) { std::map sorted; for (const auto & [key, item] : val.items()) { sorted[key] = decision_sort_keys(item); } json out = json::object(); for (const auto & [key, item] : sorted) { out[key] = item; } return out; } return val; } // kev flattens a JSON value into text, the keys of an object are kept as labels (kev/api.py: render) static std::string decision_kev_render(const json & val, int indent = 0) { const std::string pad(2 * indent, ' '); if (val.is_null()) { return ""; } if (val.is_string()) { return val.get(); } if (val.is_boolean()) { return val.get() ? "True" : "False"; } if (val.is_array()) { std::string out; for (const auto & item : val) { const std::string text = decision_kev_render(item, indent + 1); out += (out.empty() ? "" : "\n") + pad + "- " + text.substr(std::min(text.size(), text.find_first_not_of(" \t\n\r"))); } return out; } if (val.is_object()) { std::string out; for (const auto & [key, item] : val.items()) { const bool is_nested = item.is_object() || item.is_array(); out += (out.empty() ? "" : "\n") + pad + key + (is_nested ? ":\n" : ": ") + decision_kev_render(item, is_nested ? indent + 1 : 0); } return out; } return val.dump(); } // kev text input: special tokens written in the text must not be parsed as such static std::string decision_kev_text(const json & val) { static const std::regex re_special("<\\|([A-Za-z0-9_]+)\\|>"); return std::regex_replace(decision_kev_render(val), re_special, "<\xC2\xA6$1\xC2\xA6>"); } size_t server_decision_context::n_variants(const server_decision_question & question) const { // lev shows the options of a choice in 2 orders, to cancel the preference for the first label if (type == COMMON_DECISION_TYPE_LEV && question.type == SERVER_DECISION_QUESTION_CHOICE && question.options.size() > 1) { return 2; } return 1; } size_t server_decision_context::n_outputs(const server_decision_question & question) const { if (type == COMMON_DECISION_TYPE_LEV && question.type == SERVER_DECISION_QUESTION_NOUL) { return DECISION_LEV_N_RATINGS; } return question.options.size(); } std::string server_decision_context::render(const json & state, const server_decision_question & question, size_t variant, size_t n_images) const { const size_t n_options = question.options.size(); // the second variant shows the options in the reverse order json options = json::array(); for (size_t i = 0; i < n_options; i++) { const auto & opt = question.options[variant == 0 ? i : n_options - 1 - i]; json option = json{ {"key", opt.key}, {"description", opt.description}, }; if (type == COMMON_DECISION_TYPE_KEV) { option["key"] = decision_kev_text(opt.key); if (!opt.description.is_null()) { option["description"] = decision_kev_text(opt.description); } } if (!label_texts.empty()) { option["label"] = label_texts[i]; } options.push_back(option); } // the template is given raw JSON values, it serializes the ones that are not strings json inp = json{ {"id", question.id}, {"type", decision_question_type_name(question.type)}, {"instructions", question.instructions}, {"state", state}, {"options", options}, }; // lev was trained with sorted keys if (type == COMMON_DECISION_TYPE_LEV) { inp = decision_sort_keys(inp); } // the kev template only takes text if (type == COMMON_DECISION_TYPE_KEV) { inp["state"] = decision_kev_text(state); inp["instructions"] = decision_kev_text(question.instructions); } // the input must not contain the marker of the options if (!text_marker.empty()) { inp = decision_replace_text(inp, text_marker, " "); } // the template puts one media marker per image json images = json::array(); if (n_images > 0) { inp = decision_replace_text(inp, get_media_marker(), " "); for (size_t i = 0; i < n_images; i++) { images.push_back(get_media_marker()); } } inp["images"] = images; jinja::context ctx(tmpl->source()); jinja::global_from_json(ctx, inp, false); jinja::runtime runtime(ctx); const jinja::value results = runtime.execute(tmpl->prog); return jinja::runtime::gather_string_parts(results)->as_string().str(); } void server_decision_context::fill_task( const json & state, const server_decision_question & question, size_t variant, const std::vector & files, mtmd_context * mctx, const mtmd_helper_init_opt & init_opt, server_task & task) const { const std::string prompt = render(state, question, variant, files.size()); if (type == COMMON_DECISION_TYPE_OPENJEV || type == COMMON_DECISION_TYPE_LEV) { // lev reads the ratings of a noul question at its first labels, not at the digits task.decision.labels.assign(labels.begin(), labels.begin() + n_outputs(question)); if (!files.empty()) { task.tokens = process_mtmd_prompt(mctx, prompt, files, init_opt); return; } } llama_tokens tokens = common_tokenize(vocab, prompt, false, true); if (type == COMMON_DECISION_TYPE_LAYA) { fill_task_laya(tokens, question, task); } if (type == COMMON_DECISION_TYPE_KEV) { // an option is read at its end token, the question at the last token for (size_t i = 0; i < tokens.size(); i++) { if (tokens[i] == token_marker) { task.decision.markers.push_back(i); } } if (task.decision.markers.size() != question.options.size()) { throw std::runtime_error("unexpected layout of the decision prompt"); } task.decision.pointer = tokens.size() - 1; } task.tokens = server_tokens(tokens, false); } // the prompt is: [cls] question [sep] ([marker] option)* [sep] state [sep] // options and question are cut to fit max_head_tokens, the same way the model was trained void server_decision_context::fill_task_laya(llama_tokens & tokens, const server_decision_question & question, server_task & task) const { const size_t n_options = question.options.size(); std::vector markers; for (size_t i = 0; i < tokens.size(); i++) { if (tokens[i] == token_marker) { markers.push_back(i); } } const auto invalid = std::runtime_error("unexpected layout of the decision prompt"); if (markers.size() != n_options || markers[0] < 2 || tokens[markers[0] - 1] != token_sep || tokens.back() != token_sep) { throw invalid; } const size_t head_end = markers[0] - 1; const size_t opts_end = std::find(tokens.begin() + markers.back(), tokens.end(), token_sep) - tokens.begin(); if (opts_end + 1 >= tokens.size()) { throw invalid; } // marker + text of each option std::vector options; size_t n_options_tokens = 0; auto set_max = [&](size_t n_max) { n_options_tokens = 0; for (auto & opt : options) { opt.resize(std::min(opt.size(), n_max)); n_options_tokens += opt.size(); } }; for (size_t i = 0; i < n_options; i++) { const size_t end = i + 1 < n_options ? markers[i + 1] : opts_end; options.emplace_back(tokens.begin() + markers[i], tokens.begin() + end); } set_max(max_option_tokens + 1); if (n_options_tokens + 16 > max_head_tokens) { // too many or too long options, shrink them evenly set_max(std::max((size_t) 4, (max_head_tokens - std::min(max_head_tokens, (size_t) 16)) / n_options)); } const size_t n_question_max = std::max((size_t) 8, max_head_tokens - std::min(max_head_tokens, n_options_tokens)); llama_tokens out; out.push_back(tokens[0]); out.insert(out.end(), tokens.begin() + 1, tokens.begin() + std::min(head_end, 1 + n_question_max)); out.push_back(token_sep); for (const auto & opt : options) { task.decision.markers.push_back(out.size()); out.insert(out.end(), opt.begin(), opt.end()); } out.insert(out.end(), tokens.begin() + opts_end, tokens.end()); tokens = std::move(out); // the output has one score per question type task.decision.column = question.type; } // // answer // float server_decision_context::get_temperature(const server_decision_question & question) const { const size_t n = question.options.size(); const std::string type_name = decision_question_type_name(question.type); // the temperature can depend on the number of options, the buckets are the ones used to fit it std::string bucket; if (type == COMMON_DECISION_TYPE_LEV) { bucket = n <= 8 ? "small" : n <= 26 ? "mid" : "large"; } else { bucket = n <= 2 ? "2" : n <= 5 ? "3_5" : n <= 10 ? "6_10" : "11"; } for (const auto & name : {type_name + "." + bucket, type_name}) { const auto it = temperatures.find(name); if (it != temperatures.end()) { return it->second; } } return 1.0f; } // confidence formulas are the ones published by TypeSafe static double decision_confidence_choice(const std::vector & probs) { if (probs.size() < 2) { return 1.0; } const double uniform = 1.0 / probs.size(); const double p_max = *std::max_element(probs.begin(), probs.end()); return std::max(0.0, (p_max - uniform) / (1.0 - uniform)); } static double decision_confidence_score(const std::vector & probs) { if (probs.size() < 2) { return 1.0; } const size_t n = probs.size(); const size_t mode = std::max_element(probs.begin(), probs.end()) - probs.begin(); // mean distance to the mode, relative to the one of a uniform distribution around its center double dist = 0.0; double dist_uniform = 0.0; for (size_t i = 0; i < n; i++) { dist += probs[i] * std::fabs((double) i - (double) mode); dist_uniform += std::fabs((double) i - (n - 1) / 2.0) / n; } return std::max(0.0, 1.0 - dist / dist_uniform); } json server_decision_context::format_answer(const server_decision_question & question, const std::vector> & scores) const { const size_t n = n_outputs(question); if (scores.size() != n_variants(question)) { throw std::runtime_error("decision result does not match the number of variants"); } // softmax over the outputs of each variant, then the average of the variants const float temperature = get_temperature(question); std::vector probs(n, 0.0); for (size_t v = 0; v < scores.size(); v++) { const auto & s = scores[v]; if (s.size() != n) { throw std::runtime_error("decision result does not match the number of options"); } const float score_max = *std::max_element(s.begin(), s.end()); std::vector p(n); double sum = 0.0; for (size_t i = 0; i < n; i++) { p[i] = std::exp((double) (s[i] - score_max) / temperature); sum += p[i]; } for (size_t i = 0; i < n; i++) { // the second variant is in the reverse order probs[v == 0 ? i : n - 1 - i] += p[i] / sum / scores.size(); } } json answer = json{{"type", decision_question_type_name(question.type)}}; if (question.type == SERVER_DECISION_QUESTION_NOUL) { if (type == COMMON_DECISION_TYPE_LEV) { double expected = 0.0; for (size_t i = 0; i < n; i++) { expected += probs[i] * i / (n - 1); } answer["noul"] = expected; return answer; } for (size_t i = 0; i < n; i++) { if (question.options[i].key == "true") { answer["noul"] = probs[i]; } } return answer; } json probabilities = json::object(); for (size_t i = 0; i < n; i++) { probabilities[question.options[i].key] = probs[i]; } if (question.type == SERVER_DECISION_QUESTION_CHOICE) { const size_t best = std::max_element(probs.begin(), probs.end()) - probs.begin(); answer["choice"] = question.options[best].key; answer["probabilities"] = probabilities; answer["confidence"] = decision_confidence_choice(probs); } else { double expected = 0.0; json legend = json::object(); for (size_t i = 0; i < n; i++) { expected += i * probs[i]; legend[question.options[i].key] = question.options[i].description; } answer["score"] = expected; answer["legend"] = legend; answer["probabilities"] = probabilities; answer["confidence"] = decision_confidence_score(probs); } return answer; } // // shared prompt prefix // std::vector server_decision_group_tasks(std::vector && tasks, size_t n_slots) { n_slots = std::max(n_slots, (size_t) 1); std::vector groups; for (size_t i = 0; i < tasks.size(); i += n_slots) { const size_t end = std::min(tasks.size(), i + n_slots); server_task & parent = tasks[i]; // every task must have at least one token of its own to evaluate size_t n_shared = parent.tokens.size() - 1; for (size_t j = i + 1; j < end; j++) { n_shared = std::min(n_shared, parent.tokens.get_common_prefix(tasks[j].tokens)); n_shared = std::min(n_shared, tasks[j].tokens.size() - 1); } if (end - i < 2 || n_shared == 0) { for (size_t j = i; j < end; j++) { groups.push_back(std::move(tasks[j])); } continue; } parent.n_tokens_shared = n_shared; for (size_t j = i + 1; j < end; j++) { tasks[j].id_parent = parent.id; parent.child_tasks.push_back(std::move(tasks[j])); } groups.push_back(std::move(parent)); } return groups; }