hitsmart-cv / tests /test_contract.py
LuciPengu
Honest metric surface (repo bde7adf)
d0e989c
Raw
History Blame Contribute Delete
10.8 kB
"""The Space -> client contract: what clients are promised.
Two kinds of test here, and the split is the point.
The first kind pins the WORDING. Those sentences are the fallback path for
cv_results rows stored before the Space shipped the numbers, and the iOS app
parses them with regexes; rewording one is allowed, but it has to be a
deliberate act that breaks a test, not a silent degrade to raw text.
The second kind pins the AGREEMENT between the sentence and the numbers behind
it -- that every metric a fighter dict states in English is exactly what
contract.py renders from `_meta.measured`. That is the invariant the whole
module exists for: if it holds, there is nothing left to parse.
"""
import re
import pytest
import contract
import pose_features as pf
from test_trackers import two_fighter_poses
# --- the sentences ------------------------------------------------------------
RENDERINGS = [
("pressure",
{"label": "pressuring", "net_sw_per_10s": 1.234},
"pressuring (net +1.2 SW/10s)"),
("pressure",
{"label": "unclear (camera moves with the fighters)", "net_sw_per_10s": -0.04},
"unclear (camera moves with the fighters) (net -0.0 SW/10s)"),
("guard_by_range",
{"close_pct": 62.4, "long_pct": 91.0, "drop_pp": 28.6},
"both hands up 62% in the pocket vs 91% at range"),
("guard_by_hand",
{"lead_up_pct": 88.0, "rear_up_pct": 54.4},
"lead hand up 88% / rear hand up 54%"),
("guard_by_hand",
{"left_up_pct": 88.0, "right_up_pct": 54.4},
"left hand up 88% / right hand up 54%"),
("pocket_work",
{"entries": 23, "median_dwell_s": 1.8, "entries_per_min": 12.0},
"23 trips into range (12/min, median stay 1.8s)"),
("level_changes",
{"count": 7, "per_min": 14.0},
"7 level changes (14/min)"),
("level_changes",
{"count": 1, "per_min": 2.0},
"1 level change (2/min)"),
("distance_control",
{"i_closed_pct": 28.6, "they_closed_pct": 57.1,
"mutual_pct": 14.3, "n_entries": 7},
"closed the distance on 29% of entries "
"(opponent 57%, mutual 14%; n=7)"),
("vs_opponent",
{"hands_up_mine": 74.0, "hands_up_theirs": 89.0,
"pocket_guard_mine": 62.0, "pocket_guard_theirs": 80.0},
"hands up 74% vs their 89%; "
"both hands up in the pocket 62% vs their 80%"),
("vs_opponent",
{"hands_up_mine": 74.0, "hands_up_theirs": 89.0},
"hands up 74% vs their 89%"),
("range_profile",
{"close_pct": 30, "mid_pct": 45, "long_pct": 25},
"close 30% / mid 45% / long 25%"),
("stance",
{"orthodox_pct": 70.6, "southpaw_pct": 29.4},
"orthodox 71% / southpaw 29%"),
("stance_matchup",
{"closed_pct": 60.4, "open_pct": 39.6},
"closed 60% / open 40%"),
("weight_side",
{"label": "rear-loaded", "front_pct": 20.0,
"centered_pct": 30.0, "rear_pct": 50.0},
"rear-loaded (weight over front foot 20% / centered 30% / rear foot 50%)"),
("weight_side",
{"label": "rear-loaded", "front_pct": 20.0, "centered_pct": 30.0,
"rear_pct": 50.0, "throwing": contract.weight_throwing("onto_front", 35.0)},
"rear-loaded (weight over front foot 20% / centered 30% / rear foot 50%); "
"drives onto the front foot when throwing (35%)"),
("weight_side",
{"label": "front-heavy", "front_pct": 55.0, "centered_pct": 30.0,
"rear_pct": 15.0, "throwing": contract.weight_throwing("off_front", 40.0)},
"front-heavy (weight over front foot 55% / centered 30% / rear foot 15%); "
"stays OFF the front foot when throwing (40%)"),
]
@pytest.mark.parametrize("key,values,expected", RENDERINGS)
def test_wording_is_pinned(key, values, expected):
assert contract.render(key, values) == expected
def test_an_unmeasured_read_says_why_instead_of_guessing():
assert (contract.render("stance", {"unknown": "feet rarely visible"})
== "unknown (feet rarely visible)")
def test_render_all_skips_what_was_never_measured():
out = contract.render_all({"stance": None, "range_profile":
{"close_pct": 10, "mid_pct": 60, "long_pct": 30}})
assert out == {"range_profile": "close 10% / mid 60% / long 30%"}
# --- the legacy parse ---------------------------------------------------------
# Transcribed from hitanalyze/Models/CVAnalysis.swift. Those regexes read rows
# stored before the numbers shipped, so they have to keep matching what the
# Space says today -- a reworded sentence would leave old and new rows
# rendering differently in the same history list.
IOS_REGEXES = {
# The advancing/retreating clause is gone from what the Space says today,
# but rows stored before that still carry it -- so both halves are
# optional and the app keeps reading either. Matches the Swift regex in
# CVFighterStats.PressureRead.init(raw:).
"pressure": r"^(.+?)\s*\((?:net ([+-][\d.]+) SW/10s)?(?:; )?"
r"(?:advancing ([\d.]+)% / retreating ([\d.]+)%)?\)",
"range_profile": r"close ([\d.]+)% / mid ([\d.]+)% / long ([\d.]+)%",
"stance": r"^([a-z]+) ([\d.]+)% / ([a-z]+) ([\d.]+)%$",
"stance_matchup": r"^([a-z]+) ([\d.]+)% / ([a-z]+) ([\d.]+)%$",
"weight_side": r"^(.+?) \(weight over front foot ([\d.]+)% / centered ([\d.]+)% "
r"/ rear foot ([\d.]+)%\)(?:; (.+))?$",
}
# Metrics that shipped AFTER the values did. No stored row has ever carried
# their sentence, so there is nothing legacy to parse: a client reads
# `_meta.measured` and falls back to showing the sentence as plain text. Listed
# explicitly so a new metric can't quietly skip the legacy-parse question.
VALUES_ONLY = {"guard_by_range", "guard_by_hand", "pocket_work",
"distance_control", "vs_opponent", "level_changes"}
@pytest.mark.parametrize("key,values,expected", RENDERINGS)
def test_the_ios_fallback_regexes_still_read_the_sentence(key, values, expected):
if key in VALUES_ONLY:
pytest.skip(f"{key} is values-only: no stored row carries its sentence")
assert re.search(IOS_REGEXES[key], expected), \
f"{key} no longer matches the app's legacy parse: {expected!r}"
def test_every_rendered_metric_is_parseable_or_declared_values_only():
assert set(IOS_REGEXES) | VALUES_ONLY == set(contract.RENDERERS)
assert not (set(IOS_REGEXES) & VALUES_ONLY)
# --- punches ------------------------------------------------------------------
def parse_like_ios(line):
"""CVPunchEvent.init(logLine:), transcribed."""
parts = line.split(" ")
assert len(parts) >= 3
minutes, seconds = parts[0].split(":")
out = {"t": float(minutes) * 60 + float(seconds), "hand": parts[1]}
rest = parts[2:]
if rest[0].startswith("("):
out["role"] = rest[0].strip("()")
rest = rest[1:]
else:
out["role"] = None
out["type"] = rest[0]
out["conf"] = float(rest[1]) if len(rest) > 1 else None
return out
@pytest.mark.parametrize("role", ["lead", "rear", ""])
@pytest.mark.parametrize("kind,conf", [("hook", 0.82), ("straight", 0.5),
("unclear", 0.11)])
def test_a_punch_reads_the_same_from_its_values_or_its_line(role, kind, conf):
e = contract.punch_event(65.34, "left", role, kind, conf)
parsed = parse_like_ios(e["line"])
assert parsed["hand"] == e["hand"]
assert parsed["role"] == e["role"]
assert parsed["type"] == e["type"]
assert parsed["conf"] == e["conf"]
assert parsed["t"] == pytest.approx(e["t"], abs=0.05)
def test_an_unclear_call_reports_no_confidence():
# There is no confidence to report in a type the classifier or the geometry
# veto refused to name.
e = contract.punch_event(5.3, "right", "rear", "unclear", 0.4)
assert e["conf"] is None
assert e["line"] == "0:05.3 right (rear) unclear"
def test_the_punch_clock_survives_the_minute_boundary():
assert contract.clock_tenths(59.96) == "1:00.0"
assert contract.clock_tenths(0.0) == "0:00.0"
# --- errors -------------------------------------------------------------------
def test_every_failure_has_a_code_a_heading_and_a_body():
for code in contract.ERRORS:
report = contract.error_report(code, seconds=200, limit=120)
heading, _, body = report.partition("\n")
assert heading.startswith("### ") and len(heading) > 4
assert body.strip(), f"{code} has no body"
def test_the_headings_the_app_falls_back_to_are_unique():
headings = [h for h, _ in contract.ERRORS.values()]
assert len(set(headings)) == len(headings)
def test_a_failure_carries_its_reason_but_no_fighters():
feats = contract.error_features("seed_failed")
assert [k for k in feats if not k.startswith("_")] == []
meta = feats["_meta"]
assert meta["error"]["code"] == "seed_failed"
assert meta["error"]["message"] == contract.ERRORS["seed_failed"][1]
assert meta["contract_version"] == contract.CONTRACT_VERSION
def test_clip_too_long_states_both_numbers():
report = contract.error_report("clip_too_long", seconds=200, limit=120)
assert "~200s" in report and "120s" in report
# --- output slots -------------------------------------------------------------
def test_report_and_features_lead_the_output_slots():
# A client can't look slots up by name until it has read the features
# payload the names live in, so those two are positional by definition.
assert contract.OUTPUT_NAMES[:2] == ("report", "features")
assert len(set(contract.OUTPUT_NAMES)) == len(contract.OUTPUT_NAMES)
def test_the_stamp_tells_a_client_what_it_is_reading():
meta = contract.stamp({})
assert meta["contract_version"] == contract.CONTRACT_VERSION
assert meta["outputs"] == list(contract.OUTPUT_NAMES)
# --- the agreement ------------------------------------------------------------
def test_every_sentence_is_rendered_from_the_numbers_beside_it():
"""The invariant the module exists for.
Whatever a fighter dict says in English about a measured read, `_meta
.measured` carries the values it was rendered from -- so a client reads
values, and the two can never disagree. Edit a sentence anywhere but
contract.py and this fails."""
poses = two_fighter_poses(250.0, 750.0, n=120)
feats, _overlay = pf._features_from_poses(poses, 30.0, ("Fighter A", "Fighter B"))
assert feats, "the synthetic clip should analyze"
measured = feats["_meta"]["measured"]
fighters = [k for k in feats if not k.startswith("_")]
assert set(measured) == set(fighters)
for name in fighters:
for key in contract.RENDERERS:
assert key in measured[name], f"{name} states {key} with no values"
assert feats[name][key] == contract.render(key, measured[name][key]), \
f"{name}.{key} was written somewhere other than contract.py"