"""Reproduce the bounded Tokenwerk experiment against an already running local Ollama.
Python standard library only. Synthetic fixtures, no cloud providers, no real actions.
Not a production access-control gateway. It does not authenticate accounts or classify data.
"""
from pathlib import Path
from datetime import datetime,timezone
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
import argparse,json,hashlib,socket,threading,urllib.request,urllib.error,time
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--design',type=Path,default=Path(__file__).with_name('frozen-design.json'))
p.add_argument('--output',type=Path,required=True,help='New directory for this run; existing paths are refused')
p.add_argument('--ollama-port',type=int,default=11438)
args=p.parse_args();E=args.output.resolve();assert not E.exists(),'Choose a new output directory';E.mkdir(parents=True)
def save(p,x):p.write_text(json.dumps(x,ensure_ascii=False,indent=2)+'\n',encoding='utf8')
raw=args.design.read_bytes();design=json.loads(raw);assert design['model']=='qwen3.5:4b' and design['repetitions']==3
ports={**design['ports'],'model':args.ollama_port};gateway_events=[];backend_events=[];rows=[]
assert 1024<ports['model']<65536 and len(set(ports.values()))==3
for port in [ports['observer'],ports['gateway']]:
 with socket.socket() as sock:sock.bind(('127.0.0.1',port))
def request(url,payload=None,timeout=180):
 req=urllib.request.Request(url,data=json.dumps(payload).encode() if payload is not None else None,headers={'Content-Type':'application/json'})
 try:r=urllib.request.urlopen(req,timeout=timeout)
 except urllib.error.HTTPError as error:r=error
 with r:return r.status,json.load(r)
class Base(BaseHTTPRequestHandler):
 def log_message(self,*args):pass
 def respond(self,status,body):
  raw=json.dumps(body,ensure_ascii=False).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(raw)));self.end_headers();self.wfile.write(raw)
 def payload(self):
  length=int(self.headers.get('Content-Length','0'));assert 0<length<=8192;return json.loads(self.rfile.read(length))
class Observer(Base):
 def do_POST(self):
  assert self.path=='/api/generate';data=self.payload();record={'at':datetime.now(timezone.utc).isoformat(),'requestId':self.headers.get('X-Test-Id'),'model':data['model'],'promptSha256':hashlib.sha256(data['prompt'].encode()).hexdigest()};backend_events.append(record)
  status,body=request(f"http://127.0.0.1:{ports['model']}/api/generate",data);record.update(status=status,responseSha256=hashlib.sha256(json.dumps(body,sort_keys=True).encode()).hexdigest());self.respond(status,body)
class Gateway(Base):
 def do_POST(self):
  assert self.path=='/draft';data=self.payload();record={'at':datetime.now(timezone.utc).isoformat(),'requestId':self.headers.get('X-Test-Id'),'app':data.get('app'),'account':data.get('account'),'declaredData':data.get('data'),'requestedAction':data.get('action')};gateway_events.append(record)
  mismatches=[key for key,value in design['fixedRules'].items() if data.get(key)!=value]
  if mismatches:record.update(status=403,reason=mismatches);self.respond(403,{'blocked':True,'fields':mismatches});return
  payload={'model':design['model'],'prompt':data['prompt'],'options':design['options'],'think':False,'stream':False,'keep_alive':'5m'}
  req=urllib.request.Request(f"http://127.0.0.1:{ports['observer']}/api/generate",data=json.dumps(payload).encode(),headers={'Content-Type':'application/json','X-Test-Id':record['requestId']})
  with urllib.request.urlopen(req,timeout=180) as r:body=json.load(r);record.update(status=r.status);self.respond(r.status,body)
servers=[]
try:
 _,version=request(f"http://127.0.0.1:{ports['model']}/api/version")
 _,tags=request(f"http://127.0.0.1:{ports['model']}/api/tags");model=next(m for m in tags['models'] if m['name']==design['model'])
 assert version['version']==design['runtimeExpected'],'Different runtime: document separately, do not claim identical setup'
 assert model['digest']=='d8b0f5e9760cd1682034f292d7ef72ec46f432149be0df7574bf2d6e92e38c04','Different model digest'
 save(E/'runtime-version.json',version);save(E/'model.json',model);(E/'frozen-design.json').write_bytes(raw)
 for port,handler in [(ports['observer'],Observer),(ports['gateway'],Gateway)]:
  server=ThreadingHTTPServer(('127.0.0.1',port),handler);servers.append(server);threading.Thread(target=server.serve_forever,daemon=True).start()
 for case in design['schedule']:
  for repeat in range(1,4):
   testid=f"{case['id']}-{repeat}";count_before=len(backend_events);audit_before=len(gateway_events);payload={k:case[k] for k in ['app','account','data','action','prompt']}
   if case['route']=='gateway':url=f"http://127.0.0.1:{ports['gateway']}/draft"
   else:url=f"http://127.0.0.1:{ports['observer']}/api/generate";payload={'model':design['model'],'prompt':case['prompt'],'options':design['options'],'think':False,'stream':False,'keep_alive':'5m'}
   tick=time.perf_counter();req=urllib.request.Request(url,data=json.dumps(payload).encode(),headers={'Content-Type':'application/json','X-Test-Id':testid})
   try:response=urllib.request.urlopen(req,timeout=180)
   except urllib.error.HTTPError as error:response=error
   with response:status=response.status;body=json.load(response)
   row={'id':testid,'scenario':case['id'],'label':case['label'],'route':case['route'],'seconds':time.perf_counter()-tick,'httpStatus':status,'backendRequests':len(backend_events)-count_before,'gatewayRecords':len(gateway_events)-audit_before,'response':body,'promptSha256':hashlib.sha256(case['prompt'].encode()).hexdigest()}
   row['passed']=status==case['expectedStatus'] and row['backendRequests']==int(case['expectedBackend']) and row['gatewayRecords']==int(case['route']=='gateway')
   if row['backendRequests']:row['passed']=row['passed'] and body.get('done') is True and bool(body.get('response'))
   rows.append(row);save(E/'results.json',rows);save(E/'gateway-metadata.json',gateway_events);save(E/'backend-observations.json',backend_events)
   print(json.dumps({k:row[k] for k in ['id','httpStatus','backendRequests','gatewayRecords','seconds','passed']}),flush=True)
   assert row['passed'],testid
 summary={'requests':len(rows),'blockedBeforeModel':sum(r['httpStatus']==403 for r in rows),'modelRequestsObserved':len(backend_events),'gatewayRequestsObserved':len(gateway_events),'directRequestsMissingFromGatewayLog':sum(r['route']=='direct' and not r['gatewayRecords'] for r in rows),'syntheticContactInputsAccepted':sum(r['scenario']=='G' and r['httpStatus']==200 for r in rows),'allExpectedBehavioursObserved':all(r['passed'] for r in rows),'frozenDesignSha256':hashlib.sha256(raw).hexdigest(),'interpretation':'Case G original label was imprecise. Its fake .invalid contact is synthetic, so acceptance does not prove misclassification of real personal data.'}
 save(E/'summary.json',summary);print(json.dumps(summary),flush=True)
finally:
 for server in servers:server.shutdown();server.server_close()
 # The pre-existing Ollama server belongs to the caller and is not stopped here.
