masterdanh Claude Opus 5 commited on
Commit
5c88cde
·
1 Parent(s): 7d9c5f3

refactor(analyzer): drop unused training code from shotnet.py

Browse files

Nothing in this repository trains ShotNet; the loader, batcher, collate,
loss, gate metrics and the two augmentations were dead weight that read as
a live training path. The poolcoach-rl history keeps them for a retrain.
Gate: shotnet.py 862 -> 449 lines; checkpoint shotnet_20260812_c4b still
loads and the worker boots in 7.3s; shots 6 and 10 re-analysed on CPU give
EVERY field identical to before the trim; pytest 160 passed, 7 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. src/poolcoach_cv/shotnet.py +12 -425
src/poolcoach_cv/shotnet.py CHANGED
@@ -5,9 +5,8 @@ trajectory.
5
  The inverse of the RL action space: the observation sequence of ONE shot
6
  {ball positions in table coordinates, a visibility mask, timestamps} ->
7
  (V0, phi, a, b), exactly the four dimensions of ``cue.set_state``. Trained
8
- PURELY on synthetic data (the pilot noise model is the contract), and the
9
- loader here reads the npz shards WITHOUT pooltool so it can run in the CV
10
- environment.
11
 
12
  Architecture: a small transformer encoder, a learned **shot token** at the
13
  head of the sequence from which the outputs are read, and **RoPE on real
@@ -35,27 +34,23 @@ Output heads:
35
  - optional aux heads: ``v0_ball``/``phi_ball`` (labels the dataset already
36
  carries); the gate is still scored on the cue V0.
37
 
38
- The validation split takes about 2% off TRAIN by seed
39
- (``val_split_indices``); the 5k held-out set is TEST and must not be touched
40
- while training or tuning.
41
-
42
- Two things exist on the **training path only** (see
43
- ``ShardDataset.set_train_mode``): ``SlotDropoutConfig``, which thins the
44
- target-ball detections on the fly to cover the density range of real clips,
45
- and ``TargetReweight``, which pulls the loss weight towards the slice whose
46
- first contact is at least 0.3s. Neither changes the architecture, the ruler
47
- or the mapping by one line.
48
 
49
  The spin classification constants (A_SIDE_MIN/B_STUN_MAX) are copied from
50
- the evaluation harness that owns the ground truth; here they only serve the
51
- validation metrics and the mapping from a predicted (a, b) to a class.
52
  """
53
 
54
  from __future__ import annotations
55
 
56
  import math
57
- from dataclasses import dataclass, field
58
- from pathlib import Path
59
 
60
  import numpy as np
61
  import torch
@@ -130,102 +125,6 @@ def featurize_shot(shot: dict, w: float = TABLE_W_M, l: float = TABLE_L_M,
130
  return feats, np.asarray(shot["t"], dtype=np.float32)
131
 
132
 
133
- LABEL_KEYS = ("label_v0", "label_phi", "label_a", "label_b", "identifiable",
134
- "v0_ball", "phi_ball", "fps", "upconvert", "scratch")
135
-
136
-
137
- # ------------------- augmentation: "sparse target-ball detections"
138
- # The diagnosis was MEASURED, not guessed: the earlier checkpoint collapsed
139
- # on real clips because of the DISTRIBUTION of target-ball detection
140
- # density, not the architecture or the input path -- synthetic training sits
141
- # at 95-96% visibility (about 5.3 non-cue detections per frame) while one
142
- # real shot had 1.01 and another 3.09. The augmentation below thins the
143
- # detections inside the training loader itself (on the fly, with no new
144
- # dataset written to disk) so the training range covers the measured real
145
- # range.
146
- #
147
- # The dropout produces a mask of 0 EXACTLY like a ball missing from a frame
148
- # (featurize_shot multiplies coordinates by vis, and the delta block
149
- # multiplies the two frames' vis together). It touches neither img_diff nor
150
- # the timestamps nor any other feature.
151
-
152
- @dataclass
153
- class SlotDropoutConfig:
154
- """Target-ball slot dropout, with one keep rate per shot.
155
-
156
- ``p_apply``: the fraction of shots that get augmented; the rest pass
157
- through the loader BIT IDENTICAL to before (which keeps the synthetic
158
- 95-96% visibility region in the training distribution -- that region is
159
- also the top of the range).
160
- ``keep_min``/``keep_max``: the keep rate is ~ U(min, max), applied as an
161
- independent Bernoulli per (frame, non-cue slot) cell. The default range
162
- [0.10, 0.95] plus the un-augmented branch covers the whole measured real
163
- range: one real shot at about 15% per-ball visibility, another at about
164
- 50%, and the original synthetic data at 95-96%.
165
- The ``cue`` slot is NEVER dropped further: the cue track on real clips is
166
- already good (coverage 0.90-0.97) and the noise at the start of a shot is
167
- part of the pilot contract.
168
- """
169
-
170
- p_apply: float = 0.75
171
- keep_min: float = 0.10
172
- keep_max: float = 0.95
173
- seed: int = 20260812
174
-
175
-
176
- def apply_slot_dropout(shot: dict, cfg: SlotDropoutConfig,
177
- rng: np.random.Generator) -> tuple[dict, float]:
178
- """Thin ONE shot's target-ball detections -> (a new shot, the keep rate
179
- used; 1.0 means no augmentation).
180
-
181
- It does NOT modify in place: ``ShardDataset.raw_shot``'s ``covered`` is a
182
- view into the shard array held in RAM, and writing over it would corrupt
183
- the dataset for every later epoch.
184
- """
185
- cov = shot["covered"]
186
- tgt = np.flatnonzero(np.asarray(shot["ball_ids"]) != 0)
187
- if tgt.size == 0 or rng.random() >= cfg.p_apply:
188
- return shot, 1.0
189
- keep = float(rng.uniform(cfg.keep_min, cfg.keep_max))
190
- cov = np.array(cov, dtype=bool, copy=True)
191
- cov[:, tgt] &= rng.random((cov.shape[0], tgt.size)) < keep
192
- out = dict(shot)
193
- out["covered"] = cov
194
- return out, keep
195
-
196
-
197
- # The target-matched slice -- COPIED from the evaluation harness
198
- # (T_FIRST_TARGET_S; src/ cannot import from the scripts side, same as
199
- # B_STUN_MAX above). This population matches the real pilot clips and is the
200
- # official ruler of the acceptance gate.
201
- T_FIRST_TARGET_S = 0.3
202
-
203
-
204
- @dataclass
205
- class TargetReweight:
206
- """Reweighting towards the target slice: a shot whose first contact is at
207
- or after ``t_first_s`` has its loss multiplied by ``weight``.
208
-
209
- Loss weighting was chosen over oversampling deliberately: oversampling a
210
- 13.6% slice by a factor of 3 would lengthen the epoch by about 27% and
211
- break the "identical step budget" condition, so the configs would no
212
- longer be comparable with the baseline. The weight applies to EVERY head
213
- (v0/phi/ab/ident) through a weighted mean, so the overall gradient scale
214
- stays about 1 (an earlier lesson: an off-scale gradient kills a head).
215
- """
216
-
217
- weight: float = 3.0
218
- t_first_s: float = T_FIRST_TARGET_S
219
-
220
-
221
- def t_first_contact_s(shot: dict) -> float:
222
- """The cue's first contact after the strike: min(t_first_bb,
223
- t_first_cush) -- NaN when the shot has no collision at all. The same
224
- formula as the evaluation harness."""
225
- vals = [float(v) for v in (shot["t_first_bb"], shot["t_first_cush"])
226
- if not math.isnan(float(v))]
227
- return min(vals) if vals else math.nan
228
-
229
 
230
  # ------------------------- sim to real: build a shot from the app's track
231
  # The sim-to-real input gap is the main risk here: the net trains with
@@ -366,194 +265,6 @@ def shot_from_track(rows: list[dict], others: list[dict] | None
366
  "n_frames": F, "n_balls": K + 1}
367
 
368
 
369
- # --------------------------------------------------------------- dataset
370
-
371
- class ShardDataset:
372
- """Load a list of npz shards into RAM (ragged plus offsets, about 2.4 GB
373
- for 150k shots) and hand back featurized shots. It needs no pooltool, and
374
- nothing depends on torch until ``collate`` (so the loader tests run in
375
- pure numpy).
376
-
377
- ``indices``: GLOBAL indices (shards sorted, then local index) -- used for
378
- the train/val split. ``limit_shots``: truncate each shard (smoke tests).
379
-
380
- ``augment``/``reweight`` only take effect under ``set_train_mode(True)``.
381
- They default to OFF, because every evaluation path (the held-out harness,
382
- the app worker, the per-epoch validation) has to see the same data as the
383
- baseline, otherwise the numbers are not comparable.
384
- """
385
-
386
- _META = ("label_v0", "label_phi", "label_a", "label_b", "v0_ball",
387
- "phi_ball", "identifiable", "scratch", "fps", "upconvert",
388
- "t_first_bb", "t_first_cush", "n_frames", "n_balls", "shot_idx")
389
-
390
- def __init__(self, shard_paths: list[Path], limit_shots: int = 0,
391
- deltas: bool = False,
392
- augment: SlotDropoutConfig | None = None,
393
- reweight: TargetReweight | None = None):
394
- self.deltas = deltas
395
- self.augment = augment
396
- self.reweight = reweight
397
- self._train_mode = False
398
- self._epoch = 0
399
- self.shards = []
400
- self._index: list[tuple[int, int]] = []
401
- for si, p in enumerate(sorted(Path(q) for q in shard_paths)):
402
- z = np.load(p)
403
- S = len(z["shot_idx"])
404
- if limit_shots:
405
- S = min(S, limit_shots)
406
- sh = {k: np.asarray(z[k]) for k in self._META}
407
- sh["xy"] = np.asarray(z["xy"], dtype=np.float32)
408
- sh["covered"] = np.asarray(z["covered"]).astype(bool)
409
- sh["img_diff"] = np.asarray(z["img_diff"], dtype=np.float32)
410
- sh["ball_ids"] = np.asarray(z["ball_ids"])
411
- sh["xy_off"] = np.asarray(z["xy_off"], dtype=np.int64)
412
- sh["f_off"] = np.asarray(z["f_off"], dtype=np.int64)
413
- sh["b_off"] = np.asarray(z["b_off"], dtype=np.int64)
414
- self.shards.append(sh)
415
- self._index.extend((si, li) for li in range(S))
416
- self.lengths = np.array(
417
- [self.shards[si]["n_frames"][li] for si, li in self._index],
418
- dtype=np.int64)
419
-
420
- def __len__(self) -> int:
421
- return len(self._index)
422
-
423
- def set_train_mode(self, on: bool, epoch: int = 0) -> None:
424
- """Turn augmentation and reweighting on or off, and fix the epoch for
425
- the RNG.
426
-
427
- Training turns it on (a different epoch gives a different mask, still
428
- reproducible from ``[seed, epoch, i]``); validation and evaluation
429
- turn it off. One flag for both, because both only mean anything on
430
- the training path -- validation numbers have to stay comparable
431
- across configs.
432
- """
433
- self._train_mode = bool(on)
434
- self._epoch = int(epoch)
435
-
436
- def raw_shot(self, i: int) -> dict:
437
- """The raw shot dict, in the same shape the dataset generator
438
- produced."""
439
- si, li = self._index[i]
440
- sh = self.shards[si]
441
- F_n = int(sh["n_frames"][li])
442
- B = int(sh["n_balls"][li])
443
- x0, f0, b0 = (int(sh["xy_off"][li]), int(sh["f_off"][li]),
444
- int(sh["b_off"][li]))
445
- shot = {k: sh[k][li].item() for k in self._META
446
- if k not in ("n_frames", "n_balls")}
447
- shot["n_frames"], shot["n_balls"] = F_n, B
448
- shot["t"] = (np.arange(F_n) / float(sh["fps"][li])).astype(np.float32)
449
- shot["xy"] = sh["xy"][x0:x0 + F_n * B].reshape(F_n, B, 2)
450
- shot["covered"] = sh["covered"][x0:x0 + F_n * B].reshape(F_n, B)
451
- shot["img_diff"] = sh["img_diff"][f0:f0 + F_n]
452
- shot["ball_ids"] = sh["ball_ids"][b0:b0 + B]
453
- return shot
454
-
455
- def __getitem__(self, i: int) -> dict:
456
- shot = self.raw_shot(i)
457
- keep = 1.0
458
- if self._train_mode and self.augment is not None:
459
- # the RNG is keyed on [seed, epoch, i]: the same triple gives the
460
- # same mask (every epoch is reproducible) while a different epoch
461
- # gives a different one (genuinely on-the-fly augmentation rather
462
- # than one fixed thinned copy)
463
- rng = np.random.default_rng(
464
- [self.augment.seed, self._epoch, int(i)])
465
- shot, keep = apply_slot_dropout(shot, self.augment, rng)
466
- feats, t = featurize_shot(shot, deltas=self.deltas)
467
- item = {"feats": feats, "t": t}
468
- for k in LABEL_KEYS:
469
- item[k] = float(shot[k])
470
- item["keep_rate"] = keep # diagnostics/tests, not collated
471
- if self._train_mode and self.reweight is not None:
472
- tf = t_first_contact_s(shot)
473
- item["sample_w"] = (self.reweight.weight
474
- if (not math.isnan(tf))
475
- and tf >= self.reweight.t_first_s else 1.0)
476
- return item
477
-
478
-
479
- def val_split_indices(n_total: int, val_frac: float,
480
- seed: int) -> tuple[np.ndarray, np.ndarray]:
481
- """A validation split of about ``val_frac`` taken off TRAIN by seed --
482
- deterministic, disjoint, and independent of the machine or the shard
483
- layout. Returns (train_idx, val_idx), both sorted."""
484
- perm = np.random.default_rng(seed).permutation(n_total)
485
- n_val = int(round(n_total * val_frac))
486
- return np.sort(perm[n_val:]), np.sort(perm[:n_val])
487
-
488
-
489
- class BucketBatcher:
490
- """Group batches by TOKEN BUDGET (max length in the batch x batch size <=
491
- budget) after a length sort with jitter. Sequences run from 13 to 1192
492
- frames, so a fixed batch size either blows up VRAM on the long tail or
493
- wastes it on padding at the short end (the development GPU has 8 GB).
494
- Batch order is shuffled from [seed, epoch], so every epoch is
495
- reproducible."""
496
-
497
- def __init__(self, lengths: np.ndarray, indices: np.ndarray,
498
- token_budget: int = 40_000, max_batch: int = 256,
499
- seed: int = 0):
500
- self.lengths, self.indices = lengths, np.asarray(indices)
501
- self.token_budget, self.max_batch = token_budget, max_batch
502
- self.seed = seed
503
-
504
- def epoch_batches(self, epoch: int, shuffle: bool = True) -> list[np.ndarray]:
505
- rng = np.random.default_rng([self.seed, epoch])
506
- lens = self.lengths[self.indices].astype(np.float64)
507
- if shuffle:
508
- lens = lens * rng.uniform(0.9, 1.1, len(lens))
509
- order = self.indices[np.argsort(lens, kind="stable")]
510
- batches, cur, cur_max = [], [], 0
511
- for i in order:
512
- li = int(self.lengths[i])
513
- new_max = max(cur_max, li)
514
- if cur and ((len(cur) + 1) * new_max > self.token_budget
515
- or len(cur) >= self.max_batch):
516
- batches.append(np.array(cur))
517
- cur, cur_max = [], 0
518
- new_max = li
519
- cur.append(int(i))
520
- cur_max = new_max
521
- if cur:
522
- batches.append(np.array(cur))
523
- if shuffle:
524
- rng.shuffle(batches)
525
- return batches
526
-
527
-
528
- def collate(items: list[dict]) -> dict:
529
- """Pad to the batch's max length and produce torch tensors. ``mask``
530
- True means a real frame, False means padding.
531
-
532
- ``sample_w`` only appears when the loader emits it -- a batch without
533
- that key goes through EXACTLY the old loss code path, not the
534
- weighted-mean branch.
535
- """
536
- B = len(items)
537
- T = max(len(it["t"]) for it in items)
538
- dim = items[0]["feats"].shape[1]
539
- x = torch.zeros(B, T, dim, dtype=torch.float32)
540
- t = torch.zeros(B, T, dtype=torch.float32)
541
- mask = torch.zeros(B, T, dtype=torch.bool)
542
- for i, it in enumerate(items):
543
- n = len(it["t"])
544
- x[i, :n] = torch.from_numpy(np.ascontiguousarray(it["feats"]))
545
- t[i, :n] = torch.from_numpy(np.ascontiguousarray(it["t"]))
546
- mask[i, :n] = True
547
- out = {"x": x, "t": t, "mask": mask}
548
- for k in LABEL_KEYS:
549
- out[k] = torch.tensor([it[k] for it in items], dtype=torch.float32)
550
- if any("sample_w" in it for it in items):
551
- out["sample_w"] = torch.tensor(
552
- [float(it.get("sample_w", 1.0)) for it in items],
553
- dtype=torch.float32)
554
- return out
555
-
556
-
557
  # ----------------------------------------------------------------- model
558
 
559
  @dataclass
@@ -721,107 +432,8 @@ class ShotNet(nn.Module):
721
  "p_ident": torch.sigmoid(out["ident_logit"]).cpu().numpy()}
722
 
723
 
724
- # ------------------------------------------------------------------ loss
725
-
726
- def _phi_vec_loss(pred_vec: torch.Tensor,
727
- phi_deg: torch.Tensor) -> torch.Tensor:
728
- """MSE against the unit vector (cos phi, sin phi) -- continuous across
729
- 0/360."""
730
- rad = torch.deg2rad(phi_deg)
731
- target = torch.stack([torch.cos(rad), torch.sin(rad)], dim=-1)
732
- return F.mse_loss(pred_vec, target, reduction="none").sum(-1)
733
-
734
-
735
- def _phi_ang_huber(pred_vec: torch.Tensor, phi_deg: torch.Tensor,
736
- delta_deg: float) -> torch.Tensor:
737
- """Huber on the ANGULAR DISTANCE (in radians, with delta given in
738
- degrees): the difference comes through atan2(sin, cos), so it is
739
- continuous across 0/360. Compared with vec-MSE: the same ~delta^2
740
- curvature when small, but beyond delta the gradient does NOT keep growing
741
- with sin(delta) -- hopeless shots (delta ~90 deg) are capped and the bulk
742
- of the distribution (which decides the gate's MEDIAN) is not crushed by
743
- the tail. A light penalty keeps ||pred_vec|| near 1 (direction is the
744
- signal, but the norm must not collapse)."""
745
- rad = torch.deg2rad(phi_deg)
746
- norm = torch.linalg.vector_norm(pred_vec, dim=-1)
747
- u = pred_vec / (norm[..., None] + 1e-6)
748
- cos_d = u[..., 0] * torch.cos(rad) + u[..., 1] * torch.sin(rad)
749
- sin_d = u[..., 1] * torch.cos(rad) - u[..., 0] * torch.sin(rad)
750
- d_rad = torch.atan2(sin_d, cos_d).abs()
751
- hub = F.huber_loss(d_rad, torch.zeros_like(d_rad),
752
- delta=math.radians(delta_deg), reduction="none")
753
- return hub + 0.01 * (norm - 1.0) ** 2
754
-
755
-
756
- def shotnet_loss(out: dict, batch: dict, cfg: ShotNetConfig) -> dict:
757
- """The total loss plus each head's. (a, b) is only scored on
758
- identifiable shots.
759
-
760
- When ``batch["sample_w"]`` is PRESENT, every head reduces with a WEIGHTED
761
- MEAN ``sum(w.l)/sum(w)`` instead of a plain mean, so the loss scale (and
762
- therefore the gradient scale) stays about 1 even though the target
763
- slice's weight is 3. WITHOUT that key the expression is the old one,
764
- unchanged down to the arithmetic, so the configs stay comparable number
765
- for number.
766
- """
767
- w = batch.get("sample_w")
768
- if w is None:
769
- def red(x):
770
- return x.mean()
771
-
772
- def red_m(x, m):
773
- return (x * m).sum() / m.sum().clamp(min=1.0)
774
- else:
775
- def red(x):
776
- return (x * w).sum() / w.sum().clamp(min=1e-6)
777
-
778
- def red_m(x, m):
779
- return (x * m * w).sum() / (m * w).sum().clamp(min=1e-6)
780
-
781
- v0_t = batch["label_v0"]
782
- v0_target = torch.log(v0_t) if cfg.v0_log else v0_t
783
- l_v0 = red(F.huber_loss(out["v0_z"], v0_target, delta=cfg.huber_v0,
784
- reduction="none"))
785
- if cfg.phi_loss == "ang_huber":
786
- l_phi = red(_phi_ang_huber(out["phi_vec"], batch["label_phi"],
787
- cfg.phi_huber_deg))
788
- else:
789
- l_phi = red(_phi_vec_loss(out["phi_vec"], batch["label_phi"]))
790
- ab_target = torch.stack([batch["label_a"], batch["label_b"]],
791
- dim=-1) / cfg.ab_scale
792
- l_ab_per = F.huber_loss(out["ab"], ab_target, delta=cfg.huber_ab,
793
- reduction="none").mean(-1)
794
- m = batch["identifiable"]
795
- l_ab = red_m(l_ab_per, m)
796
- l_id = red(F.binary_cross_entropy_with_logits(out["ident_logit"], m,
797
- reduction="none"))
798
- total = (cfg.w_v0 * l_v0 + cfg.w_phi * l_phi + cfg.w_ab * l_ab
799
- + cfg.w_ident * l_id)
800
- parts = {"v0": l_v0, "phi": l_phi, "ab": l_ab, "ident": l_id}
801
- if cfg.aux_ball:
802
- vb = batch["v0_ball"].clamp(min=1e-3)
803
- l_avb = red(F.huber_loss(out["v0_ball_z"],
804
- torch.log(vb) if cfg.v0_log else vb,
805
- delta=cfg.huber_v0, reduction="none"))
806
- if cfg.phi_loss == "ang_huber":
807
- l_apb = red(_phi_ang_huber(out["phi_ball_vec"],
808
- batch["phi_ball"], cfg.phi_huber_deg))
809
- else:
810
- l_apb = red(_phi_vec_loss(out["phi_ball_vec"],
811
- batch["phi_ball"]))
812
- total = total + cfg.w_aux * (l_avb + l_apb)
813
- parts.update({"aux_v0_ball": l_avb, "aux_phi_ball": l_apb})
814
- parts["total"] = total
815
- return parts
816
-
817
-
818
  # --------------------------------------------------------------- metrics
819
 
820
- def circ_diff_deg(x: np.ndarray, y: np.ndarray) -> np.ndarray:
821
- """|delta phi| on the circle, in degrees -- the same formula as the
822
- evaluation harness."""
823
- return np.abs(((np.asarray(x) - np.asarray(y) + 180.0) % 360.0) - 180.0)
824
-
825
 
826
  def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
827
  """(a, b) -> (vert, side) at the harness's ground-truth thresholds. vert
@@ -835,28 +447,3 @@ def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
835
  return vert, side
836
 
837
 
838
- def gate_metrics(pred: dict, labels: dict) -> dict:
839
- """The four gate metrics, measured on one set (validation every epoch):
840
- - dphi_med: the median |delta phi| over the whole set (degrees)
841
- - v0_relerr_med: the median |V0_hat - V0|/V0 (raw, cue ruler)
842
- - side_acc: the sign of a (a plain sign, NO dead zone -- the net
843
- does not abstain) over identifiable shots whose ground
844
- truth side is not neutral
845
- - vert_acc: the three classes follow/stun/draw (b_hat quantised at
846
- B_STUN_MAX) over ALL identifiable shots
847
- """
848
- ident = np.asarray(labels["identifiable"]) > 0.5
849
- dphi = circ_diff_deg(pred["phi_deg"], labels["label_phi"])
850
- relerr = np.abs(pred["v0"] - labels["label_v0"]) / labels["label_v0"]
851
- gt_vert, gt_side = spin_classes(labels["label_a"], labels["label_b"])
852
- pv, _ = spin_classes(pred["a"], pred["b"])
853
- side_mask = ident & (gt_side != "")
854
- side_ok = (np.sign(pred["a"]) == np.sign(labels["label_a"]))[side_mask]
855
- vert_ok = (pv == gt_vert)[ident]
856
- id_acc = ((pred["p_ident"] > 0.5) == ident).mean()
857
- return {"dphi_med": float(np.median(dphi)),
858
- "v0_relerr_med": float(np.median(relerr)),
859
- "side_acc": float(side_ok.mean()) if side_ok.size else float("nan"),
860
- "vert_acc": float(vert_ok.mean()) if vert_ok.size else float("nan"),
861
- "n_side": int(side_mask.sum()), "n_ident": int(ident.sum()),
862
- "ident_acc": float(id_acc)}
 
5
  The inverse of the RL action space: the observation sequence of ONE shot
6
  {ball positions in table coordinates, a visibility mask, timestamps} ->
7
  (V0, phi, a, b), exactly the four dimensions of ``cue.set_state``. Trained
8
+ PURELY on synthetic data (the pilot noise model is the contract), and
9
+ inference here needs no pooltool so it can run in the CV environment.
 
10
 
11
  Architecture: a small transformer encoder, a learned **shot token** at the
12
  head of the sequence from which the outputs are read, and **RoPE on real
 
34
  - optional aux heads: ``v0_ball``/``phi_ball`` (labels the dataset already
35
  carries); the gate is still scored on the cue V0.
36
 
37
+ This module holds the INFERENCE path only. The training code that produced
38
+ ``models/shotnet_20260812_c4b`` (the shard loader, the bucketing batcher,
39
+ the collate function, the loss and the gate metrics, plus the slot-dropout
40
+ and target-reweight augmentations) lived here after the port but nothing in
41
+ this repository ran it, so it was removed; it is still in the poolcoach-rl
42
+ history if a retrain is ever needed. ``ShotNetConfig`` is unchanged, so the
43
+ existing checkpoints keep loading.
 
 
 
44
 
45
  The spin classification constants (A_SIDE_MIN/B_STUN_MAX) are copied from
46
+ the evaluation harness that owns the ground truth; here they serve the
47
+ mapping from a predicted (a, b) to a class.
48
  """
49
 
50
  from __future__ import annotations
51
 
52
  import math
53
+ from dataclasses import dataclass
 
54
 
55
  import numpy as np
56
  import torch
 
125
  return feats, np.asarray(shot["t"], dtype=np.float32)
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  # ------------------------- sim to real: build a shot from the app's track
130
  # The sim-to-real input gap is the main risk here: the net trains with
 
265
  "n_frames": F, "n_balls": K + 1}
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  # ----------------------------------------------------------------- model
269
 
270
  @dataclass
 
432
  "p_ident": torch.sigmoid(out["ident_logit"]).cpu().numpy()}
433
 
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  # --------------------------------------------------------------- metrics
436
 
 
 
 
 
 
437
 
438
  def spin_classes(a: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
439
  """(a, b) -> (vert, side) at the harness's ground-truth thresholds. vert
 
447
  return vert, side
448
 
449