File size: 4,600 Bytes
ca2a6e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e74009
 
d66e440
 
 
 
 
ca2a6e7
 
 
 
 
6e74009
 
 
ca2a6e7
 
 
 
 
 
 
 
6e74009
 
 
ca2a6e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import { env, PreTrainedModel, AutoTokenizer, Tensor } from '@huggingface/transformers';

export const MODEL_ID = 'raincandy-u/MacroStories';
export const REVISION = 'dc1ef998bf5331f7f70a77c815ed3e4f643a0ba7';
export const MODEL_PATH = 'macrostories';

env.allowRemoteModels = false;
env.allowLocalModels = true;
// Keep this an origin-relative path: Transformers.js metadata discovery treats
// absolute HTTP paths as remote even when they refer to our own static assets.
env.localModelPath = new URL('../models/', import.meta.url).pathname;
env.backends.onnx.wasm.wasmPaths = new URL('../vendor/', import.meta.url).href;
// A static HF Space iframe doesn't need SharedArrayBuffer or COOP/COEP headers.
env.backends.onnx.wasm.numThreads = 1;
env.backends.onnx.wasm.proxy = false;

export async function loadRuntime(device = 'wasm', progress = () => {}) {
  if (!['wasm', 'webgpu'].includes(device)) throw new Error('Choose WebAssembly or WebGPU.');
  if (device === 'webgpu') {
    if (!globalThis.navigator?.gpu || !await navigator.gpu.requestAdapter()) {
      throw new Error('No WebGPU adapter available.');
    }
  }
  const [tokenizer, vocabulary, provenance] = await Promise.all([
    AutoTokenizer.from_pretrained(MODEL_PATH, { local_files_only: true }),
    fetch(new URL('../models/macrostories/tokenizer.json', import.meta.url)).then(r => { if (!r.ok) throw new Error('Tokenizer file missing.'); return r.json(); }),
    fetch(new URL('../models/macrostories/provenance.json', import.meta.url)).then(r => { if (!r.ok) throw new Error('Model provenance missing.'); return r.json(); }),
  ]);
  // Fallback belongs to BackendController: never reuse ORT after GPU failure.
  const runtime = await createRuntime(device, progress);
  return { ...runtime, tokenizer, vocabulary: vocabulary.model.vocab, provenance };
}

async function createRuntime(device, progress) {
  progress({ status: 'runtime', device });
  // Ouro isn't a registered AutoModelForCausalLM architecture. The public base
  // class loads the exported graph; our loop supplies its custom four-pass KV cache.
  const model = await PreTrainedModel.from_pretrained(MODEL_PATH, {
    device, dtype: 'fp32', local_files_only: true, progress_callback: progress,
    // Keep the growing KV cache on the GPU. Only the 378 logits need readback
    // for sampling; copying all past keys/values out and back per token is wasteful.
    session_options: device === 'webgpu' ? { preferredOutputLocation: { present_key_values: 'gpu-buffer', logits: 'cpu' } } : {},
  });
  const decoder = new CachedDecoder(model);
  try {
    const logits = await decoder.forward(1);
    if (logits.length !== 378 || !logits.every(Number.isFinite)) throw new Error('Runtime produced invalid logits.');
    decoder.reset();
  } catch (error) {
    await model.dispose();
    throw error;
  }
  return { model, decoder, device };
}

export class CachedDecoder {
  constructor(model) { this.model = model; this.reset(); }
  reset() {
    this.past?.dispose();
    this.position = 0;
    this.past = new Tensor('float32', new Float32Array(0), [4, 2, 0, 16]);
  }
  async forward(token) {
    if (!Number.isInteger(token) || token < 0 || token >= 378) throw new Error('Invalid token ID.');
    if (this.position >= 2048) throw new Error('The 2,048-token context is full.');
    const input = new Tensor('int64', BigInt64Array.of(BigInt(token)), [1]);
    const position = new Tensor('int64', BigInt64Array.of(BigInt(this.position)), [1]);
    let output;
    try {
      output = await this.model({ input_ids: input, position, past_key_values: this.past });
      const logits = Float32Array.from(output.logits.data);
      this.past.dispose();
      this.past = output.present_key_values;
      this.position++;
      return logits;
    } finally {
      input.dispose();
      position.dispose();
      output?.logits.dispose();
    }
  }
}

export function encodePrompt(tokenizer, text) {
  const canonical = text.replace(/\b(Alex|Robin|Casey)\b/g, name => `[PERSON_NAME_${['Alex', 'Robin', 'Casey'].indexOf(name) + 1}]`);
  const ids = Array.from(tokenizer(canonical).input_ids.data, Number);
  return { ids, unknown: ids.filter(id => id === 3).length, canonical };
}

export function renderStory(tokenizer, ids) {
  const display = ids.filter(id => ![0, 1, 2].includes(id));
  if (!display.length) return { raw: '', text: '' };
  const raw = tokenizer.decode(display, { skip_special_tokens: false }).trim();
  const text = raw.replace(/\[PERSON_NAME_(\d+)\]/g, (match, n) => ['Alex', 'Robin', 'Casey'][Number(n) - 1] ?? match);
  return { raw, text };
}