Skip to content

feat(hy3): parse reaction and relay takeoff times (E2 col 83-87, F2 cols 83-102) - #27

Open
fsalum wants to merge 9 commits into
SwimComm:masterfrom
fsalum:feat/reaction-time
Open

feat(hy3): parse reaction and relay takeoff times (E2 col 83-87, F2 cols 83-102)#27
fsalum wants to merge 9 commits into
SwimComm:masterfrom
fsalum:feat/reaction-time

Conversation

@fsalum

@fsalum fsalum commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

e2_parser read backup_4_time through col 82 and then jumped straight to the date
at col 88, silently dropping the five columns in between. f2_parser had the same
hole, plus a 15-column gap the code acknowledged in a comment but never identified:

# offsets are IDENTICAL to e2_parser; only alt_time_code differs because F2
# has a 15-column gap before its date field (date at col 103, not 88).

Those columns are reaction times.

Record Columns Width Meaning
E2 83-87 5 swimmer's start reaction time
F2 83-87, 88-92, 93-97, 98-102 4 x 5 relay legs 1-4: leadoff block start, then three exchange takeovers

That accounts for the gap exactly: E2 carries one 5-char slot and its date follows
at col 88; F2 carries four, so its date sits 15 columns later at col 103 and its
alt_time_code at col 111. No offsets are guessed — the previously-known fields on
both sides now abut the new ones with nothing left over.

Evidence that these are reaction times

The format says so itself. 513 files in a 33,008-file corpus write the literal
string NRT — Meet Manager's "No Reaction Time" — into F2 takeover slots 2-4, and
in those same rows slot 1 carries a signed +0.00. A field that is named "no
reaction time" when unmeasured is a reaction time when measured.

The physics corroborates it, and distinguishes the slots. Slot 1 is a block
start off the leadoff; slots 2-4 are relay exchanges, where a swimmer may leave the
block before the incoming touch. Across the corpus:

Slot Negatives Median
1 (leadoff) 23 / 87,069 0.61
2 2,702 ~0.24
3 2,282 ~0.24
4 2,861 ~0.24

Leg 1 behaves like a start (essentially never negative, median 0.61s — a human
reacting to a signal). Legs 2-4 behave like exchanges (frequently negative, median
0.24s — a swimmer already moving). Nothing but a takeoff time produces that split,
and it independently confirms the slot ordering.

Density. 2.56% of 53.7M E2 rows corpus-wide carry a value in the plausible
reaction range — consistent with fully-automatic timing being available at a
minority of meets rather than the column being noise.

Design notes

Negatives are meaningful and must not be filtered. A relay takeover slot records
an early exchange as a negative number — 7,868 such values corpus-wide. The existing
parse_time_or_none requires > 0.0 and would silently destroy every one of them,
so these columns get their own coercion, parse_reaction_time, rather than reusing
it. The docstring says this in place, because the bug it prevents is invisible: a
> 0.0 filter leaves a green test suite and just loses the data.

Sentinels are plural and slot-dependent. Blank, 0.00 in any sign spelling
(0.00 / +0.00 / -0.00 all round-trip through float() to zero), and the
literal NRT. All map to None. Unparseable junk (a bare + sign, stray high
bytes — both observed) also maps to None.

Out-of-range values are passed through unchanged, deliberately. A minority of
files put something above the plausible reaction range in these columns: 80,706
values above 2.0 across the corpus, concentrated in 117 files that are 100%
implausible — i.e. a whole-file property, not scattered corruption. Its meaning is
unresolved. Clamping or dropping here would make that population permanently
invisible to anyone who wants to work it out, so the parser reports what the file
says and leaves the judgement to the caller.

Exposed positionally for F2. finals_reaction_times is a 4-list rather than
four named attributes, matching the file's uniform column layout, and keeping the
list index equal to the relay leg number minus one.

New attributes

Per course prefix (prelim / swimoff / finals), so six in total:

  • <prefix>_reaction_time: Optional[float] — set by e2_parser
  • <prefix>_reaction_times: Optional[List[Optional[float]]] — set by f2_parser

Both default to None, so nothing changes for existing callers.

Tests

Two new PII-redacted fixtures covering the opposite shapes these columns take:

  • mm_reaction_times_dense.hy3 — a modern fully-automatic-timing meet. 35 of 37 E2
    rows carry a reaction time (0.53-0.90), all four F2 slots populated on every relay,
    and one relay with three negative takeovers. That relay is the regression guard: a
    > 0.0 filter would drop every early exchange, and without this fixture the suite
    would still look green.
  • mm_relay_nrt_sentinel.hy3 — the opposite generation: NRT in every takeover slot,
    +0.00 in every leadoff, E2 reaction column blank throughout. Everything must parse
    to None. A raw-file tripwire asserts the sentinels are still physically present,
    so a regenerated fixture that lost them cannot pass by vacuous truth.

Plus unit tests for parse_reaction_time and an offset regression test on each of
E2 and F2. A one-column misread still yields plausible-looking floats, so the tests
assert the neighbouring date field parses correctly — that is the tripwire that
actually catches a shifted offset.

Full suite: 106 passing.

Also included: fixture redaction fix

While building the new fixtures I re-audited the existing ones against the documented
rule set in tests/hy3/fixtures/README.md and found the rules had missed two fields:
E1 cols 9-13, and the same columns within each 13-char F3 relay-leg slot, both of
which hold the first five characters of the swimmer's last name. Six fixtures added
before the rule set existed still carried real surname fragments there.

No parser reads either field — e1_parser takes the swimmer id at cols 4-8 and the
event gender at col 14, and f3_parser takes the id and the leg number at col 15 —
so they are blanked to Swimm, matching the existing D1 last-name placeholder.
Verified non-destructive: every file is byte-for-byte the same length, and a
character-level diff against the previous revision confirms every changed position
falls inside an E1 or F3 name-prefix range. All other record types and all line
checksums are untouched, and the suite passes unchanged.

The README now documents both fields and states that the rule applies to every
fixture in the directory.

Merge order

This is stacked on fix/relay-parse-robustness (#26), which is itself stacked on
feat/diving-stroke (#25). Please merge #25, then #26, then this one.

It targets master only because a cross-fork PR's base must be a branch that exists
in this repo, and fix/relay-parse-robustness lives on the fork — the same reason
#26 targets master despite sitting on top of #25. As a result the diff shown here
also contains the three commits belonging to #25 and #26:

Only the six commits from 3856af2 onward are new in this PR, and they are the
ones described above. Once #25 and #26 land, this PR's diff collapses to just those
six with no rebase needed.

fsalum added 9 commits July 20, 2026 00:07
Hy-Tek uses three diving stroke chars, not one: F = 1-metre
springboard, G = 3-metre springboard, H = platform. The prior
DIVING = "F", "6", 6 treated F as the only diving code and
extrapolated numeric aliases "6"/6 from the FREESTYLE..MEDLEY
pattern (1..5) with no corpus evidence Hy-Tek ever writes those
in the stroke column for diving -- a wrong alias could silently
mis-map some other numeric code onto diving.

Measured on a real corpus: 2012 PAC-12 (F=26 G=26 H=26), 2007 SEC
(F=60 G=60 H=54), 2005 PAC-10 (F=15 G=15 H=12) all show three
parallel diving events. Across 13 multi-diving-event files, G
never appears without F and H never appears without G (strict
nesting). Dive counts match within a meet across chars, and
median scores ascend F < G < H, tracking increasing degree of
difficulty. HS meets emit only F, consistent with NFHS being
1-metre only.

G and H previously fell through select_from_enum() to UNKNOWN,
silently dropping all 3-metre and platform diving results.

Drop the numeric aliases; use the character form only, since
select_from_enum(Stroke, ...) is only ever called with a single
extracted stroke-column char (hy3) or event_stroke field (hyv),
never a numeric value, in the whole codebase.
Two hy3 line parsers raised on real-world Meet Manager exports instead of
degrading gracefully:

- f3_parser: an F3 relay leg referencing a swimmer meet-id with no D1 roster
  record raised KeyError. Skip the unrostered leg (the relay keeps its other
  legs), matching the existing tolerance for the empty-swimmers and absent
  leg-1 cases.
- h1_parser: an H1 DQ-detail line whose last_entry carries no DQ slot raised
  an AssertionError. Make it a no-op, mirroring h2_parser -- an H1 can resolve
  to a non-DQ entry (e.g. a relay DQ, or a non-DQ entry emitted between the DQ
  result and its H1). The DQ result itself is unaffected; only the reason
  string is skipped.

Adds regression unit tests for both.
Reaction and relay-takeoff columns need their own coercion. parse_time_or_none
requires a positive value, but a takeover slot records an early exchange as a
negative number -- 7,868 such values across a 33,008-file corpus -- so reusing
it would drop every one of them.

Sentinels are plural and slot-dependent: blank, 0.00 in any sign spelling, and
the literal NRT ("No Reaction Time") that Meet Manager writes into unmeasured
takeover slots in 513 files. All map to None.

Values above the plausible reaction range are passed through unchanged. A
minority of files put something else in these columns; filtering here would
make that population permanently invisible.
The parser read backup_4 through col 82 and then jumped to the date at col 88,
dropping the five columns between. They hold the swimmer's start reaction time:
2.56% of 53.7M E2 rows corpus-wide carry a value in the plausible range.

Includes an offset regression test. A one-column error still produces
plausible-looking floats, so the neighbouring date is the tripwire that catches
a misread.
The code already noted a '15-column gap' before F2's date field without
identifying it. That gap is relay legs 2, 3 and 4 (3 x 5 chars); leg 1 sits in
the 5-char slot F2 shares with E2.

The columns are reaction times, and the file format says so itself: 513 files
write the literal NRT ('No Reaction Time') into unmeasured takeover slots, and
leg 1 in those same rows carries a signed +0.00.

The physics corroborates it across the corpus. Leg 1 is a block start: 23
negatives out of 87,069, median 0.61. Legs 2-4 are exchanges: 2,702 / 2,282 /
2,861 negatives, median ~0.24. Nothing but a takeoff time behaves that way.

Exposed as a positional 4-list rather than four named attributes, matching the
file's uniform column layout.
Two PII-redacted slices of real meets, covering the two opposite shapes the
reaction columns take.

The dense fixture is a modern fully-automatic-timing meet: 35 of its 37 E2 rows
carry a start reaction time (0.53-0.90), all four F2 takeoff slots are
populated on every relay, and one relay has three negative takeovers. That last
one is the regression guard -- a >0.0 filter on these columns would drop every
early exchange and the tests would still look green without it.

The NRT fixture is the opposite: an older generation that wrote the literal NRT
into every takeover slot and a signed +0.00 into every leadoff, with the E2
reaction column blank throughout. Everything must parse to None. A raw-file
tripwire asserts the sentinels are still physically present, so a regenerated
fixture that lost them cannot pass by vacuous truth.

Redaction extends the documented rules to two fields they had missed: the
5-char last-name prefix that E1 and each F3 relay-leg slot carry. No parser
reads them, but they are real surname fragments. Verified by diffing every
emitted line against its source line and asserting the differing character
positions fall only inside the redacted column ranges.
E1 cols 9-13, and the same columns in each 13-char F3 relay-leg slot, hold the
first five characters of the swimmer's last name. The documented redaction rule
set did not cover them, so the five fixtures added before the rule existed still
carried 109 distinct real surname fragments between them.

No parser reads either field -- e1_parser takes the swimmer id at cols 4-8 and
the event gender at col 14, and f3_parser takes the id and the leg number at
col 15 -- so blanking them to Swimm, matching the D1 last_name placeholder
Swimmer<meet_id>, is parse-safe. All 106 tests pass unchanged.

Only those columns were touched: every file is byte-for-byte the same length,
and a character-level diff against the previous revision confirms every changed
position falls inside an E1 or F3 name-prefix range. Line checksums and all
other record types are untouched.

A full re-audit of all seven fixtures against the complete documented rule set
now reports no unredacted personal field of any kind.
…ture

Completes the previous commit, which covered five of the six fixtures that
predate the E1/F3 name-prefix rule and missed this one.

221 occurrences, 142 distinct real surname fragments, all in F3 leg slots --
this fixture exists to exercise relays with alternates, so it is almost entirely
F3 content. Two of its F3 lines carry a fifth leg, so anything checking only the
first four slots will under-report it; all eight slots are covered here, matching
f3_parser's own range.

No parser reads cols 9-13 of a leg slot -- f3_parser takes the swimmer id at
cols 4-8 and the leg number at col 15 -- so blanking them to Swimm is parse-safe.
The file is byte-for-byte the same length, a character-level diff against the
previous revision puts every changed position inside an F3 name-prefix range,
and test_bug2b_relay_entries_with_alternates_preserve_leg_numbers, which reads
this fixture, still passes along with the rest of the 106.

Every fixture in the directory now satisfies the documented rule set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant