#!/usr/bin/env python3
"""Assembles content/research/one-rule-three-runtimes.json from measured results.
Every number in the prose is read from out/summary.json or out/mutants.json."""
import json, os, re, sys
R, OUTFILE = sys.argv[1], sys.argv[2]
S = json.load(open(os.path.join(R, "out/summary.json")))
M = json.load(open(os.path.join(R, "out/mutants.json")))
A = S["agreement"]; C = S["classes"]

def pct(key, nd=2):
    return f'{A[key]["rate"] * 100:.{nd}f}%'
def n(x):
    return f"{x:,}"
def cz(key): return len(C[key]["zones"])
def cs(key): return C[key]["scenarios"]
def cq(key): return C[key]["queries"]

Q = S["queries"]
TOT = Q["ALL"]
ALL3 = A["ALL|all3"]["agree"]
NAT = A["ALL|swift=kotlin"]["agree"]
MB, MBK, MBD = M["behaviourCount"], M["contractBehaviourKilled"], M["differentialBehaviourKilled"]
MR, MRK, MRD = M["refactorCount"], M["contractRefactorKilled"], M["differentialRefactorKilled"]
K = {
    "D1": "D1 sub-second anchor truncation", "D2": "D2 Today-override +24h across DST (app)",
    "D3": "D3 Kotlin tail boundary (overlap)", "D4a": "D4a Swift tail boundary (gap)",
    "D4b": "D4b Swift tail day count after a midnight transition", "D5": "D5 Swift timeline shows stale entry",
    "D6": "D6 Kotlin alarm shows stale render", "D7": "D7 Dart elapsed count vs its own published list (gap)",
    "Apia": "D4a Swift tail boundary (normal)", "Cd": "C currentPoolIndex != app (daily-time)", "Ci": "C currentPoolIndex != app (interval)",
}
TIMING = json.load(open(os.path.join(R, "out/cost.json")))

REFS = {
    "mckeeman1998": ("W. M. McKeeman. Differential testing for software. Digital Technical Journal 10(1), pp. 100–107, 1998.", None),
    "avizienis1985": ("A. Avizienis. The N-version approach to fault-tolerant software. IEEE Transactions on Software Engineering SE-11(12), pp. 1491–1501, 1985.", "https://doi.org/10.1109/TSE.1985.231893"),
    "knight1986": ("J. C. Knight and N. G. Leveson. An experimental evaluation of the assumption of independence in multiversion programming. IEEE Transactions on Software Engineering SE-12(1), pp. 96–109, 1986.", "https://doi.org/10.1109/TSE.1986.6312924"),
    "tiwari2025": ("S. Tiwari, S. Chen, A. Joukov, P. Vandervelde, A. Li and R. Padhye. It's about time: an empirical study of date and time bugs in open-source Python software. In 2025 IEEE/ACM 22nd International Conference on Mining Software Repositories (MSR), pp. 39–51, 2025.", "https://doi.org/10.1109/MSR66628.2025.00020"),
    "yang2011": ("X. Yang, Y. Chen, E. Eide and J. Regehr. Finding and understanding bugs in C compilers. In Proceedings of the 32nd ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI '11), pp. 283–294, 2011.", "https://doi.org/10.1145/1993316.1993532"),
    "fazzini2017": ("M. Fazzini and A. Orso. Automated cross-platform inconsistency detection for mobile apps. In 2017 32nd IEEE/ACM International Conference on Automated Software Engineering (ASE), pp. 308–318, 2017.", "https://doi.org/10.1109/ASE.2017.8115644"),
    "chen2018": ("T. Y. Chen, F.-C. Kuo, H. Liu, P.-L. Poon, D. Towey, T. H. Tse and Z. Q. Zhou. Metamorphic testing: a review of challenges and opportunities. ACM Computing Surveys 51(1), article 4, 2018.", "https://doi.org/10.1145/3143561"),
    "segura2016": ("S. Segura, G. Fraser, A. B. Sanchez and A. Ruiz-Cortés. A survey on metamorphic testing. IEEE Transactions on Software Engineering 42(9), pp. 805–824, 2016.", "https://doi.org/10.1109/TSE.2016.2532875"),
    "claessen2000": ("K. Claessen and J. Hughes. QuickCheck: a lightweight tool for random testing of Haskell programs. In Proceedings of the Fifth ACM SIGPLAN International Conference on Functional Programming (ICFP '00), pp. 268–279, 2000.", "https://doi.org/10.1145/351240.351266"),
    "lear2012": ("E. Lear and P. Eggert. Procedures for maintaining the time zone database. RFC 6557 (BCP 175), IETF, 2012.", "https://doi.org/10.17487/RFC6557"),
}

sections = []
def sec(id_, title, html):
    sections.append({"id": id_, "title": title, "html": " ".join(html.split())})

sec("introduction", "1 Introduction", f"""
<p>Adelante is a quotes app I built in Flutter. It has been on the App Store since 4 October 2026. Its home-screen widgets are not Flutter: on iOS they are WidgetKit extensions in Swift, and on Android they are app widgets in Kotlin. A widget has to change its quote on schedule while the app is closed, so the rule that decides which quote is showing at a given moment exists three times: once in Dart, once in Swift and once in Kotlin.</p>
<p>The three copies must agree. When the user opens the app, the Dart side works out which quote should be current and republishes the widget. If the native copy had reached a different answer, the widget visibly jumps to another quote. Nothing crashes, and no test turns red. It just looks wrong.</p>
<p>The project's guard for this was a set of 22 structural contract tests. Because the Dart test runner cannot compile Swift or Kotlin, those tests read the native source files as text and check that certain strings are present, such as <code>currentPoolIndex</code> or <code>payload.interval &gt; 0</code>. All 22 pass on the shipping code.</p>
<p>This report asks a direct question: do the three copies actually compute the same thing? I answer it with differential testing <a class="cite" href="#ref-mckeeman1998">[1]</a>. I lifted each implementation into a runnable harness, fed all three the same generated inputs, and compared their answers instant by instant. The idea is old. Running independently written versions side by side is the core of N-version programming <a class="cite" href="#ref-avizienis1985">[2]</a>, and Knight and Leveson showed that such versions fail together more often than independence would predict <a class="cite" href="#ref-knight1986">[3]</a>. Here the versions were not written independently. Each was written to mirror the others, which makes disagreement more surprising when it appears.</p>
<p>The answer is mostly yes, but not always. Across {n(TOT)} query instants in 29 IANA time zones, all three runtimes agreed on {pct('ALL|all3')} and the two native copies agreed with each other on {pct('ALL|swift=kotlin')}. The remaining disagreements trace back to eight distinct root causes. All eight are about calendar arithmetic: what "the same local time tomorrow" means when a clock change removes or repeats an hour. Date and time errors of this kind are a known, common class of bug <a class="cite" href="#ref-tiwari2025">[4]</a>. None of the eight made any of the 22 contract tests fail.</p>
<h3>Contributions</h3>
<ul>
<li>A cross-runtime differential harness for one shipping rule. It runs verbatim copies of the Dart, Swift and Kotlin functions against {n(len(json.load(open(os.path.join(R, 'out/scenarios.json')))['scenarios']))} generated widget publications in 29 time zones.</li>
<li>Eight reproduced divergences, each with its root cause, the conditions that trigger it and how long it lasts. Seven more appear only for payloads the current app never produces.</li>
<li>A comparison of oracles. The 22 string-matching tests caught {MBK} of {MB} behaviour-changing mutants and flagged {MRK} of {MR} harmless refactors. The differential test caught {MBD} of {MB} and flagged {MRD} of {MR}.</li>
<li>Practical guidance for logic shared across native targets: publish instants, not rules; test executable behaviour, not spelling.</li>
</ul>
""")

sec("background", "2 Background and related work", f"""
<h3>Differential testing and N-version programming</h3>
<p>McKeeman described differential testing as giving the same input to several comparable systems and treating any difference in output as a candidate bug <a class="cite" href="#ref-mckeeman1998">[1]</a>. Its strength is that it needs no specification of the correct answer. Its weakness is the mirror image: if every system makes the same mistake, the test is silent. Csmith is the best-known large-scale example. It generated random C programs and compared compilers, and it found hundreds of compiler bugs <a class="cite" href="#ref-yang2011">[5]</a>.</p>
<p>N-version programming uses the same comparison at run time to tolerate faults <a class="cite" href="#ref-avizienis1985">[2]</a>. Knight and Leveson's experiment with 27 versions found that independently developed programs failed together more often than chance would predict <a class="cite" href="#ref-knight1986">[3]</a>. The lesson I take from it is that shared difficulty produces shared mistakes. Programmers find the same inputs hard.</p>
<p>Adelante's situation is the reverse of an N-version system. The three copies are deliberately dependent: each comment says it "mirrors" the Dart function. Differential testing still applies, but it can only show where the copies disagree. It cannot prove that the point they agree on is the one the user wanted.</p>
<h3>Metamorphic and property-based testing</h3>
<p>Metamorphic testing checks relations between the outputs for related inputs, for example "moving every instant by one rotation period advances the index by one" <a class="cite" href="#ref-chen2018">[6]</a> <a class="cite" href="#ref-segura2016">[7]</a>. Property-based testing, popularised by QuickCheck, generates many inputs and checks a stated property on each <a class="cite" href="#ref-claessen2000">[8]</a>. Both suit this problem. I chose plain differential testing because the three implementations already give an oracle for free. The vector generator borrows from property-based testing: it is seeded and deliberately biased towards edge cases.</p>
<h3>Time-zone and date bugs</h3>
<p>The IANA time-zone database records each zone's rules and their history <a class="cite" href="#ref-lear2012">[9]</a>. Two facts from it drive this study. First, local clock times can be skipped (a "gap", when clocks go forward) or repeated (an "overlap", when they go back). Second, zones disagree on where those changes fall. Most happen at 01:00 to 03:00, some at midnight, and Lord Howe Island moves by 30 minutes. Each language's calendar library has to choose what a skipped or repeated time means. Tiwari et al. studied 151 date and time bugs in Python projects and found time-zone mistakes to be the largest contributing factor, often caused by misunderstood library defaults <a class="cite" href="#ref-tiwari2025">[4]</a>. My results are a cross-language version of the same pattern.</p>
<h3>Cross-platform consistency in mobile apps</h3>
<p>Fazzini and Orso's DiffDroid compares one Android app's behaviour across many platform versions and devices to find cross-platform inconsistencies <a class="cite" href="#ref-fazzini2017">[10]</a>. My setting differs in two ways. The implementations are in different languages rather than one binary on different platforms, and the comparison is of a pure function rather than of user interfaces. The aim is the same: one app, one intended behaviour, several runtimes.</p>
""")

sec("rule", "3 The rotation rule and its three implementations", f"""
<p>A widget configuration has a pool of up to 96 quotes, chosen and ordered by the Dart app. The quote that is current at publication comes first. The user picks a cadence: manual, hourly, twice daily, daily or weekly. A daily cadence can also have a local clock time, such as 07:00. The rule answers one question: at instant <em>t</em>, which pool index is showing?</p>
<h3>What Dart publishes</h3>
<p>When the app publishes, <code>widget_payload.dart</code> writes a small record for the native side. For fixed intervals it writes an anchor epoch in whole seconds and the interval length in seconds. For a daily clock time it also writes the next 96 local change instants, computed in Dart with the device's time zone and converted to epoch seconds. The comment explains why: so the extensions "need no timezone database and never assume that a day lasts 24 hours". After those 96 instants, roughly three months, the native code has to extend the schedule itself. A flag, <code>initialQuoteOnly</code>, marks a quote the user chose by hand for Today. That quote holds only the first period and is not repeated when the pool wraps.</p>
<h3>Three answers in Dart, Swift and Kotlin</h3>
<p><strong>Dart, the app.</strong> When the app opens it does not call a single "index at time" function. It reconciles its saved queue in <code>providers.dart</code>. It counts elapsed changes with <code>elapsedDailyChanges</code> (daily clock time) or <code>elapsedWidgetRefreshPeriods</code> (fixed intervals) and shows <code>queue[elapsed % n]</code>. With a Today override, it handles the first period separately and then counts the rest from <code>nextRefreshAt + interval</code>.</p>
<p><strong>Dart, <code>currentPoolIndex</code>.</strong> A pure function in <code>widget_rotation.dart</code> computes <code>floor((t − anchor) / interval) mod n</code>. Both native files say they mirror it. In the shipped code it is called only from tests, never from <code>lib/</code>. I include it as a fourth answer because it is the documented rule.</p>
<p><strong>Swift.</strong> <code>AdelantePayload.scheduledQuote</code> counts the published instants up to <em>t</em> with <code>prefix {{ $0 &lt;= date }}</code>. Past the last one, <code>dailyStep</code> adds whole calendar days using <code>Calendar.current</code>, <code>startOfDay</code> and <code>date(bySettingHour:minute:second:of:)</code>. WidgetKit does not run this function continuously. <code>adelanteTimeline</code> precomputes up to 12 future entries, using <code>Calendar.nextDate(…, matchingPolicy: .nextTime)</code> once the published instants run out, and the system displays the latest entry whose date has passed.</p>
<p><strong>Kotlin.</strong> <code>scheduledIndex</code> counts the published instants with <code>count {{ it &lt;= now }}</code>. Past the last one it adds civil days and compares against <code>dailyBoundary</code>, which uses a lenient <code>java.util.Calendar</code> with the hour and minute fields set. An inexact <code>AlarmManager</code> window scheduled at <code>nextChange()</code> triggers each re-render.</p>
<p>Table 1 lists the concerns each copy has to get right. The three answers agree by construction only where the Dart-computed instants are used directly. Everywhere else, each language makes its own choices.</p>
<table><caption>Table 1. Where each implementation does its own calendar arithmetic.</caption>
<thead><tr><th>Concern</th><th>Dart app</th><th>Swift widget</th><th>Kotlin widget</th></tr></thead>
<tbody>
<tr><td>Fixed interval</td><td>milliseconds from <code>nextRefreshAt</code></td><td>seconds (Double) from anchor</td><td>whole seconds from anchor</td></tr>
<tr><td>Daily, first 96 changes</td><td>own day count</td><td>count of published instants</td><td>count of published instants</td></tr>
<tr><td>Daily, after 96 changes</td><td>own day count</td><td><code>startOfDay</code> + <code>bySettingHour</code></td><td>UTC civil day + lenient <code>Calendar</code></td></tr>
<tr><td>When the display updates</td><td>on open</td><td>12-entry timeline, <code>nextDate(.nextTime)</code></td><td>alarm at <code>nextChange()</code></td></tr>
<tr><td>Today override</td><td><code>next + Duration(days: 1)</code></td><td>skip slot 0 after first step</td><td>skip slot 0 after first step</td></tr>
</tbody></table>
""")

sec("method", "4 Method", f"""
<h3>4.1 Extraction</h3>
<p>I copied the relevant source files from Adelante's repository at commit <code>af1aea2</code>, the 1.0.0 (21) code, into a scratch directory. I recorded SHA-256 hashes and did not modify the repository. The rotation files are clean in that commit. Each harness contains the rotation functions copied character for character, and a script checks that every non-comment line of the copied Swift ranges appears in the harness. The changes I had to make are mechanical and listed in the artifact:</p>
<ul>
<li>SwiftUI and WidgetKit types are removed. <code>Timeline</code> becomes a plain struct with the same entries and refresh date.</li>
<li>Android's <code>SharedPreferences</code> is replaced by a small interface backed by the configuration-catalog JSON. It reproduces the override in <code>WidgetConfigurations.resolve</code>, which is how the shipping widget reads its data.</li>
<li>Two Kotlin functions lose their <code>private</code> modifier.</li>
</ul>
<p>The Dart answer for the app is the arithmetic extracted from <code>providers.dart</code>, not the provider itself, which depends on Flutter and Riverpod.</p>
<h3>4.2 Golden vectors</h3>
<p>A seeded Python generator (seed 20261102) proposes inputs only. It never computes an answer. It covers 29 zones chosen for awkward rules:</p>
<ul>
<li>zones with no daylight saving: UTC, Kuala Lumpur, Singapore, Kolkata, Kathmandu at +05:45, Kiritimati at +14;</li>
<li>conventional 02:00 changes: New York, Los Angeles, London, Berlin, Sydney, Auckland;</li>
<li>midnight changes: Santiago, Havana, Cairo, Beirut, Azores;</li>
<li>a 30-minute change at Lord Howe, a two-hour change at Troll, and the negative-DST encoding in Dublin;</li>
<li>historical rule changes in São Paulo, Tehran and Cairo, and Samoa's skipped 30 December 2011.</li>
</ul>
<p>For each zone the generator finds every UTC offset change from 2026 to 2030 by bisection. Around each change it builds five families of publications (Table 2):</p>
<ul>
<li><em>daily</em> publications made one to three days before a clock change;</li>
<li><em>daily-tail</em> publications made about 96 days before one, so that the published instants run out across it;</li>
<li><em>interval</em> publications with millisecond-precise publish times;</li>
<li><em>long</em> horizons of up to 13 years, past 19 January 2038;</li>
<li><em>leap</em>-day publications in 2028 and 2032.</li>
</ul>
<p>Daily clock times include 00:00, 00:01, 00:30, 00:59, 01:00, 01:30, 02:00, 02:30, 03:00, 09:00, 23:00, 23:30 and 23:59. Pool sizes are 1, 2, 3, 5, 7, 24, 95 and 96, and 35% of publications carry a Today override. Query instants sit at each local change minute (both readings of a repeated time), one millisecond either side, around each offset change, and at random offsets.</p>
<table><caption>Table 2. Vector families.</caption>
<thead><tr><th>Family</th><th>Publications</th><th>Query instants</th></tr></thead>
<tbody>
<tr><td>daily (inside the 96 published changes)</td><td>2,756</td><td>{n(Q['daily'])}</td></tr>
<tr><td>daily-tail (after the 96 changes)</td><td>2,756</td><td>{n(Q['daily-tail'])}</td></tr>
<tr><td>interval (hourly, 12 h, 24 h, weekly, manual)</td><td>1,740</td><td>{n(Q['interval'])}</td></tr>
<tr><td>long horizon</td><td>232</td><td>{n(Q['long'])}</td></tr>
<tr><td>leap day</td><td>174</td><td>{n(Q['leap'])}</td></tr>
<tr><td><strong>Total</strong></td><td><strong>7,658</strong></td><td><strong>{n(TOT)}</strong></td></tr>
</tbody></table>
<p>The queries are deliberately weighted towards boundaries. An agreement rate here is therefore a rate over hard cases, not over a typical day. A uniform sample of instants would show far higher agreement.</p>
<h3>4.3 Harnesses and pipeline</h3>
<p>Figure 1 shows the pipeline. For each zone, the Dart harness runs with <code>TZ</code> set to that zone. It reproduces the publication: <code>nextDailyChange</code> or <code>t0 + interval</code>, the anchor and the 96 instants. It then records two answers per query: the app's reconciliation and <code>currentPoolIndex</code>. The Swift and Kotlin harnesses run in the same zone, read Dart's published payloads and record two answers each. The first is the instantaneous rule at <em>t</em>. The second is the displayed quote: the latest WidgetKit timeline entry, or the latest alarm-driven render, at or before <em>t</em>. Every harness checks that its runtime reports the requested zone.</p>
<figure><img src="/images/research/one-rule-three-runtimes/fig1-pipeline.svg" alt="Pipeline diagram: a seeded generator produces 7,658 publications in 29 zones; a Dart harness publishes payloads and records two Dart answers; Swift and Kotlin harnesses read the same payloads; an oracle compares all answers."><figcaption>Figure 1. The differential pipeline. Each runtime runs in its own process with the same TZ.</figcaption></figure>
<p>The displayed answers rest on two idealisations. WidgetKit is assumed to request a new timeline exactly at its refresh date, and Android's ten-minute alarm window is assumed to fire at its start. Real devices can only be later, so these figures understate staleness.</p>
<h3>4.4 Oracle</h3>
<p>There is no independent ground truth. I take the Dart app's reconciliation as the reference, because it is what the user sees after opening the app and what the widget gets republished to. Any native answer that differs from it is a visible jump. For every disagreement, a set of mechanical rules assigns a root-cause class from the answer pattern, the publication type and the local calendar facts at the query. Anything the rules cannot explain stays labelled as unexplained. I then reproduced one example of each class with a small probe of the library call involved.</p>
<h3>4.5 Comparing with the string-matching tests</h3>
<p>I ran the real 22 contract tests with Flutter 3.44.1 on a scratch copy of the files they read. Then I built 14 single-line mutants of the native rotation code: 12 that change behaviour and 2 refactors that do not. Each mutant was applied identically to the contract-test copy and to the harness copy. A mutant counts as detected by the contract tests if any of the 22 fails. It counts as detected by the differential test if any answer changes in six zones (UTC, New York, London, Lord Howe, Santiago, Kuala Lumpur).</p>
<h3>4.6 Toolchains</h3>
<p>Dart 3.12.1 (Flutter 3.44.1); Swift 6.4 (swiftlang-6.4.0.34.1) with Xcode 27.1 on macOS 27.0.1, Apple M1 Pro, Foundation time-zone data 2026e; Kotlin 2.4.21 on OpenJDK 17.0.19, JVM tzdb 2026a; Python 3.14.2 for generation and analysis. Adelante's Android build uses the Kotlin Gradle plugin 2.3.20. Kotlin 2.4.21 was installed from Homebrew because no standalone compiler was present.</p>
""")

sec("results", "5 Results", f"""
<h3>5.1 Agreement</h3>
<p>Across all {n(TOT)} queries, the three runtimes gave the same pool index for {n(ALL3)} ({pct('ALL|all3')}). Swift and Kotlin agreed with each other on {n(NAT)} ({pct('ALL|swift=kotlin')}). Table 3 breaks this down by vector family. Leap days caused no disagreement at all, and neither did zones without daylight saving apart from the sub-second effect in Section 5.2.</p>
<table><caption>Table 3. Agreement rate by vector family (boundary-weighted queries).</caption>
<thead><tr><th>Family</th><th>All three</th><th>App = Swift</th><th>App = Kotlin</th><th>Swift = Kotlin</th><th><code>currentPoolIndex</code> = app</th></tr></thead>
<tbody>
<tr><td>interval</td><td>{pct('interval|all3')}</td><td>{pct('interval|app=swift')}</td><td>{pct('interval|app=kotlin')}</td><td>{pct('interval|swift=kotlin')}</td><td>{pct('interval|cpi=app')}</td></tr>
<tr><td>daily</td><td>{pct('daily|all3')}</td><td>{pct('daily|app=swift')}</td><td>{pct('daily|app=kotlin')}</td><td>{pct('daily|swift=kotlin')}</td><td>{pct('daily|cpi=app')}</td></tr>
<tr><td>daily-tail</td><td>{pct('daily-tail|all3')}</td><td>{pct('daily-tail|app=swift')}</td><td>{pct('daily-tail|app=kotlin')}</td><td>{pct('daily-tail|swift=kotlin')}</td><td>{pct('daily-tail|cpi=app')}</td></tr>
<tr><td>long</td><td>{pct('long|all3')}</td><td>{pct('long|app=swift')}</td><td>{pct('long|app=kotlin')}</td><td>{pct('long|swift=kotlin')}</td><td>{pct('long|cpi=app')}</td></tr>
<tr><td>leap</td><td>{pct('leap|all3')}</td><td>{pct('leap|app=swift')}</td><td>{pct('leap|app=kotlin')}</td><td>{pct('leap|swift=kotlin')}</td><td>{pct('leap|cpi=app')}</td></tr>
<tr><td><strong>All</strong></td><td><strong>{pct('ALL|all3')}</strong></td><td>{pct('ALL|app=swift')}</td><td>{pct('ALL|app=kotlin')}</td><td>{pct('ALL|swift=kotlin')}</td><td>{pct('ALL|cpi=app')}</td></tr>
</tbody></table>
<p>The displayed quote matched the instantaneous rule on {pct('ALL|swiftShown=swift', 3)} of queries for Swift and {pct('ALL|kotlinShown=kotlin', 3)} for Kotlin. The rules correctly ignore leap days, 32-bit epoch limits and offsets such as +05:45 and +12:45. What they do not handle consistently is local clock times that are skipped or repeated.</p>
<p>Every disagreement fell into a named class. The classification left nothing unexplained. Figure 2 summarises the eight classes reachable with payloads the current app publishes.</p>
<figure><img src="/images/research/one-rule-three-runtimes/fig2-divergences.svg" alt="Horizontal bars for eight divergences. D1 anchor truncation appeared in all 29 zones; D2 Today override, D3 Kotlin repeated hour and D6 Kotlin alarm in 19 to 21 zones; D4a Swift gap in 16 zones; D4b Swift midnight day count in 7 zones; D5 Swift timeline in 3; D7 Dart gap in 2. None made a contract test fail."><figcaption>Figure 2. The eight divergences reachable with current payloads, coloured by the runtime that deviates: Dart blue, Swift orange, Kotlin purple.</figcaption></figure>
<h3>5.2 The eight divergences</h3>
<p>Below, "tail" means a daily widget whose 96 published change instants have run out. That happens when the app has not been opened for about three months. It is plausible for people who use only the widget.</p>
<p><strong>D1. Anchor truncated to whole seconds (Dart publisher; all 29 zones, {n(cs(K['D1']))} publications).</strong> The app's next change is <code>selectedAt + interval</code>, kept to the millisecond. The payload writes the anchor as <code>millisecondsSinceEpoch ~/ 1000</code>. Both native copies therefore change the quote up to 999 ms before the app would. This accounts for every disagreement in the interval family ({n(cq(K['D1']))} queries, because the generator deliberately probes the last second before each boundary). It is harmless in practice, but it is a real off-by-truncation.</p>
<p><strong>D2. Today override adds 24 hours, not one day (Dart app; {cz(K['D2'])} zones, {cs(K['D2'])} publications).</strong> With a Today override, the app counts later periods from <code>nextRefreshAt.add(refreshMode.interval)</code>, which is <code>Duration(days: 1)</code>: exactly 24 hours. Across a clock change, that instant lands an hour away from the true next change, and <code>elapsedDailyChanges</code> miscounts. Example: New York, a change set for 03:00, override chosen on 10 March 2028. At 03:00 EDT on 12 March both widgets have moved to pool index 2. The app, if opened then, computes index 1 and republishes, so the widget steps back. Usually the window is one hour. When the shifted instant also crosses midnight, as for a 23:59 change in St John's on 11 March 2029 or a 00:01 change in Nuuk on 27 October 2029, the app's count stays one off for the rest of that publication. This happens only for widgets with a Today override, and only when a clock change falls within a day of the first scheduled change.</p> <p><strong>D3. A repeated minute resolved to the later hour (Kotlin tail; {cz(K['D3'])} zones, {cs(K['D3'])} publications).</strong> On a fall-back day, 01:30 happens twice. Dart and Foundation resolve <code>01:30</code> to the first occurrence (daylight time). A lenient <code>java.util.Calendar</code> with <code>HOUR_OF_DAY</code> and <code>MINUTE</code> set resolves it to the second (standard time). Kotlin's tail therefore changes the quote one hour late: in New York on 1 November 2026, from 01:30 EDT until 01:30 EST (Figure 3, top).</p>
<p><strong>D4a. A skipped minute resolved to the end of the gap (Swift tail; {cz(K['D4a'])} zones, {cs(K['D4a'])} publications).</strong> On a spring-forward day, 02:30 does not exist. Dart's <code>DateTime</code> and Java's lenient calendar both move it forward by the gap, to 03:30. Foundation's <code>date(bySettingHour: 2, minute: 30, …)</code> returns 03:00, the first instant after the gap. Swift's tail changes the quote 30 minutes early in Los Angeles on 14 March 2027 (Figure 3, bottom). The early window equals how far into the gap the change time falls; a change set exactly at the start of a gap agrees.</p>
<figure><img src="/images/research/one-rule-three-runtimes/fig3-dst-nights.svg" alt="Two schematic timelines. Fall-back night in New York with a 01:30 change: the Dart app and Swift switch at 01:30 daylight time, Kotlin switches an hour later at 01:30 standard time. Spring-forward night in Los Angeles with a 02:30 change: Dart and Kotlin switch at 03:30, Swift at 03:00."><figcaption>Figure 3. When each runtime switches to the next quote on two clock-change nights. Schematic, from measured examples (scenarios s00648 and s00997); not to scale.</figcaption></figure>
<p><strong>D4b. Day count after a midnight change (Swift tail; {cz(K['D4b'])} zones, {cs(K['D4b'])} publications).</strong> This is the most consequential divergence. In zones where clocks go forward at midnight, such as Santiago, Havana, Cairo, Beirut and the Azores, the day of the change begins at 01:00. If the last published instant falls on such a day, Swift computes <code>dateComponents([.day], from: startOfDay(last), to: startOfDay(date))</code>. That spans 01:00 to 00:00 the next day, 23 hours, which Foundation counts as zero days. Swift then stays one quote behind for every following day until the app is reopened. In scenario s01648 (Santiago, change at 00:59, last published instant 6 September 2026), the lag was still present at the last query, nine days later. Kotlin builds its civil-day number from year, month and day, and so does not lag.</p>
<p><strong>D5. Timeline entry dates and the step rule disagree (Swift display; {cz(K['D5'])} zones, {cs(K['D5'])} publications).</strong> Past the published instants, Swift's timeline places entries with <code>nextDate(…, matchingPolicy: .nextTime)</code>, but evaluates each entry with <code>dailyStep</code>, which uses <code>bySettingHour</code>. When a change time is skipped and the two calls resolve it to different instants, an entry can be evaluated before the step it was meant to show. The widget then displays the previous quote until the next entry. In Nuuk, where clocks jumped from 23:00 on 28 March 2026 straight to 00:00 on 29 March, the displayed quote stayed one behind the rule from 00:00 to 22:59 on 29 March.</p>
<p><strong>D6. The next alarm keeps a shifted wall-clock time (Kotlin display; {cz(K['D6'])} zones, {cs(K['D6'])} publications).</strong> After the gap day in D4a, Kotlin's <code>nextChange</code> takes the boundary it just used (03:30) and calls <code>Calendar.add(DATE, 1)</code>. That keeps 03:30, not the configured 02:30. The next re-render is an hour late on the following day, although the rule itself is right.</p>
<p><strong>D7. Dart's elapsed count disagrees with its own published list (Dart app; {cz(K['D7'])} zones, {cs(K['D7'])} publications).</strong> In Nuuk, a change set for 23:30 on the night clocks jump from 23:00 to 00:00 is moved by <code>DateTime</code> to 00:30 the next day. The published list therefore carries 00:30. <code>elapsedDailyChanges</code> counts by calendar date and already treats that day's change as passed at 00:00. For 30 minutes, the app is one quote ahead of both widgets and of the list it published itself.</p>
<p>One class falls outside the eight. In {cs(K['Apia'])} publications, Swift's day count also lagged across Samoa's skipped 30 December 2011. That is historical and no longer reachable.</p>
<h3>5.3 The documented rule is not the shipped rule</h3>
<p><code>currentPoolIndex</code>, the function both native files claim to mirror, agreed with the app on only {pct('ALL|cpi=app')} of queries. For intervals it disagrees exactly where D1 does, because it receives the truncated anchor. For daily clock times it uses a fixed 24-hour interval, so it drifts by an hour across every clock change ({n(cs(K['Cd']))} publications). It is not called anywhere in <code>lib/</code>. The native comments point to a function that no longer describes the app's behaviour. One of the contract tests checks that the Kotlin file contains its name.</p>
<h3>5.4 Latent divergences in payloads the app does not publish</h3>
<p>I also gave both native harnesses nine hand-built payloads that the current Dart publisher never produces, such as legacy or malformed data. Seven made Swift and Kotlin disagree (Table 4). Two legacy tier-filtering cases agreed at every width. None of these is a live bug. Each is a place where the two copies would split if the publisher changed.</p>
<table><caption>Table 4. Hand-built payloads (UTC). Disagreeing answers out of queries.</caption>
<thead><tr><th>Payload</th><th>Swift</th><th>Kotlin</th><th>Disagree</th></tr></thead>
<tbody>
<tr><td>Unsorted change instants</td><td><code>prefix</code> stops at first later instant</td><td><code>count</code> scans all</td><td>6/13</td></tr>
<tr><td>Anchor epoch 0</td><td>rotates from 1970</td><td>treats as unset, index 0</td><td>6/8</td></tr>
<tr><td>Fractional interval (90.5 s)</td><td>Double</td><td>truncated to 90 s</td><td>19/40</td></tr>
<tr><td>Fractional change instants</td><td>Double</td><td><code>getLong</code> truncates</td><td>12/20</td></tr>
<tr><td>IDs <code>café</code> (NFC) and <code>café</code> (NFD)</td><td>equal under canonical equivalence</td><td>different code units</td><td>2/6</td></tr>
<tr><td>Interval stored as a string</td><td>ignored, manual</td><td>parsed</td><td>4/6</td></tr>
<tr><td>Anchor keys missing</td><td>falls back to ISO <code>updated_at</code></td><td>index 0</td><td>4/6</td></tr>
</tbody></table>
<p>The Unicode case deserves a note. Swift's <code>String ==</code> uses canonical equivalence, so two IDs that differ only in normalisation compare equal. Swift's manual "Change quote" intent finds the current quote with <code>firstIndex</code>, so it would treat two such quotes as one. All 12,759 IDs in the bundled library are ASCII, so this cannot happen today. Quote length tiers are computed once in Dart and shipped as integers, which removes another possible Unicode divergence: UTF-16 length against grapheme count.</p>
<h3>5.5 String-matching contracts against the differential test</h3>
<p>All 22 contract tests passed on the shipping code that contains D1 to D7. Six of the 22 touch the pool or its rotation. None refers to <code>scheduleDates</code>, <code>dailyStep</code>, <code>scheduledIndex</code>, <code>dailyBoundary</code> or <code>nextChange</code>, the functions where the native divergences live. So the answer to "would they have caught it?" is no for every divergence in Section 5.2.</p>
<p>The mutant study makes the comparison concrete (Figure 4). The contract tests detected {MBK} of {MB} behaviour-changing mutants. They did flag {MRK} of {MR} behaviour-preserving refactors. Renaming <code>currentPoolIndex</code> broke one test, and rewriting <code>$0.tier &lt;= maxTier</code> as <code>maxTier &gt;= $0.tier</code> broke another, although nothing a user sees changed. The differential test detected {MBD} of {MB} behaviour-changing mutants and flagged {MRD} of {MR} refactors.</p>
<figure><img src="/images/research/one-rule-three-runtimes/fig4-mutants.svg" alt="Matrix of 14 mutants. The contract-test column is filled only for the two behaviour-preserving refactors; the differential column is filled for all twelve behaviour-changing mutants and neither refactor."><figcaption>Figure 4. Mutant detection by oracle. Filled circles are detections.</figcaption></figure>
<h3>5.6 Cost</h3>
<p>The full run took {TIMING['cpu']['dart']} s of CPU in Dart, {TIMING['cpu']['swift']} s in Swift and {TIMING['cpu']['kotlin']} s on the JVM, summed over 29 processes per runtime. That includes process start-up and, for the native harnesses, simulating every timeline and alarm chain. In total that is about {TIMING['msPerQuery']} ms of CPU per query instant for all six answers. Wall-clock time is not meaningful: the machine was shared, with load averages between 68 and 112 throughout. Reproducing the run in a CI job is cheap. A trimmed vector set of a few thousand queries in six zones would take seconds.</p>
""")

sec("discussion", "6 Discussion", f"""
<h3>Contracts that check spelling, and tests that check behaviour</h3>
<p>The 22 contract tests were a reasonable answer to a real constraint: the Dart test runner cannot execute Swift or Kotlin. They prevent particular regressions they were written for, such as a removed accessibility label or a reintroduced shadow layer. For layout they are probably the right tool.</p>
<p>For calendar logic they are the wrong tool. A string test can only confirm that a line is present. It cannot say what the line computes. That limit cuts both ways. All 12 behaviour-changing mutants passed. Both harmless refactors failed, because the tests pin exact spellings. One of them pins the name of a function the app no longer uses. A string test gives assurance about the code's shape, not its behaviour.</p>
<p>The differential test removes the constraint instead of working around it. Swift and Kotlin both compile and run on a development Mac. The rotation functions are pure apart from <code>Calendar.current</code> and <code>System.currentTimeMillis()</code>, so they can run outside the app. The work was in extraction, not in the testing idea.</p>
<h3>Why the three copies diverged</h3>
<p>The Dart publisher already did the right thing for the first 96 changes. It computed change instants in Dart and shipped them as absolute times. In that region the native copies only count, and they almost always agree. Every native divergence appears where a copy computes local time itself: past the list, or when choosing when to wake up. Each language's calendar library answers "02:30 on a day without 02:30" and "01:30 on a day with two" differently, and each answer is a documented, defensible library behaviour. None of these is a bug in Dart, Foundation or Java. The bug is assuming that their answers match.</p>
<p>The two Dart-side divergences have the same shape. D2 uses a 24-hour <code>Duration</code> where the rest of the code uses calendar days. D7 counts calendar dates where the list it published counts instants.</p>
<h3>Guidance for logic shared across native targets</h3>
<ol>
<li><strong>Publish instants, not rules.</strong> Let one runtime do all calendar arithmetic and ship absolute instants. Adelante does this for 96 changes. Extending the list, or having the native side ask for more instants instead of extrapolating, would remove D3 to D6.</li>
<li><strong>Use one notion of a day.</strong> Within one codebase, pick civil-calendar arithmetic or fixed durations and keep to it. D2 is the cost of mixing them.</li>
<li><strong>Make the native side count, not compute.</strong> Where extrapolation cannot be avoided, specify the gap and overlap policy explicitly (for example, "first occurrence; skipped times move forward by the gap") and implement that policy rather than the library default.</li>
<li><strong>Test the copies against each other, not against their text.</strong> Keep string contracts for layout. For logic, a small differential harness in CI pays for itself.</li>
<li><strong>Keep comments that claim a mirror honest.</strong> If the mirrored function is not on the production path, either route production through it or delete it.</li>
</ol>
<h3>For the app</h3>
<p>I did not change Adelante. For the owner, in priority order:</p>
<ul>
<li>D4b: a one-quote lag that lasts until the app is reopened, in midnight-change zones when the three-month schedule runs out on a change day;</li>
<li>D5: a stale display for most of a day; D6: an hour late;</li>
<li>D2: a visible step back for overridden widgets around clock changes;</li>
<li>D3, D4a and D7: windows of 30 to 60 minutes;</li>
<li>D1: sub-second, cosmetic.</li>
</ul>
<p>The smallest fix that removes most of them is to make the native tail count published instants only. The app can publish further ahead, and the widget can say "open Adelante to refresh" once the list runs out. That is better than computing local time in two more languages.</p>
""")

sec("threats", "7 Threats to validity", f"""
<p><strong>Harness fidelity (internal).</strong> The harnesses run copies, not the shipped binaries. Copied ranges are checked line by line, but the surrounding glue is mine. I could have changed how a value is decoded, for example how the catalog JSON reaches <code>doublePref</code>. I mirrored the production decoding paths (<code>JSONSerialization</code> on iOS, the catalog-backed <code>SharedPreferences</code> override on Android) and probed each divergence's library call on its own. The Dart app answer is extracted arithmetic, not the Riverpod provider. The app also rebuilds its queue when the UTC offset changes. I reasoned from the source, but did not execute, that this rebuild keeps the current quote.</p>
<p><strong>Runtime differences (construct).</strong> Kotlin ran on a desktop JVM, not on Android's runtime. Android's <code>java.util.Calendar</code> descends from the same API, but its implementation and time-zone data differ. D3 and D6 should be confirmed on a device or emulator. Foundation on macOS is the same framework family as on iOS, but not the same build. The JVM's tzdb (2026a) is older than the macOS data (2026e). No divergence I found depends on rules that changed between those versions, but I did not check every zone.</p>
<p><strong>Oracle choice.</strong> Treating the Dart app as the reference makes "divergence" mean "the widget will jump when the app opens". In D2 and D7 the two native copies are arguably closer to what the user intended than the app is. The classification reports who deviates, not who is right.</p>
<p><strong>Idealised display timing.</strong> Real WidgetKit reloads and Android alarms are later and less regular than I assumed. Displayed-quote staleness is a lower bound.</p>
<p><strong>Vector bias (external).</strong> Queries cluster at boundaries by design. Agreement rates describe hard cases. They are not the probability that a given user sees a wrong quote on a given day, which is far smaller.</p>
""")

sec("limitations", "8 Limitations", f"""
<p>This is one rule in one app by one developer. The eight root causes are all calendar arithmetic, because that is what this rule does. Logic involving hashing, sorting or floating point would need different vectors. I did not test the Watch app, which uses a separate Swift-only reading deck with its own SplitMix64 generator, the lock-screen surface offset, or the per-period manual selection. No devices, simulators or emulators were used. The mutant set is small and hand-written, so the detection counts illustrate the difference between the oracles rather than measure it. The study did not fix anything. Whether each divergence is worth fixing is a product decision for the owner.</p>
""")

sec("conclusion", "9 Conclusion", f"""
<p>Three copies of one small rule, each written to mirror the others, agreed on {pct('ALL|all3')} of deliberately hard instants. They did not agree on the rest, and the reasons were consistent: whenever a copy computed local time itself, its language's calendar library made a different choice about skipped and repeated hours. The project's 22 string-matching contract tests passed throughout and would have passed for any of the 12 behaviour-changing mutants. A differential harness of a few hundred lines per language caught every one. For shared logic across native targets, compute instants in one place, and test what the copies compute, not how they are written.</p>
""")

sec("artifact", "Artifact", f"""
<p>The artifact at <a href="/research/one-rule-three-runtimes/artifact/">/research/one-rule-three-runtimes/artifact/</a> contains:</p>
<ul>
<li>the vector generator and the latent-payload generator;</li>
<li>the Dart, Swift and Kotlin harnesses, with the exact copied ranges and hashes of the source files;</li>
<li>the run script, analysis, mutant and figure scripts;</li>
<li>the generated golden vectors (<code>scenarios.json</code>);</li>
<li>the per-zone raw answers of all three runtimes;</li>
<li><code>summary.json</code> and <code>mutants.json</code>;</li>
<li>a README with commands and toolchain versions.</li>
</ul>
<p>Adelante's full source is not included. The copied functions are the only app code in the artifact.</p>
""")

# ---- citation numbering in order of first appearance ----
order = []
for s_ in sections:
    for rid in re.findall(r'href="#ref-([a-z0-9]+)"', s_["html"]):
        if rid not in order: order.append(rid)
num = {rid: i + 1 for i, rid in enumerate(order)}
for s_ in sections:
    s_["html"] = re.sub(r'<a class="cite" href="#ref-([a-z0-9]+)">\[\d+\]</a>', lambda m: f'<a class="cite" href="#ref-{m.group(1)}">[{num[m.group(1)]}]</a>', s_["html"])
refs = []
for rid in order:
    t, u = REFS[rid]
    refs.append({"id": rid, "text": t, **({"url": u} if u else {})})
assert set(order) == set(REFS), set(REFS) - set(order)

words = sum(len(re.sub(r"<[^>]+>", " ", s_["html"]).split()) for s_ in sections)
abstract = (f"Three copies of one daily-quote rotation rule in Adelante, written in Dart, Swift and Kotlin, agreed on {pct('ALL|all3')} of {n(TOT)} deliberately hard query instants, and the disagreements trace to eight distinct calendar-arithmetic divergences that none of the project's 22 string-matching contract tests detects. "
    "Adelante is a Flutter app on the App Store whose iOS and Android widgets reimplement the rotation natively so they can change quotes while the app is closed. "
    "I extracted each implementation into a runnable harness, generated 7,658 seeded widget publications in 29 IANA time zones, covering clock changes, midnight transitions, leap days and 13-year horizons, and compared the pool index each runtime shows at every query. "
    "All divergences arise where a copy computes local time itself: Java resolves a repeated hour to its later instance, Foundation resolves a skipped minute to the end of the gap and counts a 23-hour day as zero days, and the Dart app mixes 24-hour durations with calendar days. "
    f"In a mutant study the string-matching tests caught {MBK} of {MB} behaviour-changing mutants while the differential test caught {MBD} of {MB}. "
    "The report gives the root cause, trigger and duration of each divergence, and guidance for logic shared across native targets: publish instants, not rules, and test behaviour, not text.")

paper = {
    "slug": "one-rule-three-runtimes",
    "title": "One Rule, Three Runtimes: Differential Testing of a Daily-Content Rotation Across Dart, Swift and Kotlin",
    "short": "One Rule, Three Runtimes",
    "authors": [{"name": "Yousof Selim", "affiliation": "Sunway University (Lancaster University dual degree), Malaysia", "email": "yousofselim2@gmail.com"}],
    "date": "2026-11-02",
    "version": "v1.0",
    "status": "Technical report",
    "area": "Software engineering · Testing · Cross-platform mobile",
    "keywords": ["differential testing", "cross-platform consistency", "time zones", "daylight saving time", "Flutter", "WidgetKit", "Android app widgets", "mutation analysis", "contract tests"],
    "projects": ["adelante"],
    "keyResult": {"value": "0%", "label": "of real and seeded rotation bugs caught by string-matching tests"},
    "heroFigure": {"src": "/images/research/one-rule-three-runtimes/fig2-divergences.svg",
                   "alt": "Horizontal bars for the eight divergences between Dart, Swift and Kotlin, showing how many of 29 time zones each affected, from all 29 for second-truncation down to 2 for a Dart gap case; none made a contract test fail."},
    "pitch": "My app's quote rule runs in Dart, Swift and Kotlin. Testing them against each other found eight clock-change bugs the existing tests missed.",
    "findings": [
        f"All three runtimes agreed on {pct('ALL|all3')} of {n(TOT)} deliberately hard moments across 29 time zones.",
        "The disagreements trace to 8 distinct bugs, all about clock changes: skipped or repeated hours and midnight transitions.",
        "If a widget's three-month schedule runs out on a midnight clock-change day, as in Santiago or Cairo, the iPhone widget stays 1 quote behind until the app is reopened.",
        f"The 22 existing string-matching tests caught {MBK} of {MB} deliberately broken versions of the rule; running the copies caught {MBD} of {MB}.",
    ],
    "abstractParts": [
        {"label": "The problem", "text": "Adelante's widgets must change quotes while the app is closed, so the rule deciding which quote is showing exists three times: in Dart, Swift and Kotlin. The only guard was 22 tests that check the native source for certain strings."},
        {"label": "What I did", "text": f"I ran verbatim copies of all three implementations side by side on {n(TOT)} generated moments in 29 time zones, chosen to stress clock changes, midnight transitions, leap days and long horizons, and compared their answers."},
        {"label": "What I found", "text": f"They agreed on {pct('ALL|all3')}. The rest came from eight distinct calendar bugs, each a different library choice about skipped or repeated hours, and none made a string test fail. The string tests caught {MBK} of {MB} deliberately broken versions; running the code caught all {MBD}."},
        {"label": "Why it matters", "text": "Shared logic across native targets drifts exactly where each language computes local time itself. Computing instants in one place and testing behaviour instead of text is cheap and catches what string checks cannot."},
    ],
    "abstract": abstract,
    "contributions": [
        "A cross-runtime differential harness that runs verbatim Dart, Swift and Kotlin copies of one shipping rule against 7,658 seeded publications in 29 time zones.",
        "Eight reproduced divergences with root cause, trigger and duration, plus seven latent ones in payloads the app does not yet publish.",
        f"A mutant comparison: string-matching contracts caught {MBK} of {MB} behaviour changes and flagged {MRK} of {MR} harmless refactors; the differential test caught {MBD} of {MB} and flagged {MRD}.",
        "Practical guidance for logic shared across native targets: publish instants, keep one notion of a day, and test behaviour rather than text.",
    ],
    "limitationsLine": "One rule in one app; Kotlin ran on a desktop JVM rather than Android, and display timing is idealised.",
    "aiStatement": "Yousof Selim designed and built Adelante and directed this study; much of the implementation and parts of this report were produced with AI coding assistants (Claude, Codex) under his direction and review; experiments are reproducible from the artifact.",
    "artifact": {"label": "Harnesses, golden vectors, raw per-runtime results and scripts", "href": "/research/one-rule-three-runtimes/artifact/"},
    "sections": sections,
    "references": refs,
    "evidence": [],
}
paper["evidence"] = json.load(open(os.path.join(R, "evidence.json")))
json.dump(paper, open(OUTFILE, "w"), indent=1, ensure_ascii=False)
print("words in sections:", words, "| abstract words:", len(abstract.split()), "| refs:", len(refs))
