The web client now plays borealis (data/models.json), trained on the three-round match and taking the 501-dim match view rather than a bare round. Only the actor trunk is exported -- the critic exists to grade moves in training and never plays, so the graph physically cannot leak the opponent's hand or the deck, which beats promising not to call it. The TypeScript match layer and observation mirror match.py and match_obs.py. They have to agree to the bit: a mismatch throws nowhere, the ONNX policy just consumes a wrong vector and plays worse for reasons nobody can see. So the port is not trusted -- generate_match_parity_fixture.py emits 282 positions from real JAX play (mid-round, both seats, past a roll-over, with a live carry) and the TS output is checked against them to float32 round-off. Match mode is a menu toggle. A seed fixes all three deals and the coin flips, so a match stays a pure function of it. One-deal mode is unchanged from the player's side; borealis simply sees it as round one at a carry of zero, a position it has seen a great many times. Two bugs found by driving the built app in a browser, both silent: - The result card totalled the round, not the match. It read "-11 : 3" while the match stood at -96 : 66 -- it would have named the wrong winner. It now headlines the match total and breaks the round out beneath it. - Game records were being rejected. The client's schema went to v2 (it now records which model played; the old records stored the on-screen label, which stops identifying anything once there are two models) while serve_web_with_logs.py still only accepted v1, so every record would have 400'd into a console warning. v1 stays accepted -- the 111 existing games are altair. npm test and tsc were green through both. Hence web/.claude/skills/verify, which records the recipe and the selectors so the next session drives the app instead of re-deriving how. .gitignore excluded the new model, which would have shipped a 404: the deploy builds straight from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
139 lines
5.5 KiB
Python
139 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Export a match policy's actor trunk to ONNX for the browser.
|
|
|
|
Only the actor ships. The critic exists to grade moves during training and never
|
|
plays, so its trunk -- and the privileged view of the opponent's hand and the deck
|
|
that feeds it -- is dropped here rather than shipped and then not used. That also
|
|
means the exported graph physically cannot leak hidden state, which is a stronger
|
|
guarantee than promising not to call it.
|
|
|
|
MatchActorCritic lays the actor out as Dense_0..Dense_{num_layers} exactly as the
|
|
single-round model does, so the graph construction is the same; only the input
|
|
width and the checkpoint loader differ.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import jax
|
|
import jax.numpy as jnp
|
|
import numpy as np
|
|
|
|
from lost_cities_jax.match_obs import MATCH_CRITIC_OBS_DIM, MATCH_OBS_DIM
|
|
from lost_cities_jax.match_ppo import Ablation, MatchActorCritic, create_match_train_state
|
|
from lost_cities_jax.ppo import load_config, restore_checkpoint
|
|
from lost_cities_jax.types import N_ACTIONS
|
|
|
|
|
|
def build_argparser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--checkpoint", type=Path, required=True)
|
|
parser.add_argument("--config", type=Path, default=Path("configs/jax_ppo/match-selfplay.yaml"))
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--codename", required=True, help="see data/models.json")
|
|
return parser
|
|
|
|
|
|
def export_model(checkpoint: Path, config: Path, output: Path, codename: str) -> None:
|
|
try:
|
|
import onnx
|
|
from onnx import TensorProto, helper, numpy_helper
|
|
from onnx.reference import ReferenceEvaluator
|
|
except ImportError as exc:
|
|
raise SystemExit("onnx is required; run with `uv run --with onnx ...`") from exc
|
|
|
|
cfg = load_config(config)
|
|
state = restore_checkpoint(
|
|
checkpoint, create_match_train_state(cfg, jax.random.PRNGKey(0), Ablation())
|
|
)
|
|
params = state.params
|
|
dense = params["params"]
|
|
|
|
nodes = []
|
|
initializers = []
|
|
previous = "obs"
|
|
for index in range(cfg.network.num_layers):
|
|
layer = dense[f"Dense_{index}"]
|
|
weight, bias = f"dense_{index}.weight", f"dense_{index}.bias"
|
|
initializers.extend(
|
|
[
|
|
numpy_helper.from_array(np.asarray(layer["kernel"], dtype=np.float32), weight),
|
|
numpy_helper.from_array(np.asarray(layer["bias"], dtype=np.float32), bias),
|
|
]
|
|
)
|
|
nodes.append(helper.make_node("Gemm", [previous, weight, bias], [f"dense_{index}.linear"]))
|
|
nodes.append(helper.make_node("Relu", [f"dense_{index}.linear"], [f"dense_{index}.relu"]))
|
|
previous = f"dense_{index}.relu"
|
|
|
|
actor = dense[f"Dense_{cfg.network.num_layers}"]
|
|
initializers.extend(
|
|
[
|
|
numpy_helper.from_array(np.asarray(actor["kernel"], dtype=np.float32), "actor.weight"),
|
|
numpy_helper.from_array(np.asarray(actor["bias"], dtype=np.float32), "actor.bias"),
|
|
]
|
|
)
|
|
nodes.append(helper.make_node("Gemm", [previous, "actor.weight", "actor.bias"], ["logits"]))
|
|
|
|
graph = helper.make_graph(
|
|
nodes,
|
|
f"coolrl-lost-cities-match-actor-{codename}",
|
|
[helper.make_tensor_value_info("obs", TensorProto.FLOAT, [None, MATCH_OBS_DIM])],
|
|
[helper.make_tensor_value_info("logits", TensorProto.FLOAT, [None, N_ACTIONS])],
|
|
initializer=initializers,
|
|
)
|
|
model = helper.make_model(
|
|
graph, producer_name="coolrl-lost-cities", opset_imports=[helper.make_opsetid("", 17)]
|
|
)
|
|
model.ir_version = 8
|
|
onnx.checker.check_model(model)
|
|
|
|
# The exported graph has to agree with the trained one, not merely load.
|
|
rng = np.random.default_rng(20260715)
|
|
sample = rng.normal(size=(8, MATCH_OBS_DIM)).astype(np.float32)
|
|
critic_stub = jnp.zeros((8, MATCH_CRITIC_OBS_DIM), dtype=jnp.float32)
|
|
flax_model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
|
|
expected, _ = flax_model.apply(params, jnp.asarray(sample), critic_stub)
|
|
actual = ReferenceEvaluator(model).run(None, {"obs": sample})[0]
|
|
np.testing.assert_allclose(actual, np.asarray(expected), rtol=2e-5, atol=2e-5)
|
|
np.testing.assert_array_equal(
|
|
np.argmax(actual, axis=1), np.argmax(np.asarray(expected), axis=1)
|
|
)
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
onnx.save(model, output)
|
|
model_bytes = output.read_bytes()
|
|
manifest = {
|
|
"format": "coolrl-lost-cities-match-onnx-v1",
|
|
"codename": codename,
|
|
"model_file": output.name,
|
|
"model_size_bytes": len(model_bytes),
|
|
"model_sha256": hashlib.sha256(model_bytes).hexdigest(),
|
|
"source_checkpoint": str(checkpoint),
|
|
"source_config": config.name,
|
|
"observation_size": MATCH_OBS_DIM,
|
|
"action_size": N_ACTIONS,
|
|
"hidden_size": cfg.network.hidden_size,
|
|
"num_layers": cfg.network.num_layers,
|
|
"dtype": "float32",
|
|
"validation_max_abs_error": float(np.max(np.abs(actual - np.asarray(expected)))),
|
|
}
|
|
output.with_suffix(".json").write_text(json.dumps(manifest, indent=2) + "\n")
|
|
print(f"exported {codename} -> {output} ({output.stat().st_size:,} bytes)")
|
|
print(
|
|
f" sha256 {manifest['model_sha256'][:12]} obs {MATCH_OBS_DIM} max err "
|
|
f"{manifest['validation_max_abs_error']:.2e}"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
args = build_argparser().parse_args()
|
|
export_model(args.checkpoint, args.config, args.output, args.codename)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|