From 407e2adf4cdcfb5234a86ba5eeb7312c9dc6814e Mon Sep 17 00:00:00 2001 From: levius <2114377220@qq.com> Date: Tue, 6 Oct 2026 00:25:49 +0800 Subject: [PATCH 1/8] feat(decider): add native CPU request and response contract --- Cargo.lock | 11 + Cargo.toml | 2 +- src/models/decider/native/Cargo.toml | 12 + src/models/decider/native/LICENSE.decider | 190 +++++++++++ .../decider/native/THIRD_PARTY_NOTICES.md | 16 + src/models/decider/native/src/config.rs | 94 ++++++ src/models/decider/native/src/contract.rs | 283 ++++++++++++++++ src/models/decider/native/src/json.rs | 176 ++++++++++ src/models/decider/native/src/lib.rs | 31 ++ src/models/decider/native/src/math.rs | 26 ++ src/models/decider/native/src/processing.rs | 314 ++++++++++++++++++ src/models/decider/native/src/text.rs | 28 ++ 12 files changed, 1182 insertions(+), 1 deletion(-) create mode 100644 src/models/decider/native/Cargo.toml create mode 100644 src/models/decider/native/LICENSE.decider create mode 100644 src/models/decider/native/THIRD_PARTY_NOTICES.md create mode 100644 src/models/decider/native/src/config.rs create mode 100644 src/models/decider/native/src/contract.rs create mode 100644 src/models/decider/native/src/json.rs create mode 100644 src/models/decider/native/src/lib.rs create mode 100644 src/models/decider/native/src/math.rs create mode 100644 src/models/decider/native/src/processing.rs create mode 100644 src/models/decider/native/src/text.rs diff --git a/Cargo.lock b/Cargo.lock index 2bb95d2f..9250d700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "omni-decider-native" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "sha2", + "tokenizers 0.22.2", +] + [[package]] name = "omni-jev" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 242d51ca..57bf6308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/laya", "src/backends/cuda"] +members = ["src/frontend", "src/runtime", "src/models/clm", "src/models/cua_s1/native", "src/models/qwen3_5/native", "src/models/open_jev/native", "src/models/laya", "src/backends/cuda", "src/models/decider/native"] resolver = "3" diff --git a/src/models/decider/native/Cargo.toml b/src/models/decider/native/Cargo.toml new file mode 100644 index 00000000..4d84c693 --- /dev/null +++ b/src/models/decider/native/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "omni-decider-native" +version = "0.1.0" +edition = "2024" +publish = false +description = "Weight-free native CPU request and response contract for Decider-2B v11" +[dependencies] +anyhow = "1.0.100" +serde = "1.0.229" +serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order", "raw_value"] } +tokenizers = { version = "=0.22.2", default-features = false, features = ["onig"] } +sha2 = "0.10" diff --git a/src/models/decider/native/LICENSE.decider b/src/models/decider/native/LICENSE.decider new file mode 100644 index 00000000..916a53c7 --- /dev/null +++ b/src/models/decider/native/LICENSE.decider @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, or other + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any + other Contributor, and only if You agree to indemnify, defend, and + hold each Contributor harmless for any liability incurred by, or + claims asserted against, such Contributor by reason of your + accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Mark Marosi + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/models/decider/native/THIRD_PARTY_NOTICES.md b/src/models/decider/native/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..36bc4dae --- /dev/null +++ b/src/models/decider/native/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# Decider CPU contract attribution + +The request rendering, row planning, token construction and answer formulas in +`src/contract.rs` and `src/processing.rs` adapt Mapika/decider's +`decider/systemone.py`, `decider/prompt.py`, `decider/prompt_fast.py` and +`decider/temperature.py`, revision +`50d0be0d7cb43d2066965ce5fa7f3fe4e489a60f` (decider-ai 1.8.1). +Copyright the Mapika/decider contributors. Licensed under Apache-2.0; +see [the retained license](LICENSE.decider). + +The adaptation uses Rust CPU buffers, the frozen Decider-2B v11 tokenizer and +configuration, explicit native input/admission restrictions, independent rows +and isolated Score levels. It performs no model forward or checkpoint tensor +loading. The model-private JSON renderer follows this repository's shared JSON +helper's Python notation conventions, with stricter integer decoding to prevent +arbitrary_precision feature unification from changing state values. diff --git a/src/models/decider/native/src/config.rs b/src/models/decider/native/src/config.rs new file mode 100644 index 00000000..f7917a3b --- /dev/null +++ b/src/models/decider/native/src/config.rs @@ -0,0 +1,94 @@ +use crate::{contract::Kind, json}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use std::path::Path; + +/// Validated calibration for the released plain independent/isolated readout. +#[derive(Clone, Debug)] +pub struct Config { + temperatures: [f32; 3], +} +impl Config { + pub fn load(dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + let model = json::parse(&std::fs::read(dir.join("config.json"))?)?; + json::object(&model)?; + ensure!( + model["model_type"] == "qwen3_5_text" + && model["hidden_size"] == 2048 + && model["num_hidden_layers"] == 24 + && model["tie_word_embeddings"] == true + && model["vocab_size"] == 248320 + && model["dtype"] == "bfloat16", + "expected Decider-2B v11 text configuration" + ); + Self::from_value(&json::parse(&std::fs::read( + dir.join("decider_config.json"), + )?)?) + } + /// Positive calibration overrides are supported, with missing types using fallback. + /// Nonreleased prompt/readout modes and option-dependent temperatures are rejected. + pub fn from_value(value: &Value) -> Result { + let c = json::object(value)?; + ensure!( + c.get("version").and_then(Value::as_str) == Some("2b-v11"), + "expected 2b-v11 configuration" + ); + ensure!( + c.get("layout").and_then(Value::as_str) == Some("plain"), + "only plain layout supported" + ); + for (key, want) in [ + ("schema_first", false), + ("schema_first_trained", false), + ("neutralize_none", false), + ("isolated_levels", true), + ] { + json::default_mode(c, key, want)?; + } + ensure!( + c.get("max_options").and_then(Value::as_u64) == Some(255) + && c.get("max_state_tokens").and_then(Value::as_u64) == Some(32768), + "unsupported option/state limits" + ); + for key in [ + "temperature_by_options", + "temperature_schema_first", + "temperature_schema_first_by_type", + ] { + ensure!(!c.contains_key(key), "unsupported calibration {key}"); + } + for key in ["neutralize_none", "isolated_levels"] { + json::required(c, key)?; + } + let raw = json::required(c, "temperature")?; + let scalar = if let Some(text) = raw.as_str() { + serde_json::json!(json::python_float(text).context("temperature must be numeric")?) + } else { + raw.clone() + }; + let fallback = positive(&scalar)?; + let mut temperatures = [fallback; 3]; + if let Some(m) = c.get("temperature_by_type").filter(|v| !v.is_null()) { + for (key, value) in json::object(m)? { + let kind = Kind::parse(key)?; + ensure!(key != "bool", "unknown calibration type bool"); + temperatures[kind.index()] = positive(value)?; + } + } + Ok(Self { temperatures }) + } + pub fn temperature(&self, kind: Kind) -> f32 { + self.temperatures[kind.index()] + } +} +fn positive(value: &Value) -> Result { + // The serving by-type path materializes its temperature list as FP32. + let x = value.as_f64().context("temperature must be numeric")?; + let rounded = x as f32; + ensure!( + x.is_finite() && x > 0.0 && rounded.is_finite() && rounded > 0.0, + "temperature must be finite and positive in FP32" + ); + Ok(rounded) +} diff --git a/src/models/decider/native/src/contract.rs b/src/models/decider/native/src/contract.rs new file mode 100644 index 00000000..96ad5022 --- /dev/null +++ b/src/models/decider/native/src/contract.rs @@ -0,0 +1,283 @@ +//! Adapted from Mapika/decider systemone.py at 50d0be0; Apache-2.0. +use crate::json; +use anyhow::{Context, Result, bail, ensure}; +use serde_json::{Map, Value}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Choice, + Noul, + Score, +} +impl Kind { + pub(crate) fn parse(t: &str) -> Result { + match t { + "choice" => Ok(Self::Choice), + "noul" | "bool" => Ok(Self::Noul), + "score" => Ok(Self::Score), + _ => bail!("unknown question type {t:?}"), + } + } + pub(crate) fn index(self) -> usize { + match self { + Self::Choice => 0, + Self::Noul => 1, + Self::Score => 2, + } + } +} +#[derive(Clone, Debug)] +pub(crate) struct Question { + pub id: String, + pub kind: Kind, + pub text: String, + pub options: Vec, + pub names: Vec, + pub legend: Vec, +} +fn empty(v: &Value) -> bool { + v.is_null() || v.as_str() == Some("") +} +fn annotate(v: &Value) -> Value { + match v { + Value::Array(a) => Value::Array( + a.iter() + .enumerate() + .map(|(i, x)| { + let x = annotate(x); + if a.len() < 8 { + x + } else { + let mut m = Map::new(); + m.insert("_index".into(), Value::from(i)); + if let Value::Object(fields) = x { + for (k, v) in fields { + m.insert(k, v); + } + } else { + m.insert("value".into(), x); + } + Value::Object(m) + } + }) + .collect(), + ), + Value::Object(m) => { + Value::Object(m.iter().map(|(k, v)| (k.clone(), annotate(v))).collect()) + } + _ => v.clone(), + } +} +pub(crate) fn render_state(v: &Value) -> String { + if v.is_string() { + json::text(v) + } else { + json::dumps(&annotate(v)) + } +} +pub(crate) fn render_question(id: &str, spec: &Value) -> Result { + let s = json::object(spec).context("question definition must be an object")?; + json::default_mode(s, "isolated", true)?; + let kind = Kind::parse( + s.get("type") + .map(|v| v.as_str().context("type must be text")) + .transpose()? + .unwrap_or("choice"), + )?; + let none = Value::Null; + let blank = Value::String(String::new()); + let criteria = s + .get("criteria") + .or_else(|| s.get("options")) + .unwrap_or(&none); + let raw = s + .get("instructions") + .or_else(|| s.get("question")) + .unwrap_or(&blank); + let text = if kind == Kind::Noul && empty(raw) { + let described = criteria.as_object().is_some_and(|m| { + ["true", "false"] + .iter() + .any(|k| m.get(*k).is_some_and(|v| !empty(v))) + }); + ensure!( + described, + "noul without instructions requires true or false description" + ); + "Which answer fits the context?".into() + } else { + json::text(raw) + }; + ensure!(!text.is_empty(), "question without instructions"); + let mut names = Vec::new(); + let mut options = Vec::new(); + let mut legend = Vec::new(); + match kind { + Kind::Choice => { + let fields = if let Some(a) = criteria.as_array() { + let mut fields = Map::new(); + for v in a { + let name = v.as_str().context( + "Choice list alias requires string names; use a map for other JSON values", + )?; + fields.insert(name.into(), Value::Null); + } + fields + } else { + json::object(criteria) + .context("Choice requires map or string list")? + .clone() + }; + ensure!( + (2..=255).contains(&fields.len()), + "Choice requires 2..255 options" + ); + for (name, v) in fields { + options.push(if empty(&v) { + name.clone() + } else { + format!("{name}: {}", json::text(&v)) + }); + names.push(name); + } + } + Kind::Score => { + let levels = if let Some(a) = criteria.as_array() { + a.clone() + } else { + let mut ordered = json::object(criteria)? + .iter() + .map(|(k, v)| { + let number = + json::python_float(k).context("Score legend keys must be numeric")?; + ensure!(number.is_finite(), "Score legend keys must be finite"); + Ok((number, v.clone())) + }) + .collect::>>()?; + ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("finite key")); + ordered.into_iter().map(|(_, v)| v).collect() + }; + ensure!( + (2..=10).contains(&levels.len()), + "Score requires 2..10 levels" + ); + legend = levels.iter().map(json::text).collect(); + for (i, v) in legend.iter().enumerate() { + names.push(i.to_string()); + options.push(format!("{i}: {v}")); + } + } + Kind::Noul => { + ensure!( + criteria.is_null() || criteria.is_object(), + "Noul criteria must be a map" + ); + for (key, label) in [("false", "no"), ("true", "yes")] { + let v = criteria.get(key).unwrap_or(&none); + options.push(if empty(v) { + label.into() + } else { + format!("{label}: {}", json::text(v)) + }); + names.push(key.into()); + } + } + } + Ok(Question { + id: id.into(), + kind, + text, + options, + names, + legend, + }) +} +pub(crate) fn isolated_text(q: &Question, level: usize) -> String { + use crate::text::{decimal, whitespace}; + let original = &q.legend[level]; + let trimmed = original.trim_start_matches(whitespace); + let unsigned = trimmed.strip_prefix('-').unwrap_or(trimmed); + let digits = unsigned + .char_indices() + .find(|(_, c)| decimal(*c).is_none()) + .map_or(unsigned.len(), |(i, _)| i); + let stripped = if digits > 0 { + unsigned[digits..] + .trim_start_matches(whitespace) + .strip_prefix(':') + .map(|v| v.trim_start_matches(whitespace)) + } else { + None + }; + format!( + "{}\nProposed answer: {}\nDoes the proposed answer fit?", + q.text, + stripped.unwrap_or(original) + ) +} + +fn normalized(p: &[f64]) -> Vec { + let sum: f64 = p.iter().sum(); + if sum == 0.0 { + vec![1.0 / p.len() as f64; p.len()] + } else { + p.iter().map(|v| v / sum).collect() + } +} +fn mode(p: &[f64]) -> usize { + (1..p.len()).fold(0, |m, i| if p[i] > p[m] { i } else { m }) +} +pub(crate) fn answer(q: &Question, probabilities: &[f64]) -> Value { + use crate::math::round; + use serde_json::json; + let sum: f64 = probabilities.iter().take(q.options.len()).sum(); + let divisor = if sum == 0.0 { 1.0 } else { sum }; + let p: Vec = probabilities + .iter() + .take(q.options.len()) + .map(|v| v / divisor) + .collect(); + if q.kind == Kind::Noul { + return json!({"type":"noul","noul":round(p[1],4)}); + } + let j = mode(&p); + let n = p.len(); + let entropy: f64 = p.iter().filter(|v| **v > 0.0).map(|v| -v * v.ln()).sum(); + let certainty = (1.0 - entropy / (n as f64).ln()).max(0.0); + // Confidence's zero-mass uniform fallback never replaces emitted probabilities. + let norm = normalized(&p); + let confidence = if q.kind == Kind::Choice { + ((n as f64 * norm[mode(&norm)] - 1.0) / (n - 1) as f64).clamp(0.0, 1.0) + } else { + let modal = mode(&norm); + let spread: f64 = norm + .iter() + .enumerate() + .map(|(i, v)| v * i.abs_diff(modal) as f64) + .sum(); + let center = (n - 1) as f64 / 2.0; + let uniform = (0..n).map(|i| (i as f64 - center).abs()).sum::() / n as f64; + (1.0 - spread / uniform).clamp(0.0, 1.0) + }; + let probabilities: Map = q + .names + .iter() + .cloned() + .zip(p.iter().map(|v| json!(round(*v, 4)))) + .collect(); + let mut out = json!({"type":if q.kind==Kind::Choice {"choice"} else {"score"},"confidence":round(confidence,4),"x_p_max":round(p[j],4),"certainty":round(certainty,4),"probabilities":probabilities}); + if q.kind == Kind::Choice { + out["choice"] = json!(q.names[j]); + } else { + let score: f64 = p.iter().enumerate().map(|(i, v)| i as f64 * v).sum(); + out["score"] = json!(round(score, 2)); + out["legend"] = Value::Object( + q.legend + .iter() + .enumerate() + .map(|(i, v)| (i.to_string(), json!(v))) + .collect(), + ); + } + out +} diff --git a/src/models/decider/native/src/json.rs b/src/models/decider/native/src/json.rs new file mode 100644 index 00000000..f73819cc --- /dev/null +++ b/src/models/decider/native/src/json.rs @@ -0,0 +1,176 @@ +//! Model-private strict JSON decoding avoids arbitrary_precision feature-unification traps. +use anyhow::{Context, Result, bail, ensure}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value, value::RawValue}; +use std::fmt; + +pub fn parse(raw: &[u8]) -> Result { + let raw: Box = serde_json::from_slice(raw).context("invalid request JSON")?; + parse_value(&raw, 0) +} +fn parse_value(raw: &RawValue, depth: usize) -> Result { + let text = raw.get(); + if !matches!(text.as_bytes()[0], b'{' | b'[') { + if text.as_bytes()[0].is_ascii_digit() || text.starts_with('-') { + let number = if text.contains(['.', 'e', 'E']) { + let x: f64 = text.parse()?; + Number::from_f64(x).context("nonfinite or out-of-range JSON float")? + } else if text.starts_with('-') { + Number::from( + text.parse::() + .context("JSON integer outside i64/u64 range")?, + ) + } else { + Number::from( + text.parse::() + .context("JSON integer outside i64/u64 range")?, + ) + }; + return Ok(Value::Number(number)); + } + return Ok(serde_json::from_str(text)?); + } + ensure!(depth < 127, "JSON nesting exceeds supported depth"); + struct Container(usize); + impl<'de> Visitor<'de> for Container { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("JSON array or object") + } + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let mut out = Vec::new(); + while let Some(raw) = seq.next_element::>()? { + out.push(parse_value(&raw, self.0 + 1).map_err(de::Error::custom)?); + } + Ok(Value::Array(out)) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let mut out = Map::new(); + while let Some((key, raw)) = map.next_entry::>()? { + if out.contains_key(&key) { + return Err(de::Error::custom(format!("duplicate JSON key {key:?}"))); + } + out.insert( + key, + parse_value(&raw, self.0 + 1).map_err(de::Error::custom)?, + ); + } + Ok(Value::Object(out)) + } + } + use serde::Deserializer; + Ok(serde_json::Deserializer::from_str(text).deserialize_any(Container(depth))?) +} +pub fn object(value: &Value) -> Result<&Map> { + value.as_object().context("expected JSON object") +} +pub fn text(value: &Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| dumps(value)) +} +pub fn dumps(value: &Value) -> String { + match value { + Value::String(s) => serde_json::to_string(s).expect("string serialization"), + Value::Array(a) => format!("[{}]", a.iter().map(dumps).collect::>().join(", ")), + Value::Object(m) => format!( + "{{{}}}", + m.iter() + .map(|(k, v)| format!( + "{}: {}", + serde_json::to_string(k).expect("key serialization"), + dumps(v) + )) + .collect::>() + .join(", ") + ), + Value::Number(n) => { + let s = n.to_string(); + if s.contains(['.', 'e', 'E']) { + float_repr(n.as_f64().expect("validated float")) + } else { + s + } + } + _ => value.to_string(), + } +} +// Python repr notation thresholds, exponent sign/padding, and integer-valued floats. +// Uses Rust's shortest round-trip digits; rare shortest-decimal tie choices may differ. +fn float_repr(x: f64) -> String { + if x == 0.0 { + return if x.is_sign_negative() { "-0.0" } else { "0.0" }.into(); + } + let sci = format!("{x:e}"); + let (m, e) = sci.split_once('e').expect("scientific exponent"); + let exponent: i32 = e.parse().expect("integer exponent"); + let neg = m.starts_with('-'); + let digits = m.trim_start_matches('-').replace('.', ""); + let decpt = exponent + 1; + let sign = if neg { "-" } else { "" }; + if decpt <= -4 || decpt > 16 { + let fraction = if digits.len() > 1 { + format!(".{}", &digits[1..]) + } else { + String::new() + }; + format!( + "{sign}{}{fraction}e{}{:02}", + &digits[..1], + if exponent < 0 { '-' } else { '+' }, + exponent.unsigned_abs() + ) + } else if decpt <= 0 { + format!("{sign}0.{}{}", "0".repeat((-decpt) as usize), digits) + } else if decpt as usize >= digits.len() { + format!( + "{sign}{}{}.0", + digits, + "0".repeat(decpt as usize - digits.len()) + ) + } else { + format!( + "{sign}{}.{}", + &digits[..decpt as usize], + &digits[decpt as usize..] + ) + } +} +pub fn required<'a>(m: &'a Map, key: &str) -> Result<&'a Value> { + m.get(key).with_context(|| format!("missing {key}")) +} +pub fn default_mode(m: &Map, key: &str, expected: bool) -> Result<()> { + if let Some(v) = m.get(key) + && v.as_bool() != Some(expected) + { + bail!("unsupported {key}: expected {expected}"); + } + Ok(()) +} + +// Python float(string) accepts surrounding Python whitespace, digit separators, +// and Unicode decimal (Nd) digits. Normalize these before Rust's numeric parser. +pub fn python_float(key: &str) -> Result { + let normalized: String = key + .trim_matches(crate::text::float_whitespace) + .chars() + .map(|c| crate::text::decimal(c).unwrap_or(c)) + .collect(); + let bytes = normalized.as_bytes(); + for (i, c) in bytes.iter().enumerate() { + if *c == b'_' { + ensure!( + i > 0 + && i + 1 < bytes.len() + && bytes[i - 1].is_ascii_digit() + && bytes[i + 1].is_ascii_digit(), + "invalid digit separator in numeric text" + ); + } + } + normalized + .replace('_', "") + .parse::() + .context("numeric text must parse as float") +} diff --git a/src/models/decider/native/src/lib.rs b/src/models/decider/native/src/lib.rs new file mode 100644 index 00000000..7c7f14ad --- /dev/null +++ b/src/models/decider/native/src/lib.rs @@ -0,0 +1,31 @@ +//! Decider-2B v11's CPU request compiler and response assembler, without model execution. +//! +//! Reference: Mapika/decider 50d0be0 (decider-ai 1.8.1), checkpoint 533964d. +//! Only plain state-first, independent questions and isolated Score levels are supported. +//! The executor must return one candidate-logit vector per complete unpadded row, +//! in prepared order, after the reference BF16 LM projection output rounding. +//! Calibration and whole-question normalization belong here; no GPU/HTTP worker is provided. +//! +//! Native input policy: duplicate keys, depth >=128, nonfinite numbers and integer +//! literals outside i64/u64 are rejected. Integer -0 renders as 0; float -0.0 is retained. +//! Choice list aliases accept strings; canonical maps support arbitrary JSON descriptions. +//! Score legend map keys accept Python whitespace, digit separators and Unicode decimal +//! digits, but nonfinite keys are rejected. Scalar numeric-string temperatures are accepted; +//! per-type values must be numbers. Extreme temperatures must remain positive/finite in FP32. +//! Rust shortest round-trip float rendering may differ on rare shortest-decimal ties; +//! CPU FP32 softmax exp/summation can differ from torch by a few ulps. Golden parity is +//! evidence for the recorded corpus, not universal bit-identical numerical output. +//! Config and tokenizer validation establishes CPU artifact compatibility, not weight identity. +mod config; +mod contract; +mod json; +mod math; +mod processing; +mod text; + +pub use config::Config; +pub use contract::Kind; +pub use processing::{Label, Limits, PreparedRequest, Processor, ResponseContext, RowInput}; +pub const MODEL_ID: &str = "decider-2b-v11"; +pub const RUNTIME_REVISION: &str = "50d0be0d7cb43d2066965ce5fa7f3fe4e489a60f"; +pub const CHECKPOINT_REVISION: &str = "533964dae8be954c5b5e19fa4948e48408094c1e"; diff --git a/src/models/decider/native/src/math.rs b/src/models/decider/native/src/math.rs new file mode 100644 index 00000000..6ffe7455 --- /dev/null +++ b/src/models/decider/native/src/math.rs @@ -0,0 +1,26 @@ +use anyhow::{Result, ensure}; +/// Divide, subtract, exponentiate and normalize in FP32, then expose those values to Python-style FP64 finishing. +/// CPU exp/summation order can differ from torch's vectorized FP32 softmax by a few ulps. +pub(crate) fn softmax(logits: &[f32], temperature: f32) -> Result> { + ensure!( + logits.len() >= 2 && logits.iter().all(|v| v.is_finite()), + "invalid candidate logits" + ); + let scaled: Vec = logits.iter().map(|v| v / temperature).collect(); + ensure!( + scaled.iter().all(|v| v.is_finite()), + "temperature-scaled logits overflow FP32" + ); + let max = scaled.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let mut exps: Vec = scaled.iter().map(|v| (v - max).exp()).collect(); + let total: f32 = exps.iter().sum(); + exps.iter_mut().for_each(|v| *v /= total); + Ok(exps.into_iter().map(f64::from).collect()) +} +/// Fixed decimal formatting rounds the original binary64 value to even. +/// Multiplying by 10^d before rounding changes cases such as Python round(2.675,2). +pub(crate) fn round(x: f64, digits: usize) -> f64 { + format!("{x:.digits$}") + .parse() + .expect("finite decimal result") +} diff --git a/src/models/decider/native/src/processing.rs b/src/models/decider/native/src/processing.rs new file mode 100644 index 00000000..3a910a3d --- /dev/null +++ b/src/models/decider/native/src/processing.rs @@ -0,0 +1,314 @@ +//! Exact segment-aware token compilation and request-local response context. +use crate::{ + Config, + contract::{self, Kind, Question}, + json, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::path::Path; +use tokenizers::Tokenizer; + +const TOKENIZER_SHA256: &str = "06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523"; +const MAX_CONTEXT: usize = 32768; +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub max_rows: usize, + pub max_row_tokens: usize, + /// Sum of complete row lengths, not unique-prefix API usage. + pub max_request_tokens: usize, + pub max_request_bytes: usize, +} +impl Default for Limits { + fn default() -> Self { + Self { + max_rows: 1024, + max_row_tokens: 36864, + max_request_tokens: 1048576, + max_request_bytes: 8 * 1024 * 1024, + } + } +} +#[derive(Clone, Debug)] +pub struct Label { + pub name: String, + pub id: u32, +} +pub struct Processor { + tokenizer: Tokenizer, + labels: Vec