diff --git a/cmd/socair-preview/main.go b/cmd/socair-preview/main.go new file mode 100644 index 0000000..1a3eb8e --- /dev/null +++ b/cmd/socair-preview/main.go @@ -0,0 +1,102 @@ +//go:build js && wasm + +// Command socair-preview is Socair's engine built for a browser, for +// socair.ai's preview of a Hugging Face model. The page mounts the repo as a +// filesystem whose reads are HTTP range requests (tools/socair-preview) and +// calls socairPreview(dir, hashesJSON), which reads it with engine.PreviewDir +// and resolves to the report as JSON and as HTML. +package main + +import ( + "bytes" + "encoding/json" + "syscall/js" + + "github.com/defilantech/socair/internal/engine" + "github.com/defilantech/socair/internal/render" + rpt "github.com/defilantech/socair/internal/report" +) + +func main() { + js.Global().Set("socairPreview", js.FuncOf(preview)) + js.Global().Set("socairPreviewFile", js.FuncOf(previewFile)) + js.Global().Set("socairVersion", engine.Version) + select {} +} + +// preview returns a Promise for the report of the directory args[0], with +// each file's SHA-256 as its source states it in the JSON object args[1]. +func preview(_ js.Value, args []js.Value) any { + if len(args) != 2 { + return js.Global().Get("Promise").Call("reject", "socairPreview(dir, hashesJSON)") + } + dir, hashesJSON := args[0].String(), args[1].String() + var run js.Func + run = js.FuncOf(func(_ js.Value, p []js.Value) any { + resolve, reject := p[0], p[1] + go func() { + defer run.Release() + out, err := report(dir, hashesJSON) + if err != nil { + reject.Invoke(err.Error()) + return + } + resolve.Invoke(js.ValueOf(out)) + }() + return nil + }) + return js.Global().Get("Promise").New(run) +} + +// previewFile returns a Promise for the header-only report of the single file +// args[0], for a file whose own metadata carries checks a directory scan +// does not run on it (a GGUF's chat template, tokenizer, and quant). +func previewFile(_ js.Value, args []js.Value) any { + if len(args) != 1 { + return js.Global().Get("Promise").Call("reject", "socairPreviewFile(path)") + } + path := args[0].String() + var run js.Func + run = js.FuncOf(func(_ js.Value, p []js.Value) any { + resolve, reject := p[0], p[1] + go func() { + defer run.Release() + d, err := engine.ScanMode(path, engine.ModeHeaders) + if err == nil { + var out map[string]any + if out, err = documents(d); err == nil { + resolve.Invoke(js.ValueOf(out)) + return + } + } + reject.Invoke(err.Error()) + }() + return nil + }) + return js.Global().Get("Promise").New(run) +} + +func report(dir, hashesJSON string) (map[string]any, error) { + var hashes map[string]string + if err := json.Unmarshal([]byte(hashesJSON), &hashes); err != nil { + return nil, err + } + d, err := engine.PreviewDir(dir, hashes) + if err != nil { + return nil, err + } + return documents(d) +} + +// documents is the report as JSON and as the HTML a scan renders. +func documents(d *rpt.Document) (map[string]any, error) { + doc, err := json.Marshal(d) + if err != nil { + return nil, err + } + var html bytes.Buffer + if err := render.Render(&html, d); err != nil { + return nil, err + } + return map[string]any{"json": string(doc), "html": html.String()}, nil +} diff --git a/internal/engine/preview.go b/internal/engine/preview.go new file mode 100644 index 0000000..f5f8cec --- /dev/null +++ b/internal/engine/preview.go @@ -0,0 +1,29 @@ +package engine + +import ( + "time" + + "github.com/defilantech/socair/internal/report" +) + +// PreviewDir reads a model directory the way a scan does, in place and as +// far as each check reads, and never hashes a file: each file's SHA-256 is +// the one stated gives for its path, as its source (a hub's API) states it, +// and every file needs one. Nothing it reads is copied, so a reader that +// fetches byte ranges on demand (socair.ai's browser preview) fetches only +// what the checks read: headers, configs, templates, tokenizers. +// +// It is a preview, never an attestation: the subject is the manifest digest +// of hashes the scan did not compute, and the caller must say so. Tier 2 +// does not run. +func PreviewDir(dir string, stated map[string]string) (*report.Document, error) { + start := time.Now().UTC() + refs, err := loadReferences(start) + if err != nil { + return nil, err + } + if stated == nil { + stated = map[string]string{} + } + return scanDir(dir, start, refs, Inputs{}, nil, stated) +} diff --git a/internal/engine/preview_test.go b/internal/engine/preview_test.go new file mode 100644 index 0000000..22b9ea4 --- /dev/null +++ b/internal/engine/preview_test.go @@ -0,0 +1,89 @@ +package engine + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/defilantech/socair/internal/modeldir" + "github.com/defilantech/socair/internal/report" +) + +// stated returns each file's true SHA-256, as a hub would state it. +func stated(t *testing.T, dir string) map[string]string { + t.Helper() + _, files, err := modeldir.Hash(dir) + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + for _, f := range files { + m[f.Path] = f.SHA256 + } + return m +} + +func TestPreviewReadsInPlaceWithStatedHashes(t *testing.T) { + dir := modelRepo(t, nil) + hashes := stated(t, dir) + // A stated hash is reported as stated: the preview never hashes a file. + hashes["model.safetensors"] = strings.Repeat("ab", 32) + // Falsification: a snapshot needs this volume, which does not exist. + t.Setenv("SOCAIR_SCAN_TMP", filepath.Join(t.TempDir(), "missing")) + + d, err := PreviewDir(dir, hashes) + if err != nil { + t.Fatal(err) + } + if problems := report.Validate(d); len(problems) != 0 { + t.Fatal(problems) + } + for _, f := range d.Artifact.Files { + if f.SHA256 != hashes[f.Path] { + t.Errorf("%s: %s, want the stated %s", f.Path, f.SHA256, hashes[f.Path]) + } + } + for name, want := range map[string]report.Status{ + "Format and structure": report.StatusPass, "Chat template": report.StatusPass, + "Remote code": report.StatusPass, "Tokenizer config": report.StatusPass, + } { + if got := row(d, name).Status; got != want { + t.Errorf("%s = %s (%s), want %s", name, got, row(d, name).Notes, want) + } + } + if d.Artifact.Name != "org/tiny" { + t.Errorf("name %q", d.Artifact.Name) + } +} + +func TestPreviewNeedsEveryFilesHash(t *testing.T) { + dir := modelRepo(t, nil) + hashes := stated(t, dir) + delete(hashes, "config.json") + if _, err := PreviewDir(dir, hashes); err == nil || !strings.Contains(err.Error(), "config.json") { + t.Fatalf("a file with no stated hash must stop the preview, naming it: %v", err) + } +} + +func TestPreviewFindsWhatAScanFinds(t *testing.T) { + dir := modelRepo(t, map[string]string{"modeling_tiny.py": "import os\n", "config.json": `{"auto_map":{"AutoModel":"modeling_tiny.TinyModel"}}`}) + full, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + d, err := PreviewDir(dir, stated(t, dir)) + if err != nil { + t.Fatal(err) + } + if d.Artifact.SHA256 != full.Artifact.SHA256 { + t.Errorf("subject %s, a full scan's %s", d.Artifact.SHA256, full.Artifact.SHA256) + } + for _, c := range full.Checks { + if got := row(d, c.Name); got.Status != c.Status { + t.Errorf("%s: preview %s, full scan %s", c.Name, got.Status, c.Status) + } + } + if row(d, "Remote code").Status != report.StatusLead { + t.Errorf("remote code = %s, want LEAD", row(d, "Remote code").Status) + } +} diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 30a3116..ba0f092 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -109,7 +109,7 @@ func ScanWith(path string, mode Mode, in Inputs) (*report.Document, error) { if mode != ModeFull { return nil, fmt.Errorf("%s is a directory; a directory scan always reads every file in full", path) } - return scanDir(path, start, refs, in, t2) + return scanDir(path, start, refs, in, t2, nil) } // A full scan is an attestation, so it checks a private snapshot whose diff --git a/internal/engine/scandir.go b/internal/engine/scandir.go index a26e1fc..e598584 100644 --- a/internal/engine/scandir.go +++ b/internal/engine/scandir.go @@ -40,8 +40,11 @@ var pickleExt = map[string]bool{".bin": true, ".pt": true, ".pth": true, ".ckpt" // Every file is snapshotted and hashed; the subject is the manifest digest. // Each per-file check runs on every file it applies to and reports one merged // row, so one bad shard withholds the whole directory. -func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2.Config) (*report.Document, error) { - root, files, excluded, cleanup, err := modeldir.Snapshot(dir, strings.TrimSpace(os.Getenv("SOCAIR_SCAN_TMP"))) +// +// With stated hashes it is a preview instead (PreviewDir): the files are read +// in place, as far as each check reads them, and never hashed. +func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2.Config, stated map[string]string) (*report.Document, error) { + root, files, excluded, cleanup, err := snapshotOrList(dir, stated) var short *diskfree.ShortError if errors.As(err, &short) { return nil, fmt.Errorf("%w (set SOCAIR_SCAN_TMP to a volume with room)", err) @@ -50,7 +53,7 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 return nil, err } defer cleanup() - if afterSnapshot != nil { + if afterSnapshot != nil && stated == nil { afterSnapshot(dir) } at := func(f modeldir.File) string { return filepath.Join(root, filepath.FromSlash(f.Path)) } @@ -280,6 +283,28 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 return d, nil } +// snapshotOrList is a scan's private snapshot of dir, or, for a preview, the +// directory itself with the hashes its source states, every file needing one. +func snapshotOrList(dir string, stated map[string]string) (string, []modeldir.File, []modeldir.Excluded, func(), error) { + if stated == nil { + return modeldir.Snapshot(dir, strings.TrimSpace(os.Getenv("SOCAIR_SCAN_TMP"))) + } + files, excluded, err := modeldir.List(dir, func(rel string) string { return stated[rel] }) + if err != nil { + return "", nil, nil, nil, err + } + var missing []string + for _, f := range files { + if f.SHA256 == "" { + missing = append(missing, f.Path) + } + } + if len(missing) > 0 { + return "", nil, nil, nil, fmt.Errorf("no stated hash for %s", strings.Join(missing, ", ")) + } + return dir, files, excluded, func() {}, nil +} + // kerasExt are the extensions of Keras models: HDF5 files and .keras // archives. A file of one is read as Keras when its bytes are HDF5 or a // .keras archive (isKeras), whichever its extension names; otherwise it is diff --git a/internal/modeldir/list_test.go b/internal/modeldir/list_test.go new file mode 100644 index 0000000..e113a3b --- /dev/null +++ b/internal/modeldir/list_test.go @@ -0,0 +1,42 @@ +package modeldir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestListStatesHashesWithoutReadingAFile(t *testing.T) { + dir := t.TempDir() + for rel, body := range map[string]string{"config.json": "{}", "model.safetensors": "weights"} { + if err := os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + // Falsification: a file nobody can read fails any walk that opens it. + locked := filepath.Join(dir, "model.safetensors") + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o644) }) + if f, err := os.Open(locked); err == nil { + f.Close() + t.Skip("running as a user who reads any file") + } + + files, _, err := List(dir, func(rel string) string { return "stated:" + rel }) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("%d files", len(files)) + } + for _, f := range files { + if f.SHA256 != "stated:"+f.Path { + t.Errorf("%s: hash %q", f.Path, f.SHA256) + } + } + if files[1].Path != "model.safetensors" || files[1].Size != int64(len("weights")) || files[1].Role != RoleWeights { + t.Errorf("%+v", files[1]) + } +} diff --git a/internal/modeldir/modeldir.go b/internal/modeldir/modeldir.go index e3b0ed3..8b90fb7 100644 --- a/internal/modeldir/modeldir.go +++ b/internal/modeldir/modeldir.go @@ -221,6 +221,23 @@ func Hash(dir string) (string, []File, error) { return Digest(files), files, nil } +// List walks dir in the scan's rules and reads no file: each file's digest +// is the one stated gives for its path ("" when it gives none), and its size +// is its size on disk. It is for a header-only preview, which attests nothing +// and names where its hashes came from. +func List(dir string, stated func(rel string) string) ([]File, []Excluded, error) { + if err := isDir(dir); err != nil { + return nil, nil, err + } + return walk(dir, func(src, rel string) (string, int64, error) { + st, err := os.Stat(src) + if err != nil { + return "", 0, err + } + return stated(rel), st.Size(), nil + }) +} + func isDir(dir string) error { fi, err := os.Stat(dir) if err != nil { diff --git a/tools/socair-preview/.gitignore b/tools/socair-preview/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/tools/socair-preview/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/tools/socair-preview/README.md b/tools/socair-preview/README.md new file mode 100644 index 0000000..d761643 --- /dev/null +++ b/tools/socair-preview/README.md @@ -0,0 +1,87 @@ +# socair-preview (prototype) + +Put `socair.ai/` in front of a Hugging Face model URL and see what Socair's +checks find, without installing anything: + +``` +https://socair.ai/huggingface.co/nvidia/GLM-5.3-Flash-NVFP4 +``` + +The visitor's browser runs the engine, compiled to WebAssembly +(`cmd/socair-preview`), on the model's headers and small files. It fetches +them straight from Hugging Face with HTTP range requests. Nothing is uploaded +and nothing is stored, and socair.ai stays a static site. + +A preview is not an attestation: + +- **Unsigned.** It is the visitor's own run, like running the CLI. +- **Headers only.** No weight bytes are read. +- **No promotion verdict.** Whether a model is approved is the organization's + call, so the page shows the rows and leaves the decision out. + +## How it works + +- **`hffs.js`** is a read-only filesystem in the shape Go's js/wasm runtime + calls (`globalThis.fs`). + - The repo is mounted where the Hugging Face cache would hold it + (`/hub/models--org--name/snapshots/`), so the engine names the + model by its repo. + - Each read becomes range requests, cached in 16 KiB blocks. A window + doubles while a file is read sequentially, and a read after a seek is cut + short, so a parser that hops from header to header fetches a block at + each hop. + - `warm()` fetches every safetensors header and GGUF metadata in parallel + first. That only fills the cache: the engine still reads what it reads. +- **`engine.PreviewDir`** scans a directory in place and hashes nothing. + - Each file's SHA-256 is supplied: an LFS file's as the hub states it, a + small file's computed from its bytes. + - `ScanMode` still refuses a header-only directory scan. +- **A GGUF** is also scanned on its own (`engine.ScanMode(path, ModeHeaders)`), + for the chat template, tokenizer and quant rows that a directory scan does + not run on it. +- **Selection.** For a model served from safetensors, the preview reads the + selection `docs/airlock.md` recommends, plus `*.py` so the Remote code row + judges any Python. The page prints it as the airlock pull's `--include`. +- **All or nothing.** If any read fails (a budget passed, a hub error), no + row is shown: a check that could not read a file must never look like it + did. + +## Measured (2026-10-15, Node and Chromium, home broadband) + +| Repo | In the repo | Read | Time | Rows | +|---|---|---|---|---| +| HuggingFaceTB/SmolLM2-135M-Instruct | 2.0 GB | 3.7 MB | 1.2–1.8 s | 5 PASS, provenance NOT_TESTED | +| microsoft/Phi-3-mini-4k-instruct | 7.6 GB | 3.1 MB | 1.3 s | Remote code LEAD | +| openai-community/gpt2 (selection) | 5.6 GB | 6.2 MB | 2.1 s | 4 PASS | +| Qwen/Qwen3-0.6B | 1.5 GB | 16 MB | 7.6 s | 5 PASS | +| Qwen/Qwen3-0.6B-GGUF (+ GGUF on its own) | 0.64 GB | 6.4 MB | 4.3 s | chat template, tokenizer, quant PASS | +| nvidia/GLM-5.3-Flash-NVFP4 | 204.5 GB | 57.5 MB | 16 s | 6 rows, 33 shard layouts verified | + +**Fidelity check.** SmolLM2's selection was pulled with +`socair airlock pull --include …` at the same commit and scanned in full. + +- **Subject digest:** the same as the preview's (`29165dae…`), which is also + the airlock's staged digest. +- **Files:** the same 13, with the same hashes. +- **Rows:** every row has the same status and the same notes, word for word. + +**Size.** `socair.wasm` is 17 MB, 4.2 MB gzip and 3.0 MB brotli. + +## Run it + +``` +tools/socair-preview/build.sh # dist/: socair.wasm, wasm_exec.js, the page +node tools/socair-preview/serve.mjs # http://127.0.0.1:4321/huggingface.co/org/name +node tools/socair-preview/try.mjs org/name [revision] [--all] # the same path in Node +``` + +## Not done + +- **Gated repos** stop with a message. The preview takes no HF token. +- **Formats with many seeks** are slow to preview: an ONNX file with inline + weights, or a legacy (pre-zip) torch pickle. The selection leaves them out + when safetensors are present; `?all` previews everything. +- **Hosting.** On socair.ai this needs: + - a Netlify rewrite `/huggingface.co/*` to the page; + - the page under `noindex`; + - the site's design. diff --git a/tools/socair-preview/app.js b/tools/socair-preview/app.js new file mode 100644 index 0000000..91fcf6d --- /dev/null +++ b/tools/socair-preview/app.js @@ -0,0 +1,160 @@ +// The preview page: parses the Hugging Face model from the URL, mounts it as +// a filesystem over the hub (hffs.js), runs the engine (socair.wasm) on it, +// and shows the rows. Every value shown comes from the engine's report or the +// hub's listing, and is set as text, never as markup: the notes quote the +// model's own files. +import { HubFS, listRepo, previewSelection } from './hffs.js'; + +const $ = (id) => document.getElementById(id); +const MB = (n) => `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)} MB`; +const GB = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : MB(n)); + +// Go reads globalThis.fs once, when it starts; each preview swaps the hub +// behind this proxy. +let current = new HubFS({ repo: 'none/none', commit: 'none', files: [] }); +globalThis.fs = new Proxy({}, { + get(_, key) { + const v = current[key]; + return typeof v === 'function' ? v.bind(current) : v; + } +}); +let engine; +function startEngine() { + engine ??= (async () => { + const go = new globalThis.Go(); + globalThis.process.cwd = () => '/'; + const { instance } = await WebAssembly.instantiateStreaming(fetch('/socair.wasm'), go.importObject); + go.run(instance); + })(); + return engine; +} + +/** org/name and a revision from a URL, a host path, or a bare repo id. */ +export function parseTarget(s) { + s = decodeURIComponent(s.trim()).replace(/^\/+/, '').replace(/^https?:\/+/, '').replace(/^(www\.)?huggingface\.co\//, '').replace(/^hf\.co\//, ''); + const m = s.match(/^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)(?:\/(?:tree|blob|resolve)\/([^/?#]+))?/); + if (!m) return null; + return { repo: `${m[1]}/${m[2]}`, revision: m[3] || 'main' }; +} + +function chip(status) { + const s = document.createElement('span'); + s.className = `chip ${status}`; + s.textContent = status === 'NOT_TESTED' ? 'NOT TESTED' : status; + return s; +} + +function rowsInto(tbody, checks) { + for (const c of checks) { + const tr = tbody.insertRow(); + tr.insertCell().append(chip(c.status)); + tr.cells[0].className = 'st'; + const td = tr.insertCell(); + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = c.name; + const notes = document.createElement('div'); + notes.className = 'notes'; + notes.textContent = c.notes || ''; + td.append(name, notes); + if (c.status === 'PASS' && c.pass_means) { + const means = document.createElement('div'); + means.className = 'notes'; + means.textContent = `A PASS here means: ${c.pass_means}`; + td.append(means); + } + } +} + +async function preview(target) { + const { repo, revision } = target; + $('go').disabled = true; + $('result').classList.add('hidden'); + $('incomplete').classList.add('hidden'); + $('rows').replaceChildren(); + $('files').replaceChildren(); + const t0 = performance.now(); + const say = (s) => ($('status').textContent = s); + try { + say(`Listing ${repo} on Hugging Face…`); + const [listing] = await Promise.all([listRepo(repo, revision), startEngine()]); + if (listing.gated) { + say(`${listing.repo} is gated on Hugging Face: its files open only to accounts that accepted its terms, and the preview takes no token. Pull it into your airlock with your HF_TOKEN instead.`); + return; + } + const all = new URLSearchParams(location.search).has('all'); + const sel = all ? { include: [], kept: listing.files, left: [] } : previewSelection(listing.files); + const hub = new HubFS({ + ...listing, files: sel.kept, + onFetch: () => say(`Reading headers and small files: ${MB(hub.fetched)} in ${hub.requests} requests…`) + }); + current = hub; + const [hashes] = await Promise.all([hub.hashes(), hub.warm()]); + say('Running the checks…'); + const docs = [JSON.parse((await globalThis.socairPreview(hub.root, JSON.stringify(hashes))).json)]; + // A GGUF's own metadata (chat template, tokenizer, quant) is read by + // a scan of the file on its own. + const ggufs = sel.kept.filter((f) => f.path.endsWith('.gguf')); + if (ggufs.length) docs.push(JSON.parse((await globalThis.socairPreviewFile(`${hub.root}/${ggufs[0].path}`)).json)); + const secs = ((performance.now() - t0) / 1000).toFixed(1); + + if (hub.failures.length) { + $('failures').textContent = hub.failures.map((f) => `${f.path}: ${f.reason}`).join('\n'); + $('incomplete').classList.remove('hidden'); + say(`Stopped after ${MB(hub.fetched)} in ${secs} s.`); + return; + } + const d = docs[0]; + const total = listing.files.reduce((n, f) => n + (f.size || 0), 0); + $('model').textContent = `${listing.repo} at ${listing.commit.slice(0, 12)}`; + const license = d.artifact.license?.id || d.artifact.license?.name; + $('facts').textContent = [ + `${sel.kept.length} of ${listing.files.length} files (${GB(total)} in the repo)`, + `read ${MB(hub.fetched)} in ${hub.requests} requests, ${secs} s`, + d.artifact.architecture, + license && `license ${license}` + ].filter(Boolean).join(' · '); + rowsInto($('rows'), d.checks); + for (const g of docs.slice(1)) { + const h = document.createElement('h2'); + h.textContent = `${g.artifact.file_name}, on its own`; + const card = document.createElement('div'); + card.className = 'card'; + const table = document.createElement('table'); + rowsInto(table.createTBody(), g.checks); + card.append(table); + $('files').append(h, card); + } + const notRun = (d.out_of_scope.not_run || []).map((n) => n.name).filter(Boolean); + $('notrun').textContent = notRun.length ? `Not run here: ${[...new Set(notRun)].join('; ')}.` : ''; + $('digest').textContent = `sha256:${d.artifact.sha256}`; + const name = listing.repo.split('/')[1]; + const store = `~/.socair/store/incoming/${d.artifact.sha256}`; + $('commands').textContent = [ + `socair airlock pull --repo ${listing.repo} --revision ${listing.commit}` + (sel.include.length ? ` \\\n --include '${sel.include.join(',')}'` : ''), + `SOCAIR_PROVENANCE=${store}/provenance.json \\\n socair scan ${store}/${name} > report.json`, + `socair sign --key your.key --report report.json` + ].join('\n'); + $('left').textContent = sel.left.length ? sel.left.map((f) => `${f.path} (${GB(f.size || 0)})`).join('\n') : 'none'; + $('result').classList.remove('hidden'); + say(`Previewed in ${secs} s.`); + } catch (e) { + say(`Could not preview ${repo}: ${e.message || e}`); + } finally { + $('go').disabled = false; + } +} + +$('form').addEventListener('submit', (e) => { + e.preventDefault(); + const t = parseTarget($('target').value); + if (!t) return ($('status').textContent = 'Name a model as huggingface.co/org/name.'); + history.pushState(null, '', `/huggingface.co/${t.repo}${t.revision !== 'main' ? `/tree/${t.revision}` : ''}`); + preview(t); +}); + +const fromURL = parseTarget(location.pathname) || parseTarget(new URLSearchParams(location.search).get('hf') || ''); +if (fromURL) { + $('target').value = `huggingface.co/${fromURL.repo}`; + preview(fromURL); +} diff --git a/tools/socair-preview/build.sh b/tools/socair-preview/build.sh new file mode 100755 index 0000000..16853f8 --- /dev/null +++ b/tools/socair-preview/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Builds the browser preview into dist/: the engine as WebAssembly, Go's +# wasm_exec.js loader for it, and the page. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +out="$here/dist" +mkdir -p "$out" +(cd "$repo" && GOOS=js GOARCH=wasm GOFLAGS=-mod=vendor CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -buildid= -X github.com/defilantech/socair/internal/engine.Version=${SOCAIR_VERSION:-preview}" \ + -o "$out/socair.wasm" ./cmd/socair-preview) +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$out/" +cp "$here/hffs.js" "$here/app.js" "$here/index.html" "$out/" +gzip -9 -k -f "$out/socair.wasm" +ls -l "$out" diff --git a/tools/socair-preview/hffs.js b/tools/socair-preview/hffs.js new file mode 100644 index 0000000..62ad79f --- /dev/null +++ b/tools/socair-preview/hffs.js @@ -0,0 +1,343 @@ +// A read-only filesystem over one Hugging Face repo at one commit, in the +// shape Go's js/wasm runtime calls (globalThis.fs, Node's callback API). A +// read becomes HTTP range requests to the hub, cached in blocks, so the +// engine fetches only the bytes its checks read: headers, configs, +// templates, tokenizers. Nothing is written, and no file is fetched whole +// unless it is small. +// +// The repo is mounted where the Hugging Face cache would hold it, +// /hub/models--org--name/snapshots/, so the engine names it by its repo. + +const KiB = 1024; +const MiB = 1024 * KiB; + +const S_IFDIR = 0o040000; +const S_IFREG = 0o100000; + +function fsError(code, message) { + const e = new Error(message || code); + e.code = code; + return e; +} + +/** The repo's commit and files, from the hub's API. */ +export async function listRepo(repo, revision = 'main', { endpoint = 'https://huggingface.co', token } = {}) { + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const url = `${endpoint}/api/models/${repo}/revision/${encodeURIComponent(revision)}?blobs=true`; + const res = await fetch(url, { headers }); + if (res.status === 401 || res.status === 403) { + throw new Error(`${repo} is gated or private: the hub answered ${res.status}`); + } + if (res.status === 404) throw new Error(`${repo} at ${revision}: no such repo or revision on the hub`); + if (!res.ok) throw new Error(`the hub answered ${res.status} for ${url}`); + const info = await res.json(); + const files = (info.siblings || []).map((s) => ({ + path: s.rfilename, + size: s.lfs ? s.lfs.size : s.size, + // An LFS file's oid is its SHA-256; a small file's is a git blob id. + sha256: s.lfs ? s.lfs.sha256 : null + })); + return { repo: info.id || repo, commit: info.sha, files, gated: info.gated || false }; +} + +/** Translates one huggingface_hub pattern (fnmatch) to a RegExp, as the + * airlock's --include and --exclude read it: * matches any run of + * characters, slashes included; ? one; [seq] and [!seq] one in or not in + * seq; a pattern ending in / matches everything under that directory. */ +export function globRegExp(pattern) { + if (pattern.endsWith('/')) pattern += '*'; + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') re += '.*'; + else if (c === '?') re += '.'; + else if (c === '[') { + const j = pattern.indexOf(']', i + 1); + if (j === -1) throw new Error(`an unclosed [ in ${pattern}`); + let set = pattern.slice(i + 1, j).replace(/\\/g, '\\\\'); + if (set.startsWith('!')) set = '^' + set.slice(1); + re += `[${set}]`; + i = j; + } else re += c.replace(/[.+^${}()|\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +/** The files the preview reads: for a model served from safetensors, the + * selection docs/airlock.md recommends, plus any Python a loader could be + * told to import, so the Remote code row judges it; otherwise every file. + * Returns the patterns, as the airlock pull's flags, and what they keep. */ +export function previewSelection(files) { + const paths = files.map((f) => f.path); + if (!paths.some((p) => p.endsWith('.safetensors'))) return { include: [], kept: files, left: [] }; + const include = ['*.safetensors', '*.safetensors.index.json', '*.json', '*.jinja', '*.txt', '*.model', 'README.md', 'LICENSE*', '*.py']; + const res = include.map(globRegExp); + const kept = [], left = []; + for (const f of files) (res.some((r) => r.test(f.path)) ? kept : left).push(f); + return { include, kept, left }; +} + +export class HubFS { + constructor({ repo, commit, files, endpoint = 'https://huggingface.co', token, block = 16 * KiB, + maxWindow = 4 * MiB, wholeBelow = 2 * MiB, perFileBudget = 64 * MiB, budget = 256 * MiB, onFetch = () => {} }) { + Object.assign(this, { repo, commit, endpoint, token, block, maxWindow, wholeBelow, perFileBudget, budget, onFetch }); + // Every read that failed, by file. One failed read makes the whole + // preview incomplete: a check that could not read a file must never + // be shown as having read it. + this.failures = []; + this.root = `/hub/models--${repo.replace('/', '--')}/snapshots/${commit}`; + this.nodes = new Map(); // absolute path -> {dir, size, path, children} + this.fds = new Map(); + this.nextFd = 10; + this.fetched = 0; + this.requests = 0; + this.byFile = new Map(); // repo path -> bytes fetched + this.blocks = new Map(); // repo path -> Map(blockIndex -> Promise) + this.stdout = ''; + this.constants = { O_WRONLY: 1, O_RDWR: 2, O_CREAT: 64, O_EXCL: 128, O_TRUNC: 512, O_APPEND: 1024, O_DIRECTORY: 65536 }; + this.dir('/'); + for (const f of files) { + const abs = `${this.root}/${f.path}`; + this.parents(abs); + this.nodes.set(abs, { dir: false, size: f.size, path: f.path, sha256: f.sha256 }); + this.nodes.get(parentOf(abs)).children.add(baseOf(abs)); + } + } + + dir(abs) { + if (!this.nodes.has(abs)) { + this.nodes.set(abs, { dir: true, size: 0, children: new Set() }); + if (abs !== '/') { + this.parents(abs); + this.nodes.get(parentOf(abs)).children.add(baseOf(abs)); + } + } + } + + parents(abs) { + const p = parentOf(abs); + if (p !== abs) this.dir(p); + } + + node(path) { + return this.nodes.get(normalize(path)); + } + + statOf(node, ino = 1) { + const now = Date.now(); + return { + dev: 1, ino, mode: node.dir ? S_IFDIR | 0o555 : S_IFREG | 0o444, nlink: 1, uid: 0, gid: 0, rdev: 0, + size: node.size, blksize: 4096, blocks: Math.ceil(node.size / 512), + atimeMs: now, mtimeMs: now, ctimeMs: now, + isDirectory: () => node.dir + }; + } + + url(path) { + const enc = path.split('/').map(encodeURIComponent).join('/'); + return `${this.endpoint}/${this.repo}/resolve/${this.commit}/${enc}`; + } + + /** Fetches bytes [start, end) of a file, from the cache in blocks. */ + async bytes(node, start, end) { + end = Math.min(end, node.size); + if (start >= end) return new Uint8Array(0); + const whole = node.size <= this.wholeBelow; + const size = whole ? node.size : this.block; + const first = Math.floor(start / size), last = Math.floor((end - 1) / size); + let cache = this.blocks.get(node.path); + if (!cache) this.blocks.set(node.path, (cache = new Map())); + // One request for each run of missing blocks. + for (let i = first; i <= last; ) { + if (cache.has(i)) { i++; continue; } + let j = i; + while (j + 1 <= last && !cache.has(j + 1)) j++; + const from = i * size, to = Math.min((j + 1) * size, node.size); + const run = this.fetchRange(node, from, to); + for (let k = i; k <= j; k++) { + const off = (k - i) * size; + const blk = run.then((b) => b.subarray(off, Math.min(off + size, b.length))); + // A failed fetch is reported by the read that awaits it; the + // other blocks of the same run must not count as unhandled. + blk.catch(() => {}); + cache.set(k, blk); + } + i = j + 1; + } + const out = new Uint8Array(end - start); + for (let i = first; i <= last; i++) { + const b = await cache.get(i); + const bStart = i * size; + const from = Math.max(start, bStart), to = Math.min(end, bStart + b.length); + out.set(b.subarray(from - bStart, to - bStart), from - start); + } + return out; + } + + async fetchRange(node, from, to) { + const had = this.byFile.get(node.path) || 0; + if (had + (to - from) > this.perFileBudget || this.fetched + (to - from) > this.budget) { + throw fsError('EIO', `${node.path}: the preview's read budget would be passed`); + } + this.byFile.set(node.path, had + (to - from)); + this.fetched += to - from; + this.requests++; + const headers = { Range: `bytes=${from}-${to - 1}` }; + const t0 = performance.now(); + // The hub answers a file's URL with a redirect to its storage; later + // ranges of the same file go to where it redirected, which halves + // each request's round trips. An expired one falls back to the hub. + this.direct ??= new Map(); + let res; + const direct = this.direct.get(node.path); + if (direct) res = await fetch(direct, { headers }); + if (!res || (res.status !== 206 && res.status !== 200)) { + const auth = this.token ? { Authorization: `Bearer ${this.token}` } : {}; + res = await fetch(this.url(node.path), { headers: { ...headers, ...auth } }); + if (res.redirected) this.direct.set(node.path, res.url); + } + if (res.status !== 206 && res.status !== 200) throw fsError('EIO', `${node.path}: the hub answered ${res.status}`); + let b = new Uint8Array(await res.arrayBuffer()); + if (res.status === 200 && (from > 0 || b.length > to - from)) b = b.subarray(from, to); + this.onFetch({ path: node.path, from, to, ms: performance.now() - t0 }); + return b; + } + + /** Fills the cache, in parallel, with what the checks will read first: + * every safetensors header (its 8-byte length, then the JSON) and the + * start of every GGUF (its metadata). It only saves round trips: the + * engine still reads what it reads, and a miss is fetched on demand. */ + async warm(concurrency = 8) { + const jobs = []; + for (const n of this.nodes.values()) { + if (n.dir || n.size <= this.wholeBelow) continue; + if (n.path.endsWith('.safetensors')) { + jobs.push(async () => { + const head = await this.bytes(n, 0, Math.min(n.size, 256 * KiB)); + if (head.length < 8) return; + const len = Number(new DataView(head.buffer, head.byteOffset, 8).getBigUint64(0, true)); + if (len > 0 && 8 + len <= Math.min(n.size, 64 * MiB)) await this.bytes(n, 0, 8 + len); + }); + } else if (n.path.endsWith('.gguf')) { + jobs.push(() => this.bytes(n, 0, Math.min(n.size, 4 * MiB))); + } + } + const queue = [...jobs]; + await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length) { + try { await queue.shift()(); } catch { /* the read that needs it reports it */ } + } + })); + } + + /** Every file's SHA-256 by its repo path: an LFS file's as the hub states + * it, and a small file's from its bytes, fetched whole (the scan reads + * them anyway: configs, templates, tokenizers, the model card). */ + async hashes() { + const out = {}; + await Promise.all([...this.nodes.values()].filter((n) => !n.dir).map(async (n) => { + if (n.sha256) { out[n.path] = n.sha256; return; } + const b = await this.bytes(n, 0, n.size); + const d = new Uint8Array(await crypto.subtle.digest('SHA-256', b)); + out[n.path] = [...d].map((x) => x.toString(16).padStart(2, '0')).join(''); + })); + return out; + } + + // ---- the fs API Go's syscall/fs_js.go calls ---- + + writeSync(fd, buf) { + this.stdout += new TextDecoder().decode(buf); + const nl = this.stdout.lastIndexOf('\n'); + if (nl !== -1) { + console.log(this.stdout.slice(0, nl)); + this.stdout = this.stdout.slice(nl + 1); + } + return buf.length; + } + write(fd, buf, offset, length, position, cb) { + if (fd === 1 || fd === 2) { + if (offset !== 0 || length !== buf.length || position !== null) return cb(fsError('ENOSYS')); + return cb(null, this.writeSync(fd, buf)); + } + cb(fsError('EBADF')); + } + open(path, flags, mode, cb) { + const node = this.node(path); + if (!node) return cb(fsError('ENOENT', path)); + if (flags & (this.constants.O_WRONLY | this.constants.O_RDWR | this.constants.O_CREAT)) return cb(fsError('EACCES', path)); + const fd = this.nextFd++; + this.fds.set(fd, { node, pos: 0 }); + cb(null, fd); + } + close(fd, cb) { this.fds.delete(fd); cb(null); } + fstat(fd, cb) { + const f = this.fds.get(fd); + if (!f) return cb(fsError('EBADF')); + cb(null, this.statOf(f.node, fd)); + } + stat(path, cb) { + const node = this.node(path); + if (!node) return cb(fsError('ENOENT', path)); + cb(null, this.statOf(node)); + } + lstat(path, cb) { this.stat(path, cb); } + readdir(path, cb) { + const node = this.node(path); + if (!node) return cb(fsError('ENOENT', path)); + if (!node.dir) return cb(fsError('ENOTDIR', path)); + cb(null, [...node.children].sort()); + } + read(fd, buffer, offset, length, position, cb) { + const f = this.fds.get(fd); + if (!f) return cb(fsError('EBADF')); + if (f.node.dir) return cb(fsError('EISDIR')); + const at = position === null || position === undefined ? f.pos : position; + // A read where the last one ended doubles the window, up to maxWindow; + // any other starts small. A read is cut to the window (a short read, + // which Go's readers take), so a parser that seeks from header to + // header fetches a block at each, not the buffer it asked to fill. + f.window = at === f.end ? Math.min((f.window || this.block) * 2, this.maxWindow) : this.block; + const n = f.node.size <= this.wholeBelow ? length : Math.min(length, f.window); + this.bytes(f.node, at, at + n).then( + (b) => { + buffer.set(b, offset); + if (position === null || position === undefined) f.pos += b.length; + f.end = at + b.length; + cb(null, b.length); + }, + (e) => { + this.failures.push({ path: f.node.path, reason: e.message || String(e) }); + cb(e.code ? e : fsError('EIO', String(e))); + } + ); + } + fsync(fd, cb) { cb(null); } + readlink(path, cb) { cb(fsError('EINVAL', path)); } + chmod(p, m, cb) { cb(fsError('EACCES')); } + chown(p, u, g, cb) { cb(fsError('EACCES')); } + fchmod(fd, m, cb) { cb(fsError('EACCES')); } + fchown(fd, u, g, cb) { cb(fsError('EACCES')); } + lchown(p, u, g, cb) { cb(fsError('EACCES')); } + ftruncate(fd, l, cb) { cb(fsError('EACCES')); } + truncate(p, l, cb) { cb(fsError('EACCES')); } + link(p, l, cb) { cb(fsError('EACCES')); } + symlink(p, l, cb) { cb(fsError('EACCES')); } + mkdir(p, m, cb) { cb(fsError('EACCES')); } + rename(a, b, cb) { cb(fsError('EACCES')); } + rmdir(p, cb) { cb(fsError('EACCES')); } + unlink(p, cb) { cb(fsError('EACCES')); } + utimes(p, a, m, cb) { cb(fsError('EACCES')); } +} + +function normalize(p) { + const out = []; + for (const s of p.split('/')) { + if (s === '' || s === '.') continue; + if (s === '..') out.pop(); + else out.push(s); + } + return '/' + out.join('/'); +} +const parentOf = (abs) => normalize(abs + '/..'); +const baseOf = (abs) => abs.slice(abs.lastIndexOf('/') + 1); diff --git a/tools/socair-preview/index.html b/tools/socair-preview/index.html new file mode 100644 index 0000000..4005b22 --- /dev/null +++ b/tools/socair-preview/index.html @@ -0,0 +1,94 @@ + + + + + + + +Socair preview + + + +
+

Socair preview

+

Put socair.ai/ in front of a Hugging Face model URL. Your browser runs Socair's open engine on the model's headers and small files, fetched straight from Hugging Face. Nothing is uploaded, nothing is stored, and no account is needed.

+ +
+ + + +
+

+ + + + +
+ + + + diff --git a/tools/socair-preview/serve.mjs b/tools/socair-preview/serve.mjs new file mode 100644 index 0000000..918e88b --- /dev/null +++ b/tools/socair-preview/serve.mjs @@ -0,0 +1,21 @@ +// A local server for the built preview (dist/), routing /huggingface.co/* +// to the page as socair.ai would: node serve.mjs [port] +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, normalize } from 'node:path'; + +const port = Number(process.argv[2] || 4321); +const dist = new URL('./dist/', import.meta.url).pathname; +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.wasm': 'application/wasm' }; + +createServer(async (req, res) => { + let path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)); + if (path === '/' || !extname(path) || path.startsWith('/huggingface.co/') || path.startsWith('/https:')) path = '/index.html'; + try { + const body = await readFile(dist + path.replace(/^\/+/, '')); + res.writeHead(200, { 'content-type': types[extname(path)] || 'application/octet-stream' }); + res.end(body); + } catch { + res.writeHead(404).end('not found'); + } +}).listen(port, '127.0.0.1', () => console.log(`http://127.0.0.1:${port}/huggingface.co/HuggingFaceTB/SmolLM2-135M-Instruct`)); diff --git a/tools/socair-preview/try.mjs b/tools/socair-preview/try.mjs new file mode 100644 index 0000000..294a84a --- /dev/null +++ b/tools/socair-preview/try.mjs @@ -0,0 +1,46 @@ +// Runs the preview in Node, the same code path as the page: +// node try.mjs org/name [revision] [--all] +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { HubFS, listRepo, previewSelection } from './hffs.js'; + +const args = process.argv.slice(2); +const all = args.includes('--all'); +const [repo, revision = 'main'] = args.filter((a) => !a.startsWith('--')); +const dist = new URL('./dist/', import.meta.url); +const t0 = performance.now(); +const listing = await listRepo(repo, revision); +const sel = all ? { include: [], kept: listing.files, left: [] } : previewSelection(listing.files); +const hub = new HubFS({ ...listing, files: sel.kept, + onFetch: process.env.TRACE ? (e) => console.error(`fetch ${e.path} [${e.from}, ${e.to}) ${e.ms.toFixed(0)}ms`) : undefined }); +globalThis.fs = hub; +await import(pathToFileURL(new URL('wasm_exec.js', dist).pathname)); +process.cwd = () => '/'; +const go = new globalThis.Go(); +const { instance } = await WebAssembly.instantiate(await readFile(new URL('socair.wasm', dist)), go.importObject); +go.run(instance); + +const [hashes] = await Promise.all([hub.hashes(), hub.warm()]); +const out = await globalThis.socairPreview(hub.root, JSON.stringify(hashes)); +const docs = [JSON.parse(out.json)]; +// A GGUF's own metadata (chat template, tokenizer, quant) is read by a scan of +// the file on its own; the directory scan names it as not run. +for (const f of sel.kept.filter((f) => f.path.endsWith('.gguf')).slice(0, 1)) { + docs.push(JSON.parse((await globalThis.socairPreviewFile(`${hub.root}/${f.path}`)).json)); +} +if (process.env.OUT) (await import('node:fs')).writeFileSync(process.env.OUT, JSON.stringify(docs[0])); +const total = listing.files.reduce((n, f) => n + (f.size || 0), 0); +console.log(`${docs[0].artifact.name} @ ${listing.commit.slice(0, 12)}: ${listing.files.length} files, ${(total / 1e9).toFixed(2)} GB in the repo`); +if (sel.include.length) console.log(`selection: --include '${sel.include.join(',')}' keeps ${sel.kept.length}, leaves out ${sel.left.length}: ${sel.left.map((f) => f.path).join(' ')}`); +console.log(`fetched ${(hub.fetched / 1e6).toFixed(2)} MB in ${hub.requests} requests, ${((performance.now() - t0) / 1000).toFixed(1)} s`); +if (hub.failures.length) { + console.log(`PREVIEW INCOMPLETE: ${hub.failures.length} read(s) failed, so no row is shown`); + for (const f of hub.failures.slice(0, 5)) console.log(` ${f.path}: ${f.reason}`); + process.exit(1); +} +for (const d of docs) { + if (d !== docs[0]) console.log(` -- ${d.artifact.file_name} on its own:`); + for (const c of d.checks) console.log(` ${c.status.padEnd(10)} ${c.name}: ${(c.notes || '').slice(0, 140)}`); +} +console.log(' promotion with no policy (not shown on the page):', docs[0].promotion_authorization?.state); +process.exit(0);