research/demo/common.py
Builder 5dfc4a9fad Lead with a real end-to-end model-capability licensing demo
Rebuild the repo so its spine is a real, reproducible demonstration of
licensing an actual model capability, not payload-agnostic crypto on
stand-in blobs. The clean-room Ed25519 + AES-256-GCM primitives stay as
the fast mechanism layer; the real thing is now the headline.

New demo/ walkthrough (steps 1-7), each a standalone script printing
machine-checked evidence:
  1 download Qwen2.5-0.5B-Instruct from Hugging Face (gitignored cache)
  2 base scores 0.000 on an invented tool-call protocol (capability C)
  3 train a PEFT LoRA on C, base frozen (SHA-256 byte-identical proof)
  4 base + LoRA scores 0.925 on a held-out set with unseen arguments
  5 seal the adapter as an AES-256-GCM unit under an Ed25519 leaf cert
  6 valid licence decrypts-at-load and runs C at 0.925
  7 access-gate then crypto-erase: original and exfiltrated copy both
    permanently undecryptable, base alone back to 0.000

Reference run on an RTX 4090 captured the observed numbers now in the
README. keystore.py gains export_state/load_state so the authority (and
crypto-erasure) persists across the separate demo commands. A single
run_demo.sh drives steps 1-7; run_all.sh + pytest remain the fast
crypto-only mechanism tests.

Ships code only: base weights, HF cache, trained adapter, wrapping keys
and every sealed unit are gitignored and never committed. README rewritten
to lead with the demo and the observed numbers, with honest bounds
(in-memory adapter during a live licence needs a hardware enclave) and a
capability-tree scale-up as future work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:20:12 +10:00

180 lines
6.2 KiB
Python

"""Shared paths, model loading, and the held-out evaluation loop.
All model artefacts land under gitignored directories inside the repository:
models/hf-cache/ the Hugging Face download cache (base weights)
outputs/adapter/ the trained LoRA adapter (safetensors)
state/ crypto authority state and sealed capability units
Nothing under those directories is ever committed (see .gitignore). The repo
ships code only; the reviewer regenerates every artefact by running the demo.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parents[1]
MODELS_DIR = REPO_ROOT / "models"
HF_CACHE = MODELS_DIR / "hf-cache"
OUTPUTS_DIR = REPO_ROOT / "outputs"
ADAPTER_DIR = OUTPUTS_DIR / "adapter"
STATE_DIR = REPO_ROOT / "state"
# Route the Hugging Face cache into the repo-local gitignored dir so a reviewer
# sees exactly where the weights land, and nothing escapes to ~/.cache.
os.environ.setdefault("HF_HOME", str(HF_CACHE))
HF_CACHE.mkdir(parents=True, exist_ok=True)
# The base model. Smallest open instruct model that reliably learns capability C
# via a LoRA on modest hardware.
BASE_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
HF_LINK = "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct"
# Make the clean-room crypto importable exactly as the examples/tests do.
sys.path.insert(0, str(REPO_ROOT / "src"))
# The inference prompt. It states the OUTPUT should be a SIGIL directive so the
# task is well-posed, but it deliberately reveals NONE of the protocol: not the
# frame, not a single opcode, not the domain->route or action->verb mapping.
# The base model therefore cannot produce a correct directive; the mapping and
# the copy are exactly what the LoRA adapter supplies.
SYSTEM_PROMPT = (
"You are a home-automation controller. For each request, reply with exactly "
"one SIGIL control directive and nothing else."
)
def build_prompt(tokenizer, request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": request},
]
return tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
def load_tokenizer():
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left" # left-pad for batched decoder generation
return tokenizer
def load_base_model(device: str = "cuda"):
import torch
from transformers import AutoModelForCausalLM
# fp32 throughout so training and every eval share a dtype: greedy decoding
# is then exactly reproducible and the observed numbers are stable. A 0.5B
# model in fp32 is ~2 GiB, trivial on the target hardware.
model = AutoModelForCausalLM.from_pretrained(BASE_MODEL_ID, dtype=torch.float32)
model.to(device)
model.eval()
return model
def tar_dir_bytes(src_dir) -> bytes:
"""Pack a directory (the adapter) into a single tar byte string to seal."""
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as tar:
for path in sorted(Path(src_dir).iterdir()):
if path.is_file():
tar.add(str(path), arcname=path.name)
return buffer.getvalue()
def extract_tar_bytes(data: bytes, dest_dir):
"""Unpack a tar byte string (a decrypted adapter) into ``dest_dir``."""
import io
import tarfile
dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=io.BytesIO(data), mode="r") as tar:
tar.extractall(str(dest), filter="data")
return dest
def load_base_plus_adapter(adapter_dir, device: str = "cuda"):
"""Load the frozen base and attach a LoRA adapter from ``adapter_dir``."""
from peft import PeftModel
base = load_base_model(device)
model = PeftModel.from_pretrained(base, str(adapter_dir))
model.eval()
return model
def base_weight_files() -> List[Path]:
"""The base model's on-disk weight shards in the HF cache (for hashing)."""
matches = sorted(HF_CACHE.rglob("*.safetensors"))
# Exclude any adapter that might live elsewhere; base cache only.
return [p for p in matches if "adapter" not in p.name]
def generate_batch(
model,
tokenizer,
requests: List[str],
max_new_tokens: int = 32,
batch_size: int = 16,
) -> List[str]:
"""Greedy (deterministic) generation. Returns the decoded completion only."""
import torch
device = next(model.parameters()).device
outputs: List[str] = []
for start in range(0, len(requests), batch_size):
chunk = requests[start : start + batch_size]
prompts = [build_prompt(tokenizer, r) for r in chunk]
enc = tokenizer(prompts, return_tensors="pt", padding=True, add_special_tokens=False)
enc = {k: v.to(device) for k, v in enc.items()}
with torch.no_grad():
gen = model.generate(
**enc,
max_new_tokens=max_new_tokens,
do_sample=False,
num_beams=1,
pad_token_id=tokenizer.pad_token_id,
)
for i in range(len(chunk)):
completion = gen[i][enc["input_ids"].shape[1] :]
outputs.append(tokenizer.decode(completion, skip_special_tokens=True))
return outputs
def evaluate(
model,
tokenizer,
examples,
show: int = 0,
) -> Tuple[float, int, int, List[Tuple[str, str, str, bool]]]:
"""Score a model on held-out capability-C examples.
Returns (accuracy, n_correct, n_total, samples) where each sample is
(request, gold_directive, model_output, correct).
"""
from .capability import score_one
requests = [e.request for e in examples]
outputs = generate_batch(model, tokenizer, requests)
correct = 0
samples: List[Tuple[str, str, str, bool]] = []
for e, out in zip(examples, outputs):
ok = score_one(out, e.directive)
correct += int(ok)
if len(samples) < show:
samples.append((e.request, e.directive.render(), out.strip(), ok))
n = len(examples)
return (correct / n if n else 0.0), correct, n, samples