1use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14pub const MODEL_HELPER_EXT: &str = "q42";
16pub const LEGACY_MODEL_HELPER_EXT: &str = "q42.cbor-ld";
18
19pub const MODEL_HELPER_CONTEXT: &str = "https://webizen.org/ns/qualia/model-helper/v1";
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct ModelHelper {
25 #[serde(rename = "@context")]
26 pub context: String,
27 #[serde(rename = "@type")]
28 pub type_: String,
29 pub format: String,
30 pub source_gguf: String,
31 pub p64: String,
32 pub page_log2: u16,
33 pub layout: String,
34 pub converted_unix_ms: u64,
35 pub tokenizer: ModelHelperTokenizer,
36 pub notes: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct ModelHelperTokenizer {
41 pub bos_token_id: u32,
42 pub eos_token_id: u32,
43 pub add_bos_token: bool,
44 pub chat_family: String,
46 pub stop_token_ids: Vec<u32>,
47 pub stop_token_strings: Vec<String>,
48 pub vocab_len: u32,
49}
50
51impl ModelHelper {
52 pub fn new(
53 source_gguf: impl Into<String>,
54 p64_path: impl Into<String>,
55 page_log2: u16,
56 layout: impl Into<String>,
57 tokenizer: ModelHelperTokenizer,
58 ) -> Self {
59 let converted_unix_ms = std::time::SystemTime::now()
60 .duration_since(std::time::UNIX_EPOCH)
61 .map(|d| d.as_millis() as u64)
62 .unwrap_or(0);
63 Self {
64 context: MODEL_HELPER_CONTEXT.to_string(),
65 type_: "QualiaModelHelper".to_string(),
66 format: "qualia.q42.model-helper.v1".to_string(),
67 source_gguf: source_gguf.into(),
68 p64: p64_path.into(),
69 page_log2,
70 layout: layout.into(),
71 converted_unix_ms,
72 tokenizer,
73 notes: vec![
74 "verbatim = GGML quant blocks preserved (same decode kernels as GGUF).".into(),
75 "Activate the .p64 path; keep GGUF as an import-only archive.".into(),
76 "Metadata is a canonical unified Q42 v3 volume.".into(),
77 ],
78 }
79 }
80
81 pub fn to_cbor_ld(&self) -> Result<Vec<u8>, String> {
83 let mut body = Vec::new();
84 ciborium::into_writer(self, &mut body).map_err(|e| format!("cbor encode: {e}"))?;
85 let mut out = Vec::with_capacity(body.len() + 3);
86 out.extend_from_slice(&[0xd9, 0xd9, 0xf7]);
87 out.extend_from_slice(&body);
88 Ok(out)
89 }
90
91 pub fn from_cbor_ld(bytes: &[u8]) -> Result<Self, String> {
93 let payload = strip_self_describe_tag(bytes);
94 ciborium::from_reader(payload).map_err(|e| format!("cbor decode: {e}"))
95 }
96
97 #[cfg(not(target_arch = "wasm32"))]
99 pub fn write_beside_p64(&self, p64_path: &Path) -> Result<std::path::PathBuf, String> {
100 use crate::q42_volume::UnifiedVolumeBuilder;
101
102 let helper_path = helper_path_for_p64(p64_path);
103 let (lex, mut quins) = self.to_q42_graph();
104 quins.sort_unstable_by_key(|q| q.object);
106 let mut builder = UnifiedVolumeBuilder::with_lex_map(&lex)
107 .map_err(|e| format!("build Q42LEX for helper: {e:?}"))?;
108 builder
109 .push_block(0, &quins)
110 .map_err(|e| format!("build canonical Q42 helper: {e}"))?;
111 builder
112 .finish(&helper_path)
113 .map_err(|e| format!("write {}: {e}", helper_path.display()))?;
114 Ok(helper_path)
115 }
116
117 #[cfg(target_arch = "wasm32")]
118 pub fn write_beside_p64(&self, _p64_path: &Path) -> Result<std::path::PathBuf, String> {
119 Err("canonical Q42 model metadata is written by the native conversion tool".into())
120 }
121
122 #[cfg(not(target_arch = "wasm32"))]
124 pub fn load_beside_p64(p64_path: &Path) -> Result<Option<Self>, String> {
125 let helper_path = helper_path_for_p64(p64_path);
126 if helper_path.is_file() {
127 return Self::from_q42_path(&helper_path).map(Some);
128 }
129
130 let legacy_path = legacy_helper_path_for_p64(p64_path);
131 if !legacy_path.is_file() {
132 return Ok(None);
133 }
134 let bytes = std::fs::read(&legacy_path)
135 .map_err(|e| format!("read {}: {e}", legacy_path.display()))?;
136 Ok(Some(Self::from_cbor_ld(&bytes)?))
137 }
138
139 #[cfg(target_arch = "wasm32")]
140 pub fn load_beside_p64(_p64_path: &Path) -> Result<Option<Self>, String> {
141 Ok(None)
142 }
143
144 #[cfg(not(target_arch = "wasm32"))]
145 fn to_q42_graph(&self) -> (std::collections::HashMap<u64, String>, Vec<crate::NQuin>) {
146 use crate::frame_layout::{INLINE_TAG_BOOLEAN, INLINE_TAG_INTEGER, INLINE_VALUE_MASK};
147 use crate::q_hash;
148 use std::collections::HashMap;
149
150 let mut lex = HashMap::new();
151 let subject = q_hash(&self.p64);
152 let context = q_hash(MODEL_HELPER_CONTEXT);
153 insert_lex(&mut lex, context, MODEL_HELPER_CONTEXT);
154
155 let mut quins = Vec::new();
156 let mut string_edge = |predicate_iri: &str, value: &str, metadata: u64| {
157 let predicate = q_hash(predicate_iri);
158 let object = q_hash(value) & INLINE_VALUE_MASK;
159 insert_lex(&mut lex, predicate, predicate_iri);
160 insert_lex(&mut lex, object, value);
161 quins.push(make_quin(subject, predicate, object, context, metadata));
162 };
163
164 string_edge("rdf:type", "q42:QualiaModelHelper", 0);
165 string_edge("q42:format", &self.format, 0);
166 string_edge("q42:sourceGguf", &self.source_gguf, 0);
167 string_edge("q42:p64Asset", &self.p64, 0);
168 string_edge("q42:layout", &self.layout, 0);
169 string_edge("q42:chatFamily", &self.tokenizer.chat_family, 0);
170 for (i, value) in self.tokenizer.stop_token_strings.iter().enumerate() {
171 string_edge("q42:stopTokenString", value, i as u64);
172 }
173 for (i, value) in self.notes.iter().enumerate() {
174 string_edge("q42:note", value, i as u64);
175 }
176 drop(string_edge);
177
178 let mut integer_edge = |predicate_iri: &str, value: u64, metadata: u64| {
179 let predicate = q_hash(predicate_iri);
180 insert_lex(&mut lex, predicate, predicate_iri);
181 quins.push(make_quin(
182 subject,
183 predicate,
184 INLINE_TAG_INTEGER | (value & INLINE_VALUE_MASK),
185 context,
186 metadata,
187 ));
188 };
189 integer_edge("q42:pageLog2", self.page_log2 as u64, 0);
190 integer_edge("q42:convertedUnixMs", self.converted_unix_ms, 0);
191 integer_edge("q42:bosTokenId", self.tokenizer.bos_token_id as u64, 0);
192 integer_edge("q42:eosTokenId", self.tokenizer.eos_token_id as u64, 0);
193 integer_edge("q42:vocabLen", self.tokenizer.vocab_len as u64, 0);
194 for (i, value) in self.tokenizer.stop_token_ids.iter().enumerate() {
195 integer_edge("q42:stopTokenId", *value as u64, i as u64);
196 }
197 drop(integer_edge);
198
199 let predicate = q_hash("q42:addBosToken");
200 insert_lex(&mut lex, predicate, "q42:addBosToken");
201 quins.push(make_quin(
202 subject,
203 predicate,
204 INLINE_TAG_BOOLEAN | u64::from(self.tokenizer.add_bos_token),
205 context,
206 0,
207 ));
208
209 (lex, quins)
210 }
211
212 #[cfg(not(target_arch = "wasm32"))]
213 fn from_q42_path(path: &Path) -> Result<Self, String> {
214 use crate::frame_layout::{
215 INLINE_TAG_BOOLEAN, INLINE_TAG_INTEGER, INLINE_TAG_MASK, INLINE_VALUE_MASK,
216 };
217 use crate::q42_volume::Q42Volume;
218 use crate::q_hash;
219 use std::collections::BTreeMap;
220
221 let volume = Q42Volume::open(path)
222 .map_err(|e| format!("open canonical Q42 helper {}: {e}", path.display()))?;
223 volume
224 .header()
225 .verify_version()
226 .map_err(|e| format!("invalid Q42 helper {}: {e}", path.display()))?;
227 let lex = volume
228 .lex_view()
229 .map_err(|e| format!("invalid Q42LEX in {}: {e:?}", path.display()))?;
230 let quins = volume
231 .read_all_quins()
232 .map_err(|e| format!("read Q42 helper {}: {e}", path.display()))?;
233
234 let text = |object: u64| -> Result<String, String> {
235 lex.lookup_webizen_identity(object)
236 .or_else(|| lex.lookup_hash(object))
237 .map(str::to_owned)
238 .ok_or_else(|| format!("Q42 helper has unresolved lexicon hash {object:#018x}"))
239 };
240 let integer = |object: u64| -> Result<u64, String> {
241 if object & INLINE_TAG_MASK != INLINE_TAG_INTEGER {
242 return Err(format!(
243 "Q42 helper expected integer object, got {object:#018x}"
244 ));
245 }
246 Ok(object & INLINE_VALUE_MASK)
247 };
248
249 let mut format = None;
250 let mut source_gguf = None;
251 let mut p64 = None;
252 let mut page_log2 = None;
253 let mut layout = None;
254 let mut converted_unix_ms = None;
255 let mut bos_token_id = None;
256 let mut eos_token_id = None;
257 let mut add_bos_token = None;
258 let mut chat_family = None;
259 let mut vocab_len = None;
260 let mut stop_ids = BTreeMap::new();
261 let mut stop_strings = BTreeMap::new();
262 let mut notes = BTreeMap::new();
263 let mut saw_type = false;
264
265 for q in quins {
266 match q.predicate {
267 p if p == q_hash("rdf:type") => {
268 saw_type = text(q.object)? == "q42:QualiaModelHelper";
269 }
270 p if p == q_hash("q42:format") => format = Some(text(q.object)?),
271 p if p == q_hash("q42:sourceGguf") => source_gguf = Some(text(q.object)?),
272 p if p == q_hash("q42:p64Asset") => p64 = Some(text(q.object)?),
273 p if p == q_hash("q42:layout") => layout = Some(text(q.object)?),
274 p if p == q_hash("q42:chatFamily") => chat_family = Some(text(q.object)?),
275 p if p == q_hash("q42:stopTokenString") => {
276 stop_strings.insert(q.metadata, text(q.object)?);
277 }
278 p if p == q_hash("q42:note") => {
279 notes.insert(q.metadata, text(q.object)?);
280 }
281 p if p == q_hash("q42:pageLog2") => page_log2 = Some(integer(q.object)? as u16),
282 p if p == q_hash("q42:convertedUnixMs") => {
283 converted_unix_ms = Some(integer(q.object)?)
284 }
285 p if p == q_hash("q42:bosTokenId") => {
286 bos_token_id = Some(integer(q.object)? as u32)
287 }
288 p if p == q_hash("q42:eosTokenId") => {
289 eos_token_id = Some(integer(q.object)? as u32)
290 }
291 p if p == q_hash("q42:vocabLen") => vocab_len = Some(integer(q.object)? as u32),
292 p if p == q_hash("q42:stopTokenId") => {
293 stop_ids.insert(q.metadata, integer(q.object)? as u32);
294 }
295 p if p == q_hash("q42:addBosToken") => {
296 if q.object & INLINE_TAG_MASK != INLINE_TAG_BOOLEAN {
297 return Err("Q42 helper addBosToken is not a boolean".into());
298 }
299 add_bos_token = Some(q.object & 1 != 0);
300 }
301 _ => {}
302 }
303 }
304
305 if !saw_type {
306 return Err("Q42 volume is not a QualiaModelHelper".into());
307 }
308 let required = |name: &str| format!("Q42 model helper missing {name}");
309 Ok(Self {
310 context: MODEL_HELPER_CONTEXT.to_string(),
311 type_: "QualiaModelHelper".to_string(),
312 format: format.ok_or_else(|| required("format"))?,
313 source_gguf: source_gguf.ok_or_else(|| required("sourceGguf"))?,
314 p64: p64.ok_or_else(|| required("p64Asset"))?,
315 page_log2: page_log2.ok_or_else(|| required("pageLog2"))?,
316 layout: layout.ok_or_else(|| required("layout"))?,
317 converted_unix_ms: converted_unix_ms.ok_or_else(|| required("convertedUnixMs"))?,
318 tokenizer: ModelHelperTokenizer {
319 bos_token_id: bos_token_id.ok_or_else(|| required("bosTokenId"))?,
320 eos_token_id: eos_token_id.ok_or_else(|| required("eosTokenId"))?,
321 add_bos_token: add_bos_token.ok_or_else(|| required("addBosToken"))?,
322 chat_family: chat_family.ok_or_else(|| required("chatFamily"))?,
323 stop_token_ids: stop_ids.into_values().collect(),
324 stop_token_strings: stop_strings.into_values().collect(),
325 vocab_len: vocab_len.ok_or_else(|| required("vocabLen"))?,
326 },
327 notes: notes.into_values().collect(),
328 })
329 }
330
331 #[cfg(any(not(target_arch = "wasm32"), feature = "wasm-llm"))]
333 pub fn apply_stops_to_tokenizer(&self, tok: &mut crate::gguf_sharder::GgufTokenizer) {
334 tok.merge_stop_token_ids(&self.tokenizer.stop_token_ids);
335 }
336}
337
338#[cfg(not(target_arch = "wasm32"))]
339fn insert_lex(lex: &mut std::collections::HashMap<u64, String>, hash: u64, value: &str) {
340 lex.insert(hash, value.to_string());
341}
342
343#[cfg(not(target_arch = "wasm32"))]
344fn make_quin(
345 subject: u64,
346 predicate: u64,
347 object: u64,
348 context: u64,
349 metadata: u64,
350) -> crate::NQuin {
351 crate::NQuin {
352 subject,
353 predicate,
354 object,
355 context,
356 metadata,
357 parity: crate::NQuin::calculate_parity(subject, predicate, object, context, metadata),
359 }
360}
361
362pub fn helper_path_for_p64(p64_path: &Path) -> std::path::PathBuf {
364 helper_path_with_ext(p64_path, MODEL_HELPER_EXT)
365}
366
367pub fn legacy_helper_path_for_p64(p64_path: &Path) -> std::path::PathBuf {
368 helper_path_with_ext(p64_path, LEGACY_MODEL_HELPER_EXT)
369}
370
371fn helper_path_with_ext(p64_path: &Path, ext: &str) -> std::path::PathBuf {
372 let parent = p64_path.parent().unwrap_or_else(|| Path::new("."));
373 let stem = p64_path
374 .file_stem()
375 .and_then(|s| s.to_str())
376 .unwrap_or("model");
377 parent.join(format!("{stem}.{ext}"))
378}
379
380fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
381 if bytes.starts_with(&[0xd9, 0xd9, 0xf7]) {
382 &bytes[3..]
383 } else {
384 bytes
385 }
386}
387
388pub fn has_model_helper_magic(bytes: &[u8]) -> bool {
390 bytes.starts_with(&[0xd9, 0xd9, 0xf7])
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn sample() -> ModelHelper {
398 ModelHelper::new(
399 "x.gguf",
400 "x.p64",
401 14,
402 "Verbatim",
403 ModelHelperTokenizer {
404 bos_token_id: 1,
405 eos_token_id: 2,
406 add_bos_token: true,
407 chat_family: "Llama3".into(),
408 stop_token_ids: vec![2, 128_009],
409 stop_token_strings: vec!["</s>".into(), "<|eot_id|>".into()],
410 vocab_len: 128_256,
411 },
412 )
413 }
414
415 #[test]
416 fn cbor_ld_interchange_round_trip() {
417 let h = sample();
418 let bytes = h.to_cbor_ld().expect("encode");
419 assert!(has_model_helper_magic(&bytes));
420 assert_eq!(ModelHelper::from_cbor_ld(&bytes).unwrap(), h);
421 }
422
423 #[cfg(not(target_arch = "wasm32"))]
424 #[test]
425 fn canonical_q42_round_trip_preserves_model_metadata() {
426 use crate::q42_volume::{Q42Volume, Q42_MAGIC, Q42_VERSION_V3};
427
428 let dir = tempfile::tempdir().unwrap();
429 let p64 = dir.path().join("x.p64");
430 std::fs::write(&p64, b"p64\0").unwrap();
431 let h = sample();
432 let path = h.write_beside_p64(&p64).unwrap();
433 assert_eq!(path.extension().and_then(|x| x.to_str()), Some("q42"));
434 assert!(std::fs::read(&path).unwrap().starts_with(&Q42_MAGIC));
435 let volume = Q42Volume::open(&path).unwrap();
436 assert_eq!({ volume.header().version }, Q42_VERSION_V3);
437 volume
438 .verify_all_blocks()
439 .expect("canonical helper must pass five-field ECC + BIDX");
440 assert!(volume.header().flags & crate::q42_volume::FLAG_FIELD_POSTINGS != 0);
441 assert!(volume.header().flags & crate::q42_volume::FLAG_FIELD_RANGES != 0);
442 assert_eq!(ModelHelper::load_beside_p64(&p64).unwrap(), Some(h));
443 }
444
445 #[test]
446 fn helper_path_is_plain_q42() {
447 let p = Path::new(r"C:\LLM_Models\P64\smollm2.f16.p64");
448 assert!(helper_path_for_p64(p)
449 .to_string_lossy()
450 .ends_with("smollm2.f16.q42"));
451 }
452}