aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/044-specs
diff options
context:
space:
mode:
Diffstat (limited to 'research/experiments/044-specs')
-rw-r--r--research/experiments/044-specs/2026-10-19-rto.jsonl1
-rw-r--r--research/experiments/044-specs/rto.py40
-rw-r--r--research/experiments/044-specs/rto_check.py145
3 files changed, 186 insertions, 0 deletions
diff --git a/research/experiments/044-specs/2026-10-19-rto.jsonl b/research/experiments/044-specs/2026-10-19-rto.jsonl
new file mode 100644
index 0000000..71d73d7
--- /dev/null
+++ b/research/experiments/044-specs/2026-10-19-rto.jsonl
@@ -0,0 +1 @@
+{"scenarios": 20, "events": 241612, "timeouts": 156, "right": {"batches": 241612, "rounds": 241612, "all": 241612}, "paths_same": true, "costs": {"10": {"ticks": 218070, "timeouts": 78, "ticks_a_timeout": 2795.8, "late_mean_ms": 4.27, "late_most_ms": 9.875}, "100": {"ticks": 21968, "timeouts": 78, "ticks_a_timeout": 281.6, "late_mean_ms": 49.23, "late_most_ms": 99.0}}, "aup_lines": 56, "aup_brace_lines": 11, "python_lines": 30}
diff --git a/research/experiments/044-specs/rto.py b/research/experiments/044-specs/rto.py
new file mode 100644
index 0000000..e4a4a57
--- /dev/null
+++ b/research/experiments/044-specs/rto.py
@@ -0,0 +1,40 @@
+# RFC 6298's retransmission timer in plain Python: the reference 140 counts aup's lines against. A sender's segments
+# are sent and acknowledged (cumulatively) at given times; the round-trip time of each acknowledged segment that was
+# never sent again (Karn) updates SRTT, RTTVAR and RTO (2.2-2.5: 1 s at least, 60 s at most), and the timer runs as
+# 5.1-5.6 say: started when one is sent and none runs, stopped when all are acknowledged, restarted when new data is,
+# and on expiry the earliest is sent again and RTO doubles. The timer is seen at ticks of period P, as an app sees time.
+LOWEST, HIGHEST = 1000, 60000 # milliseconds; the clock's granularity G is the tick's period
+
+
+class Sender:
+ def __init__(self, period):
+ self.period, self.srtt, self.rttvar, self.rto, self.measured = period, 0.0, 0.0, 1000.0, False
+ self.deadline, self.outstanding, self.retransmits = -1.0, [], [] # outstanding: [seq, sent, again]
+
+ def send(self, now, seq):
+ self.outstanding.append([seq, now, False])
+ if self.deadline < 0:
+ self.deadline = now + self.rto # 5.1
+
+ def ack(self, now, seq):
+ acked = [s for s in self.outstanding if s[0] <= seq]
+ if not acked:
+ return
+ self.outstanding = [s for s in self.outstanding if s[0] > seq]
+ last = acked[-1]
+ if not last[2]: # Karn: no sample from a segment sent again
+ r = now - last[1]
+ if not self.measured:
+ self.srtt, self.rttvar, self.measured = r, r / 2, True # 2.2
+ else:
+ self.rttvar = 0.75 * self.rttvar + 0.25 * abs(self.srtt - r) # 2.3
+ self.srtt = 0.875 * self.srtt + 0.125 * r
+ self.rto = min(max(self.srtt + max(self.period, 4 * self.rttvar), LOWEST), HIGHEST) # 2.4, 2.5
+ self.deadline = now + self.rto if self.outstanding else -1.0 # 5.3, 5.2
+
+ def tick(self, now):
+ if self.deadline >= 0 and now >= self.deadline: # 5.4-5.6
+ self.outstanding[0][2] = True
+ self.retransmits.append((self.outstanding[0][0], now))
+ self.rto = min(self.rto * 2, HIGHEST)
+ self.deadline = now + self.rto
diff --git a/research/experiments/044-specs/rto_check.py b/research/experiments/044-specs/rto_check.py
new file mode 100644
index 0000000..be41ffb
--- /dev/null
+++ b/research/experiments/044-specs/rto_check.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Research 140: RFC 6298's retransmission timer written in aup (aup's examples/rto.aup), with periodic ticks.
+
+Each scenario (a seed and a tick period P) is made closed-loop by rto.py's model and a receiver: segments sent at random
+gaps, each arriving half a random round trip later unless its first sending is lost (one in five), the receiver
+acknowledging cumulatively all it holds without a gap, the acknowledgement arriving after the other half; a segment the
+timer sends again arrives. The events (sends, acknowledgements, ticks every P ms) are then given to the app
+through pure-run (time MS before each), on Pure's three paths: after every event its SRTT, RTTVAR, RTO, deadline, the
+segments outstanding and the retransmissions must be the model's, the paths the same. No known implementation is
+compared: the oracle is the model of the same rules. What the ticks cost is measured: ticks a timeout (a one-shot timer
+would be one event), and how late a timeout is seen (below P). Then counts lines of rto.aup and of rto.py's model. One
+JSON line."""
+import json, os, random, struct, subprocess, sys, tempfile
+
+here = os.path.dirname(os.path.abspath(__file__))
+pure = os.path.normpath(os.path.join(here, '../../..'))
+aup = os.environ.get('AUP', os.path.join(pure, '..', 'aup'))
+sys.path.insert(0, here)
+import rto # noqa: E402
+
+START = 1_000_000 # the first event's time (ms since 1970, as now is)
+
+
+def lines(path, stop=None):
+ code = braces = 0
+ for line in open(path):
+ if stop and line.startswith(stop): break
+ text = line.strip()
+ if not text or text.startswith('#'): continue
+ code += 1
+ braces += text == '}'
+ return code, braces
+
+
+def scenario(seed, period, segments=40):
+ """The events the app is given (time, kind, seq), the model's state after each, and the timeouts' lateness. The
+ network: a sending arrives half a random round trip later unless it is lost (a first sending, one in five), and the
+ receiver acknowledges at once, cumulatively, all it holds without a gap; the acknowledgement arrives after the other
+ half."""
+ chooser = random.Random(seed)
+ sender = rto.Sender(period)
+ pending, t = [], START + period # (time, order, kind, seq): arrivals, then acknowledgements, sends, then ticks
+ for seq in range(1, segments + 1):
+ t += chooser.choice([0, 0, 5, 40, 120, 300, 900])
+ pending.append((t, 2, 'transmit', seq))
+ held, through = set(), 0 # the receiver's segments, and the last one it holds every one up to
+
+ def sent(time, seq, first):
+ if not first or chooser.random() > 0.2:
+ half = chooser.randrange(20, 350)
+ pending.append((time + half, 0, 'arrive', seq * 1000 + half))
+
+ events, states, late = [], [], []
+ tick = START
+ while pending or sender.outstanding:
+ pending.sort()
+ if pending and pending[0][0] < tick + period: # the host ticks every P, the timer running or not
+ time, _, kind, seq = pending.pop(0)
+ else:
+ time, kind, seq = tick + period, 'tick', 0
+ tick = time
+ if kind == 'arrive': # the receiver's: not the app's
+ seq, half = divmod(seq, 1000)
+ held.add(seq)
+ while through + 1 in held:
+ through += 1
+ if through:
+ pending.append((time + half, 1, 'ack', through))
+ continue
+ if kind == 'transmit':
+ sender.send(time, seq)
+ sent(time, seq, True)
+ elif kind == 'ack':
+ sender.ack(time, seq)
+ else:
+ before, deadline = len(sender.retransmits), sender.deadline
+ sender.tick(time)
+ if len(sender.retransmits) > before:
+ late.append(time - deadline)
+ sent(time, sender.retransmits[-1][0], False)
+ events.append((time, kind, seq))
+ states.append((sender.srtt, sender.rttvar, sender.rto, sender.deadline, len(sender.outstanding), len(sender.retransmits)))
+ if len(events) > 200000:
+ raise RuntimeError('a scenario without end')
+ return events, states, late
+
+
+def run(module, events, env, work):
+ commands = os.path.join(work, 'rto.commands')
+ with open(commands, 'w') as f:
+ for time, kind, seq in events:
+ f.write(f'time {time}\n')
+ f.write('tick tick\n' if kind == 'tick' else f"message {kind} {struct.pack('<I', seq).hex()}\n")
+ got = []
+ with open(commands) as stdin:
+ process = subprocess.Popen([os.path.join(pure, 'build', 'pure-run'), module], stdin=stdin, stdout=subprocess.PIPE,
+ text=True, env=dict(os.environ, **env))
+ for k, line in enumerate(process.stdout):
+ if k % 2 == 0: continue # a time command's
+ state = json.loads(line)
+ s = state['states']
+ got.append((state['fault'], s['srtt'], s['rttvar'], s['rto'], s['deadline'], len(s['outstanding']),
+ len(s['retransmits'])))
+ process.wait()
+ return got
+
+
+if __name__ == '__main__':
+ report = {'scenarios': 0, 'events': 0, 'timeouts': 0, 'right': {}, 'paths_same': True}
+ costs = {}
+ seeds = int(sys.argv[1]) if len(sys.argv) > 1 else 10
+ with tempfile.TemporaryDirectory() as work:
+ source = open(os.path.join(aup, 'examples', 'rto.aup')).read()
+ for period in (10, 100):
+ module = os.path.join(work, f'rto-{period}.pmod') # its tick and its G the scenario's period
+ with open(os.path.join(work, f'rto-{period}.aup'), 'w') as f:
+ f.write(source.replace('every 10 ms', f'every {period} ms').replace('fn PERIOD = 10 ', f'fn PERIOD = {period} '))
+ subprocess.run([os.path.join(aup, 'build', 'aup'), 'build', os.path.join(work, f'rto-{period}.aup'), '-o', module], check=True)
+ ticks = timeouts = 0
+ lateness = []
+ for seed in range(seeds):
+ events, states, late = scenario(seed, period)
+ outputs = {}
+ for name, env in (('batches', {'PURE_JIT': '0'}), ('rounds', {'PURE_JIT': '0', 'PURE_BATCH': '0'}),
+ ('all', {'PURE_JIT': 'all'})):
+ got = run(module, events, env, work)
+ want = [('none',) + s for s in states]
+ report['right'][name] = report['right'].get(name, 0) + sum(g == w for g, w in zip(got, want))
+ outputs[name] = got
+ report['paths_same'] = report['paths_same'] and outputs['rounds'] == outputs['batches'] == outputs['all']
+ report['scenarios'] += 1
+ report['events'] += len(events)
+ ticks += sum(kind == 'tick' for _, kind, _ in events)
+ timeouts += len(late)
+ lateness += late
+ report['timeouts'] += timeouts
+ costs[period] = {'ticks': ticks, 'timeouts': timeouts, 'ticks_a_timeout': round(ticks / max(timeouts, 1), 1),
+ 'late_mean_ms': round(sum(lateness) / max(len(lateness), 1), 2), 'late_most_ms': max(lateness, default=0)}
+ report['costs'] = costs
+ aup_code, aup_braces = lines(os.path.join(aup, 'examples', 'rto.aup'))
+ python_code, _ = lines(os.path.join(here, 'rto.py'))
+ report.update({'aup_lines': aup_code, 'aup_brace_lines': aup_braces, 'python_lines': python_code})
+ ok = all(n == report['events'] for n in report['right'].values()) and report['paths_same']
+ print(json.dumps(report))
+ sys.exit(0 if ok else 1)