Skip to content

fix(figc4.4 + figc4.5 + §4.4.2/§6.4.4): discard out-of-window duplicate I frames — X.25 2.4.6.4(a) guard, bound = negotiated k (#40) - #81

Merged
M0LTE merged 4 commits into
mainfrom
fix/figc44-figc45-out-of-window-discard
Sep 9, 2026
Merged

M0LTE merged 4 commits into
mainfrom
fix/figc44-figc45-out-of-window-discard

Conversation

@M0LTE

@M0LTE M0LTE commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #40. Figure + prose in one PR, following the #38/#65 (abd46b0) precedent: figc4.4 and figc4.5 as two independently revertable commits, prose in the v2.3 draft as a third.

What changed in each figure, and why

Both I-received out-of-sequence arms treated any N(s) ≠ V(r) as a fresh out-of-sequence frame. A late duplicate of an already-acknowledged frame was saved into the receive buffer (SREJ branch — the misdelivery vector) or answered with REJ; with an empty sender queue the SREJ names a frame that will never exist, an exception nothing can clear, re-sent on every poll per §4.4.4 (the 3m22s on-air capture against BPQ in #40, plus the fuzzer ping-pong livelock).

  • figc4.4 DataLink_Connected.graphml (commit 1): new decision diamond spliced between N(s) == V(r)?:No and Reject Exception?. Its No arm merges into the existing reject-exception discard arm (Discard Contents of I Frame → P == 1? → RR F=1 / nothing) — no new action boxes, exactly the packet.net formulation quoted in figc4.4/figc4.5 Connected I_received lacks the X.25 §2.4.6.4(a) out-of-window duplicate-discard guard #40. The out-of-sequence subtree (Reject Exception? through the own-busy discard arm) shifted one column right (+202.5) to make room; the Reject Exception?:Yes edge re-routed down-then-left into the discard box's right side.
  • figc4.5 DataLink_TimerRecovery.graphml (commit 2): identical splice; Reject Exception? moved one staircase step down-right (midway to SREJ Enabled), preserving the page's diagonal idiom. figc4.5 is where the on-air trigger actually fires — the peer's checkpoint retransmission (stale F=1 N(R)) is what delivers the already-acknowledged N(s).

Guard placement nuance (one step earlier than the issue comment's "before the srej_enabled split"): the diamond sits before Reject Exception?, not between Reject Exception?:No and SREJ Enabled?. Semantics are identical — the reject-exception:Yes arm already discards, and the guard's No arm routes to that same discard box — but this way all six out-of-sequence transitions route through the guard, and the drawing needs no crossing edges. The guard precedes Save Contents of I Frame on every path, closing the misdelivery vector.

The question text and what it mints

Drawn verbatim in both figures (matching the existing V(a) <= N(r) <= V(s)? chained-comparison idiom):

V(r) < N(s) < V(r) + k?

Walker-minted: id: i_vr_lt_ns_lt_vr_plus_k, predicate: vr_lt_ns_lt_vr_plus_k (verified by running Packet.Sdl.Transcribe; identical in both pages). Registered in spec-sdl/predicates.yaml under sequence_variables with a comment fixing the semantics: open interval per §4.4.2 / X.25 §2.4.6.4(b), modulo comparison, k = the window size this receiver granted — never a constant. direwolf's GENEROUS_K 63 collapses at modulo 8 (63 mod 8 = 7: the only value ever discarded is the frame exactly one behind V(r); two behind is saved as a future frame), and modulo 8 + SREJ is reachable (BPQ proposes exactly that; the #40 capture ran on it).

Walker output (house format)

  • DataLink_Connected.graphml → connected.sdl.yaml: 68 transitions / 39 decisions (was 66 / 38), zero branch: Undefined, zero next: Undefined (before and after).
  • DataLink_TimerRecovery.graphml → timer_recovery.sdl.yaml: 92 transitions / 47 decisions (was 90 / 46), zero branch: Undefined, zero next: Undefined (before and after).

Per figure: the six out-of-sequence transitions gain { decision: i_vr_lt_ns_lt_vr_plus_k, branch: Yes } and re-suffix (insert yes_ after ..._no_no_); two new guard:No transitions per figure (..._no_no_no_yes = discard + RR F=1, ..._no_no_no_no = discard only). Note the ids ..._no_no_no_{yes,no} are re-minted — the pre-change ..._no_no_no_no was the REJ arm; the citations files carry a CAUTION comment about this.

Citations: both .citations.yaml files re-keyed for the renames, header note added in the #55-correction style, and ERRATUM (#40) entries written for the four new transitions (§4.4.2 quotes; direwolf is_ns_in_window with the GENEROUS_K caveat; linbpq SDIFRM/CheckNSLoop as negative evidence — linbpq line numbers were verified at 560e9fc per #40, while the file's pinned commit is 88a6898; function anchors are the primary reference).

Prose amended (v2.3 draft only; v2.2.4 stays frozen, per the abd46b0 convention)

  • §4.4.2: new paragraph making the disposal explicit — out-of-window N(S) is a duplicate of an already-acknowledged frame: discard, no REJ/SREJ exception raised (no frame is missing, so no coherent N(R) exists), respond RR F=1 N(R)=V(R) only to P=1, whether or not SREJ is negotiated; points at the new figure decision. This closes the "disposes only of the information field, silent on exceptions" gap.
  • §6.4.4.1 (REJ): REJ now conditioned on N(S) being within the receive window; out-of-window → no REJ, no exception (cross-refs §4.4.2).
  • §6.4.4.2 (SREJ): retention conditioned on the window; new paragraph for the out-of-window case, keyed to the "no coherent N(R)" argument. This removes the literal reading that ordered an SREJ for any N(S) ≠ V(R).
  • "Changes from Version 2.2" table: row appended (the table asks for this; note abd46b0 did not add a row for figc4.5 (Timer Recovery): SREJ handler specifies go-back-N, contradicting §4.3.2.4/§6.4.8 and figc4.4 #38 — if the convention is actually "no table rows for errata", drop this hunk).

The three open editorial calls (from the #40 fix spec)

  1. Does the guard cover the REJ branch too? → Yes. One guard ahead of the whole out-of-sequence fan covers SREJ and REJ branches alike; §6.4.4.1 amended to match. Trades a spurious go-back-N burst for a T1 wait; every deployed REJ-mode stack recovers through poll/F-bit machinery regardless.
  2. P=1 out-of-window frame when genuine gaps exist → plain RR F=1 (§6.2 style), which is what the merged discard arm already draws. The direwolf-style SREJ fan (X.25 2.4.6.11) is not drawn and not mentioned in prose as a permitted alternative — flagging that as a residual editorial choice for review; adding a permissive note to §6.4.4.2 would be a one-sentence follow-up.
  3. k <= modulus/2 clamp → stays in Window wrap — k should always be less than half the modulus? #13. Not drawn, not put in prose. The predicates.yaml comment records that the bound is the receiver's granted k; the Window wrap — k should always be less than half the modulus? #13 companion invariant (window guard alone is porous at mod 8 when k ≥ 5) still needs its own erratum, and this PR does not pretend to cover it.

Verification

  • CI drift jobs simulated locally, both clean: Packet.Sdl.Transcribe (tooling pin ff5bc7d; the Transcribe walker is byte-identical between the pin and ax25sdl HEAD 4212c1e) regenerates exactly the committed *.sdl.yaml; tools/render/render_all.py regenerates exactly the committed SVGs.
  • Renders: before/after rendered and inspected for both figures (full page + splice crops). Mechanical segment-vs-node check of every edge polyline against every node box: zero new edge-through-node incidents in either figure (TimerRecovery's six pre-existing incidents are unchanged). The two committed SVG diffs are reviewable as GitHub rich image diffs.
  • ax25sdl (working tree only, nothing committed there): submodule pointed at this branch; Packet.Sdl.CodeGen runs clean through all pages and lints (the new predicate passes the catalog-completeness lint); dotnet test: conformance 270/270 pass; codegen tests show exactly the four expected movers — Walks_every_existing_state_page pins (Connected 66/38 → 68/39, TimerRecovery 90/46 → 92/47) and the two Yaml_round_trips_…_matches_committed_snapshot fixtures — plus 8 RustEmitter failures that are pre-existing on this machine (rustfmt not installed; identical failure set with the submodule at the old pin).
  • The .g.mmd files committed here were produced by the ax25sdl CodeGen mermaid emitter against this branch.

Follow-up work this implies (not in this PR)

  • ax25sdl regen PR (the 56bbccd shape): bump the submodule pin + .github/ax25sdl-tooling-ref consumers, update TranscribeTests.cs pins to (68, 39) and (92, 47), regenerate the Fixtures/transcribe/*.sdl.yaml snapshots and the seven language backends.
  • Runtime bindings for the new predicate: vr_lt_ns_lt_vr_plus_k has no legacy binding. packet.net Ax25SessionBindings.cs and ax25-ts session-bindings.ts must bind it (modulo comparison, receiver's granted k) before consuming the regenerated backends — the bindings lint silently skips when those files are absent from the codegen working dir, so this will not fail CI anywhere; it has to be tracked manually.

Not verified / caveats

  • The X.25 text itself was not read first-hand — 2.4.6.4(a)/(b) are cited as quoted in direwolf's comments and the existing connected.citations.yaml, per the same caveat in figc4.4/figc4.5 Connected I_received lacks the X.25 §2.4.6.4(a) out-of-window duplicate-discard guard #40.
  • Go/Rust canonical-formatting steps of CodeGen could not run locally (gofmt/rustfmt not installed); irrelevant to this PR (no backends committed) but noted for the follow-up regen PR.
  • A few pre-existing renderer quirks (edges whose yEd anchor sits ~0.007px outside the shape border collapse to a stub — e.g. the SReject Exception > 0?:No entry into N(s) > V(r) + 1? in figc4.4) exist in the committed renders and are deliberately left untouched so the SVG diffs stay scoped to the splice.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE

M0LTE and others added 3 commits September 9, 2026 16:23
…(a) guard (#40)

The Connected I-received out-of-sequence arm treated ANY N(s) != V(r)
as a fresh out-of-sequence frame: a late duplicate of an
already-acknowledged frame was saved into the receive buffer (SREJ) or
answered with REJ, and with an empty sender queue the SREJ names a
frame that will never exist — an exception nothing can clear, re-sent
on every poll per 4.4.4 (observed on air against BPQ 6.0.24.79,
2026-09-09; fuzzer-provable SREJ ping-pong livelock; see #40).

- DataLink_Connected.graphml: new decision "V(r) < N(s) < V(r) + k?"
  spliced between "N(s) == V(r)?":No and "Reject Exception?", so every
  out-of-sequence path (SREJ and REJ branches alike) passes the guard
  and it precedes "Save Contents of I Frame". The No arm merges into
  the existing reject-exception discard / "P == 1?" chain — discard,
  no exception, RR F=1 only to a poll; no new action boxes. The bound
  is the k this receiver granted, expressed symbolically — direwolf's
  GENEROUS_K 63 constant collapses at modulo 8. Out-of-sequence
  subtree shifted one column right to make room; no edge/node overlap
  regressions (checked mechanically against HEAD).
- connected.sdl.yaml regenerated by the Packet.Sdl.Transcribe walker:
  68 transitions / 39 decisions (was 66 / 38). New decision
  i_vr_lt_ns_lt_vr_plus_k, predicate vr_lt_ns_lt_vr_plus_k; six
  t26_i_received out-of-sequence ids re-suffixed, two new guard:No
  discard transitions.
- connected.citations.yaml re-keyed for the renames and annotated:
  ERRATUM (#40) entries for the two new transitions (4.4.2, direwolf
  is_ns_in_window, linbpq CheckNSLoop negative evidence).
- spec-sdl/predicates.yaml: vr_lt_ns_lt_vr_plus_k added to the
  sequence_variables catalog (new atom, no legacy binding — downstream
  runtimes must bind it).
- connected.g.mmd + DataLink_Connected.svg regenerated.

Adjudication and evidence: see PR body and #40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE
…s figc4.4 (#40)

TimerRecovery carries the identical I-received out-of-sequence decision
chain, and is where the on-air trigger actually fires: the peer's
checkpoint retransmission (driven by a stale F=1 N(R)) delivers the
already-acknowledged N(s) while this end is in Timer Recovery.

- DataLink_TimerRecovery.graphml: same splice as figc4.4 — new
  "V(r) < N(s) < V(r) + k?" decision between "N(s) == V(r)?":No and
  "Reject Exception?"; its No arm merges into the existing discard /
  "P == 1?" chain. "Reject Exception?" moved one staircase step
  down-right to make room; the figure's diagonal idiom is preserved
  and no edge/node overlap regressions (checked mechanically against
  HEAD).
- timer_recovery.sdl.yaml regenerated by the Packet.Sdl.Transcribe
  walker: 92 transitions / 47 decisions (was 90 / 46). Same
  i_vr_lt_ns_lt_vr_plus_k decision id and vr_lt_ns_lt_vr_plus_k
  predicate as figc4.4; six t22_i_received ids re-suffixed, two new
  guard:No discard transitions.
- timer_recovery.citations.yaml re-keyed and annotated (ERRATUM (#40)
  entries referencing the figc4.4 rationale).
- timer_recovery.g.mmd + DataLink_TimerRecovery.svg regenerated
  (DataLink_TimerRecovery.srej-received.svg viewport is untouched by
  this splice).

Kept separate from the figc4.4 commit so the two figures can be
reviewed and reverted independently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE
… the figures now draw (#40)

Section 4.4.2 disposed only of the information field ("otherwise, it is
discarded") and never said whether a sequence-error exception is
raised; 6.4.4.2 read literally ordered an SREJ for ANY N(S) != V(R),
with no window qualifier, while requiring the SREJ to carry "the value
N(R) of the missing frame" — for a duplicate behind V(R) no frame is
missing and there is no coherent N(R) to send. The prose was internally
inconsistent, and an executable SDL cannot leave "is an exception
raised?" implicit.

- 4.4.2: new paragraph — N(S) outside "greater-than V(r)" /
  "less-than V(r)+k" (k = the window granted by this receiver) is a
  duplicate of an already-acknowledged frame: discard, raise no
  REJ/SREJ exception, respond RR F=1 only to P=1, whether or not SREJ
  is negotiated. Points at the new figc4.4/figc4.5 decision.
- 6.4.4.1: REJ conditioned on N(S) being within the receive window;
  out-of-window duplicates raise nothing (the guard covers the REJ
  branch too — trading a spurious go-back-N burst for a T1 wait).
- 6.4.4.2: retention conditioned on the window; new paragraph for the
  out-of-window case.
- "Changes from Version 2.2" table: row appended.

Refs #40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE
@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

SVG figure changes (abd46b05 → 022289d6)

spec-sdl/v2.2-errata/data-link/svg/DataLink_Connected.svg

nodes 181→182, edges 209→211, labels 71→73

what change
+ node V(r) < N(s) < V(r) + k?
+ label No (by Discard Contents of I Frame)
+ label Yes (by V(r) < N(s) < V(r) + k?)
+ edge N(s) == V(r)? → V(r) < N(s) < V(r) + k?
+ edge V(r) < N(s) < V(r) + k? → Discard Contents of I Frame
+ edge V(r) < N(s) < V(r) + k? → Reject Exception?
− edge N(s) == V(r)? → Reject Exception?

spec-sdl/v2.2-errata/data-link/svg/DataLink_TimerRecovery.svg

nodes 240→241, edges 270→272, labels 112→114

what change
+ node V(r) < N(s) < V(r) + k?
+ label No (by Discard Contents of I Frame)
+ label Yes (by Reject Exception?)
+ edge N(s) == V(r)? → V(r) < N(s) < V(r) + k?
+ edge V(r) < N(s) < V(r) + k? → Discard Contents of I Frame
+ edge V(r) < N(s) < V(r) + k? → Reject Exception?
− edge N(s) == V(r)? → Reject Exception?

Interactive viewers — swipe, onion-skin, blink, and a clickable change list that zooms to each change — are attached to this run as the svg-figure-diff artifact: download, unzip, open the HTML.

Locally: python3 tools/svgdiff/svg_diff.py main HEAD --all --merge-base

M0LTE added a commit that referenced this pull request Sep 9, 2026
A text was treated as a node's caption when it fell inside the node's
bounding box less a small inset. On a diamond that box over-claims the
corners by nearly half the node's width — exactly where branch labels sit —
so a "Yes" beside a decision could be counted as that decision's caption.
Because the caption forms part of the node signature, one label drifting
across that boundary reported the node as removed and re-added, and dragged
every edge naming it into the diff as well.

Nodes now carry their outline, built from the shape the renderer drew:
polygon points as-is, rectangles as their corners, ellipses and circles
sampled. Ownership keeps the existing inset box as a cheap reject and then
confirms against the outline, so the test is strictly narrowing — it can
only reject a claim the box test used to make, never add one.

A path-shaped node still falls back to its box, since the numbers in `d`
cannot be paired into vertices without a real path parser. That leaves one
known false pair on DataLink_TimerRecovery.srej-received.svg, where a glyph
node drawn as a path claims a nearby "No". It predates this change.

Measured on a geometry-only change — #86, which moves every edge endpoint but
alters nothing structural, so every finding it produces is false: 28 findings
before, 13 after. On the worst affected figure, 14 before and 4 after. The
findings for #81 are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188JcqNg5JDQqdsBt7LrbyE

M0LTE commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Code review — 3 finding(s)

normal: doc/ax.25.2.3-draft.md:9-1170

Errata row and §4.4.2 body assert Figures C4.4 and C4.5 now contain a 'V(r) < N(s) < V(r) + k?' decision, but the embedded PNG figures are not updated in this PR — readers who follow the figure references find no such box.

nit: spec-sdl/predicates.yaml:68-70

X.25 sub-clause citation inconsistent with the rest of the change: predicates.yaml cites X.25 §2.4.6.4(b) for vr_lt_ns_lt_vr_plus_k, but the same commit cites X.25 §2.4.6.4(a) in three other places — the errata row in doc/ax.25.2.3-draft.md ("Matches X.25 §2.4.6.4(a)") and the identical direwolf is_ns_in_window note added to both connected.citations.yaml and timer_recovery.citations.yaml ("X.25 2.4.6.4 (a): N(S) outside the window — discard, respond only if P=1"). Only one sub-clause can name the out-of-window discard behaviour.

nit: spec-sdl/v2.2-errata/data-link/yaml/connected.citations.yaml:82-87

Legacy-id → t26 mapping table in the file header (lines 82-87) not updated for the #40 rename: it still lists the six old keys (e.g. t17_..._out_of_seq_srej_disabled → t26_i_received_yes_yes_yes_no_no_no_no, t12_..._reject_exception_p_eq_1 → t26_i_received_yes_yes_yes_no_no_yes_yes), but the diff renamed those keys under transitions: (_no_no_no_no → _no_no_yes_no_no, _no_no_yes_yes → _no_no_yes_yes_yes, etc.) and RE-MINTED t26_i_received_yes_yes_yes_no_no_no_{yes,no} for the two new out-of-window guard:No paths — a completely different semantic than the pre-#40 REJ arm the header still claims lives there.


Generated by Claude Code

…mapping table for the #40 re-suffix

Two of the three review findings (the third — regenerating the embedded
PNG figures in the prose docs — is deferred; PNG generation is being
re-implemented separately):

- spec-sdl/predicates.yaml: the vr_lt_ns_lt_vr_plus_k comment cited
  X.25 §2.4.6.4(b) where the rest of the change cites §2.4.6.4(a).
  Both are involved and the comment now says which is which: (b) is
  the window-membership test the diamond asks; (a) is the disposal of
  a frame that fails it (discard, respond only to P=1).
- connected.citations.yaml: the 2026-06-01 legacy-id → t26 header
  mapping still pointed the six out-of-sequence rows at their pre-#40
  ids, two of which are now re-minted for the new guard:No discard
  transitions. Rows updated to the current ids with a note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE
@M0LTE
M0LTE marked this pull request as ready for review September 9, 2026 23:11
@M0LTE
M0LTE merged commit 344aa97 into main Sep 9, 2026
3 checks passed
M0LTE added a commit to packet-net/ax25sdl that referenced this pull request Sep 10, 2026
…nerate backends (#81)

Mechanical pin-bump + regen PR, the follow-up packethacking/ax25spec#81
specified (same vehicle as #78 for the figc4.5 SREJ fix).

- ax25spec submodule: abd46b0 → 344aa97 (packethacking/ax25spec#81
  merged): figc4.4 + figc4.5 gain the X.25 2.4.6.4(a) out-of-window
  duplicate-discard guard — a new "V(r) < N(s) < V(r) + k?" decision
  on the I-received out-of-sequence arm, bound = the receiver's
  granted k. The intervening upstream commits are the svgdiff tooling
  (#82-#85) and doc/media only — no other data-link table changes.
- Regenerated connected + timer_recovery tables across all 7 backends
  (C#/Go/TS/JSON/Rust/C/Python). Closed-set churn: Ax25Guard gains
  vr_lt_ns_lt_vr_plus_k (Ax25Guard.g.cs / ax25-guard.g.ts /
  ax25_guard.g.rs). NOTE: the new guard atom has NO runtime binding
  yet — packet.net Ax25SessionBindings.cs and ax25-ts
  session-bindings.ts must bind it before consuming these backends
  (the bindings lint silently skips when those files are absent).
- TranscribeTests pins: Connected 66/38 → 68/39, TimerRecovery
  90/46 → 92/47; both transcribe snapshot fixtures refreshed.

Walker output: Connected 68 transitions / 39 decisions (was 66 / 38);
TimerRecovery 92 / 47 (was 90 / 46); zero branch: Undefined, zero
next: Undefined on both pages.

Verified in the compiled JSON: the six out-of-sequence paths carry
vr_lt_ns_lt_vr_plus_k=true ahead of the reject/SREJ decisions, and the
two new guard-false paths discard with no exception (RR F=1 only when
P=1). C# 270 conformance + 150 codegen tests green locally (gofmt /
rustfmt 1.9.0-stable / clang-format 22.1.5 on PATH).


Claude-Session: https://claude.ai/code/session_01EvTRUwL676xWw3WHu1mKPE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

figc4.4/figc4.5 Connected I_received lacks the X.25 §2.4.6.4(a) out-of-window duplicate-discard guard

1 participant