Skip to content

perf: cache plugin qualifiers, hoist regex literals, add missing decoder tests#388

Open
kevinelliott wants to merge 4 commits into
masterfrom
claude/optimize-performance-decoders-ifxPy
Open

perf: cache plugin qualifiers, hoist regex literals, add missing decoder tests#388
kevinelliott wants to merge 4 commits into
masterfrom
claude/optimize-performance-decoders-ifxPy

Conversation

@kevinelliott
Copy link
Copy Markdown
Contributor

@kevinelliott kevinelliott commented Apr 17, 2026

Summary

Performance pass on the decoder hot path plus filling in test coverage gaps.

Performance

  • MessageDecoder.decode — pre-compute per-label candidate buckets at registration time. The hot path is now a single Map.get plus a startsWith loop instead of spread + Set dedup + filter + a plugin.qualifiers() call per candidate per message.
  • Hoist regex literals to module scope in CBand, ARINC_702, Label_80, and Label_83 (the CBAND_HEADER regex previously ran on every message because CBand is a wildcard plugin).
  • CoordinateUtils.decodeStringCoordinatesDecimalMinutes — validate the prefix once, then compute Number(...) once each for lat/lon (was twice).
  • Minor: Label_4A swaps match(/^[NS]/) for direct char compare; Label_83 strips whitespace once instead of per-field.

Bench (1.4M decodes across a mixed-label corpus, three runs each with warmup): ~8.09 µs/decode → ~7.44 µs/decode (~8% faster).

Decoder change

  • Label_B6_Forwardslash (CPDLC) was a no-op stub that dropped the body. Now extracts the text after the leading / as text and reports partial. Full ATN/PM-CPDLC parsing left as TODO.

Tests

New suites for previously untested decoders/utils: Label_QP, Label_QR, Label_QS, Label_ColonComma, Label_B6, ARINC_702, coordinate_utils. Plus a MessageDecoder.labelindex regression test for wildcard registration order.

407 → 446 passing tests across 88 → 95 suites.

Review-cycle changes

  • 78e2526 — fixed wildcard registration ordering bug flagged by Codex/Copilot (unshiftsplice at end of wildcard section, with regression test).
  • 0522b31 — wrapped Label_80 POS/ETA case bodies in blocks; corrected CBand char-range comment off-by-one; strengthened ARINC_702 peel test.
  • 45da708 — reverted cosmetic inverted-early-return refactor in decodeStringCoordinates (didn't actually save anything; the real win is in the decimal-minutes function and is preserved).

Test plan

  • npm test — 446 passing across 95 suites, no regressions
  • npm run lint — no new warnings
  • npx tsup — build succeeds, dist sizes unchanged
  • Microbenchmark (MessageDecoder.decode × 1.4M) shows ~8% per-decode improvement
  • Wildcard ordering regression test fails against the original unshift form, passes with the fix

Non-blocking follow-ups

Per maintainer review, two items worth follow-up issues but not merge blockers:

  • Guard MessageDecoder.registerPlugin against the latent qualifiers.labels = ['A', '*'] ordering, which would currently double-add the entry to bucket A. No existing plugin triggers this.
  • Extract a shared prefix-parsing helper between decodeStringCoordinates and decodeStringCoordinatesDecimalMinutes (CodeRabbit nitpick).

Performance:
- MessageDecoder.decode no longer calls plugin.qualifiers() on every
  message. Per-plugin metadata (preambles) is captured once at
  registration time, and per-label candidate lists are pre-built so the
  hot path is just a Map lookup + a single startsWith loop.
- Hoist module-scoped regex literals in CBand, ARINC_702, Label_80, and
  Label_83 to avoid re-allocating patterns inside per-message decode().
- CoordinateUtils.decodeStringCoordinatesDecimalMinutes now validates
  the prefix chars before parsing and computes Number(...) once each
  for lat/lon (was twice).
- Label_4A swaps a regex prefix check for a direct charAt comparison.
- Label_83 strips whitespace once and skips redundant per-field
  re-stripping.

Decoders:
- Label_B6 now extracts the CPDLC body after the leading "/" as text
  rather than discarding it (full ATN/PM-CPDLC parsing is still TODO).

Tests:
- Add tests for previously untested decoders: Label_QP, Label_QR,
  Label_QS, Label_ColonComma, Label_B6, ARINC_702.
- Add tests for CoordinateUtils.

Test count: 407 -> 445 passing, 88 -> 95 suites.
Copilot AI review requested due to automatic review settings April 17, 2026 02:03
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Walkthrough

Refactors MessageDecoder plugin registration/lookup to use PluginEntry-backed candidate lists with precomputed preambles; moves preamble matching into a helper. Adds multiple plugin unit tests, extracts shared regex constants, refactors several decoders, and fixes coordinate-decoding logic and tests.

Changes

Cohort / File(s) Summary
MessageDecoder core
lib/MessageDecoder.ts, lib/MessageDecoder.labelindex.test.ts
Replaced labelIndex/wildcardPlugins with candidatesByLabel, wildcardEntries, and PluginEntry objects; cache preambles on registration; add matchesPreambles helper; adjust candidate seeding to preserve wildcard registration order; new test ensures wildcard order is preserved across label buckets.
ARINC-702 wildcard plugin
lib/plugins/ARINC_702.ts, lib/plugins/ARINC_702.test.ts
Introduced module-level NEWLINE_REGEX for CR/LF stripping; added tests validating wildcard behavior, header normalization, checksum/description assertions, and end-to-end routing via MessageDecoder.
CBand decoder
lib/plugins/CBand.ts
Extracted CBAND_HEADER regex with named groups, added early return for short text, simplified header-match branching and payload decoding, and reorganized decode result construction.
Label_80 / Label_83
lib/plugins/Label_80.ts, lib/plugins/Label_83.ts
Extracted shared regex constants (POS_REGEX, WS_REGEX, NEWLINE_REGEX, ALL_WS_REGEX, ALL_DOTS_REGEX), replaced inline splits/matches with centralized regex usage, and used startsWith() for variant checks; minor parsing refactors.
Label_4A
lib/plugins/Label_4A.ts
Replaced regex test on fields[0] with local f0 and explicit first-character check (`'N'
Label_B6 & tests
lib/plugins/Label_B6.ts, lib/plugins/Label_B6.test.ts
decode() now uses initResult(), removes console.log, extracts body via substring(1), formats non-empty bodies with ResultFormatter.text(), and adds unit tests for preamble handling, empty-body negative-path, and routing.
Label_ColonComma, QP, QR, QS tests
lib/plugins/Label_ColonComma.test.ts, lib/plugins/Label_QP.test.ts, lib/plugins/Label_QR.test.ts, lib/plugins/Label_QS.test.ts
Added new Jest suites validating plugin metadata, decode behavior (full/partial/none), field extraction, remaining-text handling, and end-to-end MessageDecoder routing for each plugin.
Coordinate utilities & tests
lib/utils/coordinate_utils.ts, lib/utils/coordinate_utils.test.ts
Fixed decodeStringCoordinatesDecimalMinutes to return decoded coordinates when valid (reworked validation and parsing order), switched to charAt() for direction extraction, and added comprehensive tests covering multiple coordinate formats and utility functions.
Other minor refactors
lib/plugins/Label_83.ts (also listed above)
Whitespace/dot normalization improvements and small control-flow clarifications in parsing logic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #225: Rewrites MessageDecoder plugin registration/candidate structures; touches the same register/decode path and ordering semantics.
  • #231: Alters MessageDecoder plugin registration/lookup; likely impacted by the new candidate/wildcard handling.
  • #174: Changes MessageDecoder wildcard handling and ordering semantics similar to this refactor.

Suggested reviewers

  • andermatt64
  • taclane

"🐰
Hopping through plugin rows I go,
Wildcards in order, a tidy show,
Regexes tamed, coordinates true,
Tests sprout green where fixes grew,
A carrot-coded cheer—hop, review!"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf: cache plugin qualifiers, hoist regex literals, add missing decoder tests' clearly and accurately summarizes the main changes: performance optimizations (caching, regex hoisting) and test additions (new decoder tests).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/optimize-performance-decoders-ifxPy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0de83d3e93

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/MessageDecoder.ts Outdated
Comment on lines +134 to +135
for (const bucket of this.candidatesByLabel.values()) {
bucket.unshift(entry);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve wildcard registration order in candidate buckets

Appending a new wildcard plugin currently uses bucket.unshift(entry) for every existing label bucket, which makes later wildcard registrations run before earlier ones for labels that already have buckets. This changes decoder precedence versus the previous behavior (wildcards in registration order) and can alter which plugin wins when multiple wildcard decoders can parse the same message, especially when consumers call registerPlugin() after initialization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: Confirmed and addressed in 78e2526splice(wildcardEntries.length - 1, 0, entry) is the correct fix. New regression test MessageDecoder.labelindex.test.ts covers it. Resolved.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR focuses on reducing per-message overhead in the decoding hot path (especially plugin qualification/preamble filtering and regex allocation), while expanding unit test coverage for several decoders and coordinate utilities.

Changes:

  • Cache plugin qualifier metadata at registration time and prebuild per-label candidate lists to speed up MessageDecoder.decode().
  • Hoist frequently used regex literals to module scope across multiple plugins and tighten a few hot-path string checks.
  • Add/expand unit tests for CoordinateUtils and several label decoders/wrappers (QP/QR/QS, :;, B6, ARINC 702).

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/MessageDecoder.ts Adds registration-time qualifier caching + per-label candidate indexing and preamble filtering helper.
lib/utils/coordinate_utils.ts Validates coordinate prefix chars earlier and reduces redundant Number(...) parsing.
lib/utils/coordinate_utils.test.ts Adds direct tests for CoordinateUtils parsing/formatting helpers.
lib/plugins/CBand.ts Hoists C-Band header regex and adds quick reject paths before delegation.
lib/plugins/ARINC_702.ts Hoists newline-stripping regex to module scope.
lib/plugins/ARINC_702.test.ts Adds tests for newline stripping, leading header peeling, and routing behavior.
lib/plugins/Label_83.ts Hoists regexes and removes redundant whitespace stripping in variant parsing.
lib/plugins/Label_80.ts Hoists regexes and reuses them during parsing to reduce per-message allocations.
lib/plugins/Label_4A.ts Replaces a regex prefix check with a direct first-character check.
lib/plugins/Label_B6.ts Preserves CPDLC body text after / and uses shared result helpers/debug logging.
lib/plugins/Label_B6.test.ts Adds tests asserting CPDLC body preservation and routing by preamble.
lib/plugins/Label_QP.test.ts Adds tests for OUT report decoding + MessageDecoder routing.
lib/plugins/Label_QR.test.ts Adds tests for ON report decoding + MessageDecoder routing.
lib/plugins/Label_QS.test.ts Adds tests for IN report decoding + MessageDecoder routing.
lib/plugins/Label_ColonComma.test.ts Adds tests for frequency change decoding + routing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/MessageDecoder.ts Outdated
Comment on lines +132 to +135
// Prepend the new wildcard to every existing label bucket so that
// wildcard plugins continue to be tried before label-specific ones.
for (const bucket of this.candidatesByLabel.values()) {
bucket.unshift(entry);
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When registering a new wildcard plugin after some label buckets already exist, bucket.unshift(entry) reverses wildcard precedence for those existing buckets (newly registered wildcard runs before earlier ones) and also makes wildcard order inconsistent across labels (buckets created later use wildcardEntries.slice() which preserves registration order). To preserve documented registration order, insert the new wildcard at the end of the existing wildcard section in each bucket (e.g., splice at the previous wildcard count) or rebuild each bucket from wildcardEntries + label-specific entries.

Suggested change
// Prepend the new wildcard to every existing label bucket so that
// wildcard plugins continue to be tried before label-specific ones.
for (const bucket of this.candidatesByLabel.values()) {
bucket.unshift(entry);
// Insert the new wildcard at the end of the wildcard section in
// every existing label bucket so wildcard plugins remain ahead of
// label-specific ones while preserving wildcard registration order.
const wildcardInsertIndex = this.wildcardEntries.length - 1;
for (const bucket of this.candidatesByLabel.values()) {
bucket.splice(wildcardInsertIndex, 0, entry);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — both reviewers flagged this. Fixed in 78e2526 using the suggested splice(wildcardEntries.length - 1, 0, entry) so the new wildcard lands at the end of the wildcard section in every existing bucket, preserving both "wildcards ahead of label-specific plugins" and "wildcards in registration order."

Added a regression test (wildcard registration order is preserved across existing label buckets) that registers a label-specific plugin followed by two wildcards; with the old unshift behavior the test fails (Expected: "wild-first", Received: "wild-second").


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: Confirmed and addressed in 78e2526 — your suggested splice(wildcardInsertIndex, 0, entry) was applied verbatim and a regression test was added. Resolved.

@kevinelliott kevinelliott requested a review from makrsmark April 17, 2026 03:02
@kevinelliott kevinelliott added the enhancement New feature or request label Apr 17, 2026
bucket.unshift(entry) inserted newly registered wildcard plugins at
position 0 of every existing label bucket, which reversed wildcard
precedence for those buckets: the most recently registered wildcard
would run first. It also made wildcard order inconsistent versus newly
created buckets (which seed from wildcardEntries.slice() in
registration order).

Use splice(wildcardEntries.length - 1, 0, entry) instead so the new
wildcard is inserted at the end of the wildcard section — preserving
both "wildcards ahead of label-specific plugins" and "wildcards in
registration order" invariants.

Adds a regression test that registers a label-specific plugin,
followed by two wildcard plugins, and asserts the first-registered
wildcard still wins for that label (previously it would lose to the
second).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/plugins/Label_80.ts (1)

109-162: ⚠️ Potential issue | 🟡 Minor

Wrap case bodies with declarations in blocks.

Biome flags const posResult (line 112) as an unbounded declaration leaking into other case clauses. The same pattern exists for const hhmm on line 160 (pre-existing). Since you're already touching this switch, wrapping both cases in braces is a cheap, targeted fix and matches the static analysis hint.

🔧 Proposed fix
     switch (tag) {
-      case 'POS':
+      case 'POS': {
         // don't use decodeStringCoordinates because of different position format
         const posResult = POS_REGEX.exec(val);
         const lat =
           Number(posResult?.groups?.lat) *
           (posResult?.groups?.latd === 'S' ? -1 : 1);
         const lon =
           Number(posResult?.groups?.lng) *
           (posResult?.groups?.lngd === 'W' ? -1 : 1);
         const position = {
           latitude: Number.isInteger(lat) ? lat / 1000 : lat / 100,
           longitude: Number.isInteger(lon) ? lon / 1000 : lon / 100,
         };
         ResultFormatter.position(results, position);
         break;
+      }
       ...
-      case 'ETA':
+      case 'ETA': {
         const hhmm = val.split('.')[0].replace(':', '');
         ResultFormatter.eta(results, DateTimeUtils.convertHHMMSSToTod(hhmm));
         break;
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/plugins/Label_80.ts` around lines 109 - 162, The POS and ETA case bodies
declare block-scoped constants (posResult and hhmm) that currently leak across
switch cases; wrap the POS case and the ETA case in their own block braces ({
... }) so posResult and hhmm remain scoped to their respective cases, leaving
the logic that computes position (using POS_REGEX, Number.isInteger and
ResultFormatter.position) and the ETA handling (using
DateTimeUtils.convertHHMMSSToTod and ResultFormatter.eta) unchanged.
lib/MessageDecoder.ts (1)

115-158: ⚠️ Potential issue | 🟡 Minor

Registration ordering logic looks correct; one latent edge case.

The splice-at-wildcardInsertIndex trick (Line 137-140) correctly keeps wildcard entries ahead of label-specific ones in every pre-existing bucket while preserving registration order among wildcards — verified by tracing:

  • Register W1 → wildcardEntries=[W1]
  • Register L1('A') → bucket A=[W1, L1]
  • Register W2 → insertIdx=1, bucket A becomes [W1, W2, L1]
  • Register L2('B') → bucket B=[W1, W2, L2] ✓ (seeded via wildcardEntries.slice())

One sharp edge worth noting: if a plugin's qualifiers.labels ever contained both a specific label and '*' in that order (e.g. ['A', '*']), the entry would be appended to bucket A during the first iteration (since it's not yet in wildcardSet), and then inserted into bucket A again by the splice step during the '*' iteration — producing a duplicate. No current plugin in pluginClasses triggers this, but if that ever changes the same plugin would decode twice per matching message.

If you want to harden against this, you could check whether the entry is already present before splicing, or alternatively process '*' before specific labels in the loop.

🛡️ Optional hardening
         if (!this.wildcardSet.has(plugin)) {
           this.wildcardEntries.push(entry);
           this.wildcardSet.add(plugin);
-          const wildcardInsertIndex = this.wildcardEntries.length - 1;
-          for (const bucket of this.candidatesByLabel.values()) {
-            bucket.splice(wildcardInsertIndex, 0, entry);
-          }
+          const wildcardInsertIndex = this.wildcardEntries.length - 1;
+          for (const bucket of this.candidatesByLabel.values()) {
+            if (!bucket.includes(entry)) {
+              bucket.splice(wildcardInsertIndex, 0, entry);
+            }
+          }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/MessageDecoder.ts` around lines 115 - 158, The registerPlugin method can
duplicate an entry if a plugin's qualifiers.labels contains both a specific
label and '*' (e.g., ['A','*']) because the code both pushes entry into bucket
for 'A' and later splices the same entry into that bucket from wildcardEntries;
update registerPlugin (references: registerPlugin, qualifiers.labels,
wildcardSet, wildcardEntries, candidatesByLabel, entry) to guard against
duplicates by checking whether the bucket already contains the entry before
splicing (or alternately iterate '*' first so wildcardEntries are inserted
before any label-specific pushes), ensuring each bucket only gets a single
reference to the entry.
🧹 Nitpick comments (2)
lib/utils/coordinate_utils.ts (1)

8-73: Optional: deduplicate the parsing preamble between the two decoders.

Lines 12-24 and 45-58 now share identical prefix-extraction and validation logic (with the optional-space handling). Consider extracting a small private helper such as:

♻️ Suggested helper extraction
private static parseCoordParts(stringCoords: string):
  | { latChars: string; lonChars: string; firstChar: string; middleChar: string }
  | undefined {
  const firstChar = stringCoords.charAt(0);
  let middleChar = stringCoords.charAt(6);
  let lonChars = stringCoords.substring(7, 13);
  if (middleChar === ' ') {
    middleChar = stringCoords.charAt(7);
    lonChars = stringCoords.substring(8, 14);
  }
  if (
    (firstChar !== 'N' && firstChar !== 'S') ||
    (middleChar !== 'W' && middleChar !== 'E')
  ) {
    return undefined;
  }
  return { latChars: stringCoords.substring(1, 6), lonChars, firstChar, middleChar };
}

Then both public methods can share the preamble and differ only in how they fold latRaw/lonRaw into degrees. Non-blocking — happy to defer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/utils/coordinate_utils.ts` around lines 8 - 73, Both decoders
(decodeStringCoordinates and decodeStringCoordinatesDecimalMinutes) duplicate
the same prefix parsing and validation logic; extract that shared preamble into
a private helper (e.g. parseCoordParts) that returns { latChars, lonChars,
firstChar, middleChar } or undefined on invalid input, then call this helper at
the start of both public methods and use its results to compute
latitude/longitude; keep the conversion logic inside each public method (decimal
degrees vs degrees+minutes) and return undefined when the helper returns
undefined.
lib/plugins/ARINC_702.test.ts (1)

30-38: Consider asserting the delegated H1 body was actually decoded.

This test only checks decoded: true and that the unparsed header remains in remaining.text. To prove the prefix was actually peeled before delegation (rather than, say, another rule matching the whole string), consider also asserting the decoded payload — e.g. result.formatted.description === 'Request for Position Report' or result.raw.checksum === 0x037b, matching the CR/LF test above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/plugins/ARINC_702.test.ts` around lines 30 - 38, The test "peels a
leading / header before delegating" currently only asserts result.decoded and
remaining.text; add an assertion that the delegated H1 body was actually decoded
by checking the decoded payload from plugin.decode — for example assert
result.formatted.description equals the expected string (e.g. 'Request for
Position Report') or assert result.raw.checksum equals 0x037b (matching the
CR/LF test), so the test validates the peel-and-delegate behavior in addition to
the header remaining.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/plugins/CBand.ts`:
- Around line 6-13: The comment above the CBAND_HEADER regex has off-by-one
ranges; update the text to accurately describe the 10-char structure to match
the pattern in CBAND_HEADER: state that chars 0-3 are the message number
([A-Z]\d{2}[A-Z]), chars 4-5 are the airline code ([A-Z0-9]{2}), and chars 6-9
are the flight number ([0-9]{4}); keep the CBAND_HEADER regex as-is and only
correct the explanatory comment near the CBAND_HEADER declaration.

---

Outside diff comments:
In `@lib/MessageDecoder.ts`:
- Around line 115-158: The registerPlugin method can duplicate an entry if a
plugin's qualifiers.labels contains both a specific label and '*' (e.g.,
['A','*']) because the code both pushes entry into bucket for 'A' and later
splices the same entry into that bucket from wildcardEntries; update
registerPlugin (references: registerPlugin, qualifiers.labels, wildcardSet,
wildcardEntries, candidatesByLabel, entry) to guard against duplicates by
checking whether the bucket already contains the entry before splicing (or
alternately iterate '*' first so wildcardEntries are inserted before any
label-specific pushes), ensuring each bucket only gets a single reference to the
entry.

In `@lib/plugins/Label_80.ts`:
- Around line 109-162: The POS and ETA case bodies declare block-scoped
constants (posResult and hhmm) that currently leak across switch cases; wrap the
POS case and the ETA case in their own block braces ({ ... }) so posResult and
hhmm remain scoped to their respective cases, leaving the logic that computes
position (using POS_REGEX, Number.isInteger and ResultFormatter.position) and
the ETA handling (using DateTimeUtils.convertHHMMSSToTod and
ResultFormatter.eta) unchanged.

---

Nitpick comments:
In `@lib/plugins/ARINC_702.test.ts`:
- Around line 30-38: The test "peels a leading / header before delegating"
currently only asserts result.decoded and remaining.text; add an assertion that
the delegated H1 body was actually decoded by checking the decoded payload from
plugin.decode — for example assert result.formatted.description equals the
expected string (e.g. 'Request for Position Report') or assert
result.raw.checksum equals 0x037b (matching the CR/LF test), so the test
validates the peel-and-delegate behavior in addition to the header remaining.

In `@lib/utils/coordinate_utils.ts`:
- Around line 8-73: Both decoders (decodeStringCoordinates and
decodeStringCoordinatesDecimalMinutes) duplicate the same prefix parsing and
validation logic; extract that shared preamble into a private helper (e.g.
parseCoordParts) that returns { latChars, lonChars, firstChar, middleChar } or
undefined on invalid input, then call this helper at the start of both public
methods and use its results to compute latitude/longitude; keep the conversion
logic inside each public method (decimal degrees vs degrees+minutes) and return
undefined when the helper returns undefined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b20a3f09-c6e8-41e0-bd93-b53b8c0226c5

📥 Commits

Reviewing files that changed from the base of the PR and between 10f9f69 and 78e2526.

📒 Files selected for processing (16)
  • lib/MessageDecoder.labelindex.test.ts
  • lib/MessageDecoder.ts
  • lib/plugins/ARINC_702.test.ts
  • lib/plugins/ARINC_702.ts
  • lib/plugins/CBand.ts
  • lib/plugins/Label_4A.ts
  • lib/plugins/Label_80.ts
  • lib/plugins/Label_83.ts
  • lib/plugins/Label_B6.test.ts
  • lib/plugins/Label_B6.ts
  • lib/plugins/Label_ColonComma.test.ts
  • lib/plugins/Label_QP.test.ts
  • lib/plugins/Label_QR.test.ts
  • lib/plugins/Label_QS.test.ts
  • lib/utils/coordinate_utils.test.ts
  • lib/utils/coordinate_utils.ts

Comment thread lib/plugins/CBand.ts
- Label_80: wrap the POS and ETA switch case bodies in blocks so their
  block-scoped const declarations (posResult, hhmm) can't leak across
  sibling case clauses — matches static-analysis guidance.
- CBand: fix off-by-one ranges in the header char-range comment above
  CBAND_HEADER (was 0-4/5-6/7-10, now 0-3/4-5/6-9 to line up with the
  regex).
- ARINC_702.test: strengthen the leading-"/" peel test so it asserts
  the delegated H1 body actually decoded (description + checksum),
  not just that the header remains.
Comment thread lib/utils/coordinate_utils.ts Outdated
Comment on lines +20 to +21
(firstChar === 'N' || firstChar === 'S') &&
(middleChar === 'W' || middleChar === 'E')
(firstChar !== 'N' && firstChar !== 'S') ||
(middleChar !== 'W' && middleChar !== 'E')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess this is fine

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is dubious for sure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — this one wasn't actually earning its keep. In the original, the validity check was already gating the only allocation, so inverting it to an early-return with (!N && !S) || (!W && !E) just added a double-negative without saving anything.

Reverted to the original positive-match structure in 45da708. The only retained change is charAt(0)/charAt(6)/charAt(7) in place of substring(i, i+1) (trivial, functionally identical).

The real optimization here — the second function (decodeStringCoordinatesDecimalMinutes) — is untouched: it was doing Number(stringCoords.substring(1, 6)) and Number(longitudeChars) twice before, now once each, and with the validity check upfront so we don't parse at all for malformed input.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: Addressed via revert in 45da708 — the cosmetic inverted early-return was rolled back to the original positive-match shape. The actual perf win (single Number() parse for lat/lon) is preserved. Resolved.

Comment thread lib/MessageDecoder.ts

/**
* Per-plugin metadata captured at registration time so that decode() can
* avoid re-invoking plugin.qualifiers() (and re-allocating its arrays) on
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will that even save anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair question — I benched it to check. Micro-benchmark on this laptop, 1.4M decodes across a mixed corpus (label-specific hits, wildcard wrappers, unknown labels), three runs each with warmup:

per-decode
baseline (main) ~8.09 µs
this PR ~7.44 µs

So ~8% faster. Variance is real (one run was within noise), but the trend held across runs. The wins come from avoiding these per-decode allocations on the hot path:

  • spread [...wildcards, ...labelPlugins] (new array)
  • new Set() for dedup
  • a dedup candidates array
  • .filter(...) (another array + closure)
  • plugin.qualifiers() on every candidate — returns a fresh {labels, preambles} each call

On the memory side: the pre-built candidatesByLabel Map holds ~30 entries (one per registered label) pointing at arrays of references to ~65 shared PluginEntry objects. Order of a few KB, one-time at construction — it's not per-decode. So it's not really "taking up memory" in a hot-path sense.

TL;DR: modest speedup, negligible memory overhead, and it removes per-call GC pressure (the spread+Set+filter churn was the loudest signal). Happy to pull it back if you'd prefer the simpler shape — the label index lookup alone (the 1.8.x change) already does most of the heavy lifting; this is an incremental win on top.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: Author replied with a 1.4M-decode microbenchmark showing ~8% per-decode improvement. The cache eliminates a plugin.qualifiers() call and array allocation on every message — change is real and worth keeping. Resolved.

Comment thread lib/MessageDecoder.ts
/** Plugins that match all labels (qualifier label '*'). */
private wildcardPlugins: DecoderPluginInterface[] = [];
/** Maps a label string to the candidate entries (wildcard + label-specific) in registration order. */
private candidatesByLabel: Map<string, PluginEntry[]> = new Map();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like we're just taking up memory. is it really faster?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question, replying once to avoid repetition — see my reply on the related thread for the benchmark numbers (~8% faster per decode with negligible memory overhead, since candidatesByLabel is built once at construction and holds shared references).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: candidatesByLabel is built once at construction (one shared PluginEntry per plugin, reused across buckets). Memory cost is O(plugins x labels-per-plugin) and effectively constant. Hot-path savings of ~8% from the benchmark above justify the bookkeeping. Resolved.

The inverted early-return shape I introduced in this function didn't
save anything vs. the original positive-match (the validity check was
already before any allocation), and the double-negative was harder to
read. Reviewers flagged it as dubious, so restore the original
positive-match structure; only keep the `charAt` swap over
`substring(i, i+1)`.

The real optimization — guarding against redundant Number() parsing
in decodeStringCoordinatesDecimalMinutes — is preserved.
Copy link
Copy Markdown
Contributor Author

@kevinelliott kevinelliott left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer Re-review

Verdict: Ready to merge

I re-evaluated this PR independently against the head commit 45da708. CI is green across all 10 checks (build 20.x/22.x/24.x/latest, CodeQL, grype, Analyze, CodeRabbit). All 11 inline review threads are resolved. Locally, the full test suite passes: 95 suites, 446 tests passing, 9 skipped.

Performance wins verified

I traced the hot path in MessageDecoder.decode():

  • plugin.qualifiers() and the array allocation it returns is no longer invoked per message; preambles are captured once at registration into PluginEntry.preambles.
  • candidatesByLabel.get(label) is now a single Map lookup; the previous code allocated a seen Set, ran a spread to build candidates, and re-filtered through qualifiers() on every call.
  • Module-scoped regex hoisting in CBand, ARINC_702, Label_80, and Label_83 is correct — the literals were inside decode() per-message before. CBAND_HEADER ran on every wildcard decode (i.e. every message), so this is a real per-call saving, not just micro-optimization.
  • CoordinateUtils.decodeStringCoordinatesDecimalMinutes correctly halves the Number(stringCoords.substring(1,6)) work for lat and the Number(longitudeChars) work for lon. Pre-PR each was parsed twice.
  • Label_4A charAt swap and Label_83 single-pass whitespace strip are minor but real wins.

The author's bench (~8%/decode on a 1.4M-message corpus) is consistent with what I'd predict from removing per-call qualifiers() calls and Set construction.

Correctness

  • Wildcard ordering: The original bucket.unshift(entry) in commit 1 was a real bug — it would have reversed wildcard precedence for late-registered wildcards on existing buckets, which is a behavior change versus master. Fixed in 78e2526 with splice(wildcardEntries.length - 1, 0, entry). New regression test (MessageDecoder.labelindex.test.ts) catches it; verified the test fails against the buggy form.
  • No decoder behavior changes for existing decoders other than Label_B6_Forwardslash, which now extracts the body after / instead of dropping it. This is an improvement (text was previously discarded silently) and is covered by new tests.
  • CBand semantics preserved: the early-return on text.length < 10 is sound because CBAND_HEADER requires exactly 10 anchored chars before any subsequent payload — the regex would have failed anyway.
  • coordinate_utils: the cosmetic inverted early-return was correctly reverted in 45da708 after reviewer pushback. The kept change (single Number() parse, charAt swap) is purely an optimization with no semantic effect.

Outstanding concerns

  • Non-blocking: CodeRabbit flagged a latent edge case in MessageDecoder.registerPlugin — if a plugin ever returned qualifiers.labels = ['A', '*'] (specific label before *), the entry would be added to bucket A first, then spliced again during the '*' iteration, causing double-execution. No current plugin does this and the wildcardSet guard prevents it for ['*', 'A'] order. Worth a follow-up issue but not a merge blocker.
  • Non-blocking: coordinate_utils.ts has duplicated prefix-parsing between the two decoders (CodeRabbit nitpick). Helper extraction is a clean follow-up.
  • Title cosmetic: PR title is the auto-generated branch name (claude/optimize-performance-decoders-ifxPy). Suggest renaming on merge to something like "perf: cache plugin qualifiers, hoist regex literals, add missing decoder tests" so the squash commit message is meaningful.

Test coverage

Strong improvement here. Net adds:

  • New suites: Label_QP, Label_QR, Label_QS, Label_ColonComma, Label_B6, ARINC_702, coordinate_utils, plus MessageDecoder.labelindex regression.
  • 446 passing (up from ~407) across 95 suites (up from 88).
  • coordinate_utils.ts is now at 100/100/100/100 coverage.
  • The Label_B6 behavior change is covered by new tests including the empty-body negative path.
  • The wildcard-ordering regression test is well-targeted: registers label-specific then two wildcards and asserts first-registered wildcard wins.

Status of the 11 inline comments

  • 6 reviewer threads, all resolved:
    • 3097480956 (Codex, wildcard order): addressed in 78e2526.
    • 3097485575 (Copilot, wildcard order): addressed in 78e2526.
    • 3097637187 (CodeRabbit, CBand comment): addressed in 0522b31.
    • 3099943857 (makrsmark, coord refactor): addressed via revert in 45da708.
    • 3099956770 (makrsmark, "will that save anything?"): answered with benchmark.
    • 3099963823 (makrsmark, "really faster?"): answered with benchmark.
  • 5 author replies: kevinelliott responses confirming fixes / answering questions.
  • Maintainer reply posted on each of the 6 reviewer threads.

LGTM to merge. Recommend renaming the PR title before squash.

@kevinelliott kevinelliott changed the title claude/optimize-performance-decoders-ifxPy perf: cache plugin qualifiers, hoist regex literals, add missing decoder tests Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants