<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DisNoire</title>
    <description>The latest articles on DEV Community by DisNoire (@disnoire).</description>
    <link>https://dev.to/disnoire</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069779%2Ff4c8e481-3d84-43d2-ad3d-88bdfa3550e8.jpg</url>
      <title>DEV Community: DisNoire</title>
      <link>https://dev.to/disnoire</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/disnoire"/>
    <language>en</language>
    <item>
      <title>Comparator contract violations in aircraft stand sorting</title>
      <dc:creator>DisNoire</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:34:11 +0000</pubDate>
      <link>https://dev.to/disnoire/comparator-contract-violations-in-aircraft-stand-sorting-hjc</link>
      <guid>https://dev.to/disnoire/comparator-contract-violations-in-aircraft-stand-sorting-hjc</guid>
      <description>&lt;p&gt;&lt;em&gt;Third post about the engineering inside an airport slot coordination platform. The first two — &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;lossless schedule repacking&lt;/a&gt; and &lt;a href="https://dev.to/disnoire/one-slot-too-many-claimants-conflicts-are-groups-not-pairs-3pf3"&gt;conflict detection with Union-Find&lt;/a&gt; — were about algorithms I designed. This one is about a bug I inherited, and about the most misunderstood exception in Java.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The incident
&lt;/h2&gt;

&lt;p&gt;While bringing an abandoned flight-operations service back to life, production threw this at me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.IllegalArgumentException: Comparison method violates its general contract!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not from some exotic subsystem — from a stream's &lt;code&gt;.sorted(comparator)&lt;/code&gt; in the endpoint that lists aircraft stand states ordered by &lt;strong&gt;parking-area code&lt;/strong&gt; (under the hood, &lt;code&gt;sorted&lt;/code&gt; buffers into an array and hands it to &lt;code&gt;Arrays.sort&lt;/code&gt; — TimSort). A request that sorted stands by code simply aborted. Sometimes. On some data. Not in tests, not on staging, and not reproducibly on demand — which is this exception's signature move, and the reason it's so widely misread as a JDK bug.&lt;/p&gt;

&lt;p&gt;It isn't one. It's the JDK catching &lt;em&gt;you&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the contract actually says
&lt;/h2&gt;

&lt;p&gt;Every Java developer can recite the &lt;code&gt;Comparator&lt;/code&gt; interface: negative, zero, positive. Far fewer can recite the three laws the JavaDoc actually demands:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Antisymmetry:&lt;/strong&gt; &lt;code&gt;sgn(compare(a, b)) == -sgn(compare(b, a))&lt;/code&gt; — if a beats b, then b must lose to a. Always. Both directions must agree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transitivity:&lt;/strong&gt; if a beats b and b beats c, a must beat c.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency:&lt;/strong&gt; if &lt;code&gt;compare(a, b) == 0&lt;/code&gt;, then a and b must agree in how they compare against everything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why TimSort notices, and why only sometimes
&lt;/h2&gt;

&lt;p&gt;Since Java 7, &lt;code&gt;Arrays.sort&lt;/code&gt; for objects is &lt;strong&gt;TimSort&lt;/strong&gt; — a merge sort that hunts for already-ordered runs and merges them with a galloping optimization. That machinery &lt;em&gt;relies&lt;/em&gt; on the total-order laws: when it gallops through a run, it trusts that what compared "less" stays "less" from every direction it checks.&lt;/p&gt;

&lt;p&gt;Hand it a comparator that lies, and one of two things happens. If the input happens not to expose the lie, the sort completes — perhaps correctly, perhaps subtly misordered. If the input &lt;em&gt;does&lt;/em&gt; expose it, TimSort's internal invariants collapse and it throws the contract exception rather than return garbage.&lt;/p&gt;

&lt;p&gt;Two consequences worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The exception is data-dependent.&lt;/strong&gt; That's why it passed every test and failed in production on Tuesdays. A broken comparator is a landmine; TimSort only reports the mine when someone steps on it. Absence of the exception proves nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exception is a gift.&lt;/strong&gt; Pre-Java-7 merge sort would have silently produced a wrong order. There's even an escape hatch — &lt;code&gt;-Djava.util.Arrays.useLegacyMergeSort=true&lt;/code&gt; — and reaching for it is the classic wrong fix: it doesn't repair the comparator, it just asks the JDK to stop telling you about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The actual bug
&lt;/h2&gt;

&lt;p&gt;Parking-area codes are mostly letter-plus-number — &lt;code&gt;B2&lt;/code&gt;, &lt;code&gt;B10&lt;/code&gt; — sorted "naturally", so &lt;code&gt;B2&lt;/code&gt; precedes &lt;code&gt;B10&lt;/code&gt;. But the population also contains &lt;strong&gt;letter-only codes&lt;/strong&gt; (&lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;) and &lt;strong&gt;empty strings&lt;/strong&gt; — states whose parking area is simply absent, collapsed to &lt;code&gt;""&lt;/code&gt;. The comparator I inherited split each code into a numeric part and a letter part, parsed the numbers, compared them — and handled the no-number cases in a branch chain that ended like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reached when a code has no numeric part&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numPart1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// fires regardless of what numPart2 is&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;strong&gt;both&lt;/strong&gt; codes lacked digits — &lt;code&gt;A&lt;/code&gt; vs &lt;code&gt;B&lt;/code&gt;, or anything vs &lt;code&gt;""&lt;/code&gt; — that branch fired in &lt;em&gt;both argument orders&lt;/em&gt;: &lt;code&gt;compare("A","B") == 1&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;compare("B","A") == 1&lt;/code&gt;. Textbook antisymmetry violation. And the letter-comparison written to handle exactly this case sat just below the branch, &lt;strong&gt;unreachable&lt;/strong&gt; — dead code guarding the live bug. My favourite detail: &lt;code&gt;compare("A","A") == 1&lt;/code&gt; too. Under this comparator, a letter-only stand wasn't even equal to &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvokyhe2w6xpyr18j37fa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvokyhe2w6xpyr18j37fa.png" alt="Sign matrices of the broken and fixed comparators — the broken one has a 3×3 block of +1s that refuses to mirror-negate, diagonal included" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The matrix view makes the lie visible: antisymmetry demands that the grid mirror-negate across a zero diagonal. The numeric codes (top-left) obey. The letter-only-and-empty corner is a solid block of &lt;code&gt;+1&lt;/code&gt; — nine cells of contradiction, three of them on the diagonal itself.&lt;/p&gt;

&lt;p&gt;So for most requests — most stands carrying ordinary numeric codes — the sort held. Let enough letter-only or parking-less states into one response, in positions where TimSort's merge compares them from both directions, and the request died. The bug had sat quietly in an inherited codebase until the data found it.&lt;/p&gt;

&lt;p&gt;There was a second, quieter landmine in the same method: &lt;code&gt;Integer.parseInt&lt;/code&gt; on an unvalidated digit run — one unexpectedly long code away from &lt;code&gt;NumberFormatException&lt;/code&gt;. Nothing in production had tripped it yet; it went into the same fix anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: compare digits without parsing them
&lt;/h2&gt;

&lt;p&gt;The repair restructured the branches so &lt;strong&gt;every case returns a consistent answer from both directions&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// after — every path total and symmetric (illustrative shape, not production source)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bothHaveNumbers&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;compareNumericallyThenByLetters&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aHasNumber&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// numeric codes sort first…&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bHasNumber&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// …seen from either side&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;lettersOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lettersOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// both letter-only: the once-dead branch, now alive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in the "numerically" part: ties between equal numbers break on the letter part, so &lt;code&gt;compare == 0&lt;/code&gt; only ever means &lt;em&gt;genuinely interchangeable&lt;/em&gt; — law three's cheap insurance. And the numeric comparison stopped parsing entirely: digit runs compare &lt;strong&gt;as strings&lt;/strong&gt; — longer significant run wins, equal lengths compare lexicographically — which is overflow-proof at any code length. Ordering for every previously-valid code is unchanged; only the lies are gone.&lt;/p&gt;

&lt;p&gt;Then the step that mattered as much as the fix: &lt;strong&gt;auditing the sibling comparators.&lt;/strong&gt; The same endpoint also sorts by arrival and departure flight time; both of those delegate to &lt;code&gt;OffsetDateTime.compareTo&lt;/code&gt; — a valid total order — which exonerated them for &lt;em&gt;this&lt;/em&gt; crash. But the audit still paid: &lt;strong&gt;both could NPE&lt;/strong&gt;, because a state can legitimately lack its arrival or departure flight, and every candidate time field can be null. Their fix was a null guard plus &lt;code&gt;Comparator.nullsLast(...)&lt;/code&gt; — with a note to the product owner that &lt;em&gt;whether missing times sort first or last is a product decision, not something a comparator should decide by accident.&lt;/em&gt; Defects cluster; code written in the same week shares the same blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it, not eyeballing it
&lt;/h2&gt;

&lt;p&gt;A fixed comparator that "looks right" is exactly as trustworthy as the previous one looked. The laws are properties, so the proof is &lt;strong&gt;property-based&lt;/strong&gt;: a sandbox harness mirroring the production logic, generating randomised datasets of stand codes — numeric, letter-only, empty, null-adjacent — and asserting the three laws directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;for every pair: &lt;code&gt;sgn(compare(a,b)) == -sgn(compare(b,a))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;for every triple: transitivity holds&lt;/li&gt;
&lt;li&gt;for every "equal" pair: both elements order identically against every third value&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, the harness &lt;em&gt;reproduced the exact production exception&lt;/em&gt; against the old comparator — no fix is proven until the bug is reproduced. The reproduction also measured the size of the landmine: across &lt;strong&gt;4,000 randomised datasets, the original comparator threw in 1,582 of them.&lt;/strong&gt; Two out of every five random datasets could kill the endpoint; production had been surviving on the luck of its data mix. Against the fixed comparator: &lt;strong&gt;zero law violations, zero sort exceptions, zero mis-ordered outputs across all 4,000&lt;/strong&gt;, plus targeted regressions for the original failing case, leading zeros, and numeric-vs-letter precedence. The harness stayed in the suite; it now guards every future edit to those comparators.&lt;/p&gt;

&lt;p&gt;This is the same philosophy the first two posts kept arriving at, wearing a different coat: the repacking pipeline has a set-equality guard, conflict detection asserts its example before drawing conclusions, and comparators get their laws fuzzed. &lt;strong&gt;Don't inspect correctness — check it, mechanically, every time.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd generalise
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Interfaces have laws, not just signatures.&lt;/strong&gt; &lt;code&gt;Comparator&lt;/code&gt;, &lt;code&gt;equals&lt;/code&gt;/&lt;code&gt;hashCode&lt;/code&gt;, &lt;code&gt;Comparable&lt;/code&gt; — the compiler checks the shape and never the laws, and the laws are load-bearing. The most dangerous code in a codebase is a five-line method everyone assumed was too simple to be wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data-dependent failures demand law-based tests.&lt;/strong&gt; Example-based tests encode the inputs you thought of; the production data will think of others. When correctness is a &lt;em&gt;property&lt;/em&gt;, test the property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you find one, check the others.&lt;/strong&gt; A broken comparator is rarely alone. The half-day spent auditing siblings bought more reliability than the fix itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And don't take the escape hatch.&lt;/strong&gt; &lt;code&gt;useLegacyMergeSort&lt;/code&gt; silences the messenger. The exception told the truth: the contract was violated. Fix the contract.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build airport slot coordination and airline schedule systems — IATA telegram processing, schedule algorithms, conflict detection — in Java. Currently relocating to Spain. Earlier in this series: &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;schedule repacking&lt;/a&gt; · &lt;a href="https://dev.to/disnoire/one-slot-too-many-claimants-conflicts-are-groups-not-pairs-3pf3"&gt;conflict detection&lt;/a&gt;. Find me on &lt;a href="https://www.linkedin.com/in/disnoire/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>algorithms</category>
      <category>datastructures</category>
      <category>backend</category>
    </item>
    <item>
      <title>Conflict detection in airline schedules</title>
      <dc:creator>DisNoire</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:12:19 +0000</pubDate>
      <link>https://dev.to/disnoire/one-slot-too-many-claimants-conflicts-are-groups-not-pairs-3pf3</link>
      <guid>https://dev.to/disnoire/one-slot-too-many-claimants-conflicts-are-groups-not-pairs-3pf3</guid>
      <description>&lt;p&gt;&lt;em&gt;Second post about the algorithms inside an airport slot coordination platform. The &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;first one&lt;/a&gt; was about repacking schedules into canonical form. This one is about the question a coordinator asks right after: which of these rows are fighting over the same slot?&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Part 3. &lt;a href="https://dev.to/disnoire/comparator-contract-violations-in-aircraft-stand-sorting-hjc"&gt;Comparator contract violations in aircraft stand sorting&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;An airport slot coordination system stores schedules as rows of &lt;em&gt;(validity period + weekday mask)&lt;/em&gt; — a compact representation whose care and feeding I covered &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;last time&lt;/a&gt;. A season schedule runs to hundreds of flights and, in our production system, 300,000+ rows. Rows arrive from several directions at once — coordination telegrams, draft edits, imports — and sometimes the schedule ends up with &lt;strong&gt;rows of the same flight whose operating dates collide&lt;/strong&gt;: the same flight identity holding competing claims for a date. That's a conflict — and note it's strictly a same-flight affair; in this model, two different flights cannot conflict with each other.&lt;/p&gt;

&lt;p&gt;A coordinator can't act on a schedule in that state. The system's job is to find every such situation across the whole collection and show it — fast enough to run interactively, on every check.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the obvious answer is wrong twice
&lt;/h2&gt;

&lt;p&gt;The obvious answer is to compare every row with every other — and I don't have to speculate about how that goes, because that's what the check did when I met it. On a 14,799-row collection, all-pairs is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14,799 × 14,798 / 2  ≈  109 million comparisons
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— each involving date-range expansion. Across the full 300,000-row production schedule, the same shape reaches &lt;strong&gt;~45 billion&lt;/strong&gt;. In practice the check ran for &lt;strong&gt;about 20 seconds&lt;/strong&gt;. I watched that spinner, did this arithmetic, and decided the algorithm didn't deserve its runtime.&lt;/p&gt;

&lt;p&gt;Twenty seconds is the first "wrong": quadratic cost on a collection that, as the previous article showed, is &lt;em&gt;inflated by representational fragmentation&lt;/em&gt; to begin with. Fragmentation and pairwise comparison compound each other — every unnecessary sliver multiplies against every other row.&lt;/p&gt;

&lt;p&gt;The second "wrong" is subtler and more interesting: &lt;strong&gt;pairs are the wrong output shape.&lt;/strong&gt; If row A collides with row B, and B collides with C, that is not two findings — it's &lt;em&gt;one conflict involving three rows&lt;/em&gt;, and the coordinator needs to see it whole to resolve it. Conflict is a transitive, relational property. A list of pairs pushes the grouping work onto the user's eyeballs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Conflicts are groups, not pairs.&lt;/strong&gt; What we're really computing is the connected components of a collision relation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Idea one: never compare strangers
&lt;/h2&gt;

&lt;p&gt;The definition above does half the work for us: conflicts are same-flight by construction, so &lt;strong&gt;rows belonging to different flights never need to be compared at all&lt;/strong&gt;. Before any date is expanded, partition the collection by flight identity — airline and flight number.&lt;/p&gt;

&lt;p&gt;One linear pass with a hash map, and the one huge n² shatters into thousands of tiny problems — most clusters holding a handful of rows. This is where the 45 billion goes to die: almost every pair the naive version compared was a pair that could never conflict.&lt;/p&gt;

&lt;p&gt;One wrinkle: a schedule row in a slot system describes a turnaround — an arrival leg and a departure leg, each with its own flight identity. So a single row participates in &lt;strong&gt;two&lt;/strong&gt; clusterings, once by its arrival identity and once by its departure identity. Hold that thought; it's where the output gets its precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idea two: groups are connected components
&lt;/h2&gt;

&lt;p&gt;Within each cluster, expand rows to their operating dates. Two rows collide when they share a date. And because we want groups, not pairs, we don't &lt;em&gt;record&lt;/em&gt; collisions — we &lt;strong&gt;merge&lt;/strong&gt; them, with a Union-Find — also known as a disjoint-set union, DSU — the textbook structure for "who's in the same group":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UnionFind&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;UnionFind&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
        &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;      &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// smaller tree under bigger&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="o"&gt;]++;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine used &lt;strong&gt;union by rank&lt;/strong&gt; — always hang the shallower tree under the deeper root, which bounds &lt;code&gt;find&lt;/code&gt; at O(log n). The textbook goes further: add path compression and the amortized cost drops to inverse-Ackermann, i.e. effectively constant. I didn't bother, and that's a deliberate point, not a confession: after the identity partition, each cluster holds a handful of rows, and log of a handful is nothing. &lt;strong&gt;The asymptotic profile of the union structure never showed up in a profiler — the clustering had already done the heavy lifting.&lt;/strong&gt; Optimizing the Union-Find further would have been polishing the doorknob on a door we'd already removed.&lt;/p&gt;

&lt;p&gt;The mechanics within a cluster: walk each row's dates, keep a map from date to the first row seen on that date; when a second row lands on an occupied date, &lt;code&gt;union&lt;/code&gt; the two rows. At the end, every disjoint set with more than one member &lt;em&gt;is&lt;/em&gt; one conflict group — the A–B–C chain falls out for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Row A:  01JUN–30JUN  1......      (Mondays, all June)
Row B:  15JUN–15JUL  1......      (Mondays, mid-June to mid-July)
Row C:  01JUL–31JUL  1......      (Mondays, all July)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg819q1xciby8ece6ldn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg819q1xciby8ece6ldn.png" alt="A and C never collide, but A–B and B–C do — union() chains all three into one group" width="800" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A and C never share a date. But A–B collide in late June, B–C collide in early July — so &lt;code&gt;union&lt;/code&gt; chains all three into one component. One conflict, three rows, exactly what the coordinator must see to fix it. A pairwise report would have shown two separate findings and left the user to discover they're one problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The output: not just "conflict", but which leg
&lt;/h2&gt;

&lt;p&gt;Because each row was clustered twice — by arrival identity and by departure identity — the result can say &lt;em&gt;which side of the turnaround&lt;/em&gt; is at fault. Each row gets marked &lt;strong&gt;arrival-conflicted, departure-conflicted, or both&lt;/strong&gt; (a row whose arrival leg collides in one cluster and whose departure leg collides in another carries both flags), plus a total conflict count for the collection. The UI highlights the exact leg, not just the row.&lt;/p&gt;

&lt;p&gt;That leg-level precision is the difference between an alarm and a diagnosis. "This row is in conflict" sends the coordinator hunting; "this row's &lt;em&gt;departure&lt;/em&gt; is double-claimed on 12 dates" tells them where to cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs now
&lt;/h2&gt;

&lt;p&gt;Two passes, each effectively linear: one hash-map partition over all rows, then per-cluster date walks with cheap unions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; ~20 seconds for the check across the production schedule — a spinner, a context switch, a thing you schedule around.&lt;br&gt;
&lt;strong&gt;After:&lt;/strong&gt; a 14,799-row collection checks in &lt;strong&gt;20 ms&lt;/strong&gt; — a keystroke.&lt;/p&gt;

&lt;p&gt;Fast enough that conflict detection stopped being a batch job you schedule and became a property of the schedule you simply always see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait — didn't the repacking article make this problem disappear?
&lt;/h2&gt;

&lt;p&gt;Fair question, if you read the &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;previous post&lt;/a&gt;: if schedules get canonicalized into overlap-free form, what's left to detect?&lt;/p&gt;

&lt;p&gt;The two algorithms treat different diseases. A repacked row carries exactly one operational payload — one departure time, one aircraft, one set of business fields — so repacking can only merge rows that &lt;strong&gt;agree on everything except how their dates are written&lt;/strong&gt;. It removes &lt;em&gt;redundant&lt;/em&gt; duplication: the same claim, encoded messily. But two rows of the same flight with &lt;strong&gt;different payloads&lt;/strong&gt; on colliding dates are not redundancy — they're &lt;strong&gt;contradiction&lt;/strong&gt;. The canonical example from our production data: the same flight number claiming &lt;strong&gt;two different airports&lt;/strong&gt; on the same date. A plane cannot land in two cities at once, and no algorithm should resolve that by silently picking a winner — deciding which claim is true is the coordinator's job. (Milder versions abound: same flight, same date, different departure times, different aircraft.) Repacking is forbidden to touch that pair, so it survives canonicalization — and surfacing it, whole and precisely blamed, is what conflict detection is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Tidy the redundancy automatically; hand the contradictions to a human. The boundary between those two is the most important line in the whole system. And it compounds with the repacking from article #1: canonical schedules keep n small; near-linear detection keeps the check cheap at any n. The two algorithms are one story — representation hygiene and cheap verification, feeding each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd generalize
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Name the output shape before choosing the algorithm.&lt;/strong&gt; The naive version wasn't slow because pairwise comparison is slow — it was slow because &lt;em&gt;pairs were never the answer&lt;/em&gt;. The moment the requirement was stated as "groups", the structure (connected components, Union-Find) was inevitable. Most O(n²) scans hiding in business systems are really badly-shaped questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition by identity before comparing by value.&lt;/strong&gt; The two-level shape — coarse hash partition on an identity key, fine relational pass within each bucket — turns quadratic problems near-linear whenever most pairs can be excluded by a cheap key. It's the same trick as a hash join, wearing domain clothes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Precision of blame is a feature.&lt;/strong&gt; Clustering each row twice (per leg) cost almost nothing and turned the output from "something's wrong here" into "cut this leg on these dates". The cheapest UX win in the whole system came from the algorithm's bookkeeping, not the UI.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build airport slot coordination and airline schedule systems — IATA telegram processing, schedule algorithms, conflict detection — in Java. Currently relocating to Spain. The previous post in this series: &lt;a href="https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec"&gt;lossless repacking of airline schedules&lt;/a&gt;. Find me on &lt;a href="https://www.linkedin.com/in/disnoire/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>algorithms</category>
      <category>datastructures</category>
      <category>backend</category>
    </item>
    <item>
      <title>Lossless repacking of airline schedules</title>
      <dc:creator>DisNoire</dc:creator>
      <pubDate>Thu, 27 Aug 2026 18:55:27 +0000</pubDate>
      <link>https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec</link>
      <guid>https://dev.to/disnoire/one-flight-a-thousand-ways-to-write-it-lossless-repacking-of-airline-schedules-35ec</guid>
      <description>&lt;p&gt;&lt;em&gt;How a compact schedule representation creates an equivalence-class problem, and how we repack fragmented schedules into minimal form — with a guarantee we never lose a single operating date.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Part 2. &lt;a href="https://dev.to/disnoire/one-slot-too-many-claimants-conflicts-are-groups-not-pairs-3pf3"&gt;Conflict detection in airline schedules&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Part 3. &lt;a href="https://dev.to/disnoire/comparator-contract-violations-in-aircraft-stand-sorting-hjc"&gt;Comparator contract violations in aircraft stand sorting&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The representation
&lt;/h2&gt;

&lt;p&gt;Airlines don't publish their schedules as lists of dates. A flight that operates every Monday, Wednesday and Friday all summer is one line, not ninety:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;XX123  KZN → MAD   01JUN–30SEP   1.3.5..
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last field is a weekday mask in the IATA convention — digits 1–7 for Monday through Sunday, dots for days the flight doesn't operate. &lt;code&gt;1.3.5..&lt;/code&gt; reads: Mondays, Wednesdays, Fridays.&lt;/p&gt;

&lt;p&gt;The whole industry runs on this. SSIM files, slot-coordination telegrams, airport capacity systems — everywhere a repeating flight appears, it appears as a &lt;strong&gt;(validity period, weekday mask)&lt;/strong&gt; pair. It's compact, humans can read it, and a season's schedule fits on a screen.&lt;/p&gt;

&lt;p&gt;I work on an airport slot coordination platform — the system through which airlines request arrival and departure slots and airports allocate them. Our schedule rows use exactly this representation. And it has a property that looks harmless and isn't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same set of operating dates has many valid representations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These three schedules are the same flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A:  01JUN–30JUN  1.3.5..   B:01JUN–15JUN  1.3.5..  C:01JUN–30JUN  1......
                             16JUN–30JUN  1.3.5..    01JUN–30JUN  ..3....
                                                     01JUN–30JUN  ....5..
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expand any of them to concrete dates and you get the identical set. A is one row. B is two. C is three. Operationally they are indistinguishable — and that's the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How schedules fragment
&lt;/h2&gt;

&lt;p&gt;Nobody writes version C on purpose. It emerges.&lt;/p&gt;

&lt;p&gt;A coordinator moves the Friday departure time for two weeks in July: the edit has to split the row, because the change applies to a sub-range of the period and one day of the mask. An airline cancels three specific dates: more splits. A season of ordinary edits later, a flight that is conceptually "Mon/Wed/Fri all summer" is stored as a dozen slivers — short periods, sparse masks, some of them describing a single date in period-and-mask costume.&lt;/p&gt;

&lt;p&gt;And this is not a per-flight issue — it compounds across the whole schedule. A season schedule is hundreds of flights, and &lt;strong&gt;ours runs to more than 300,000 rows before repacking&lt;/strong&gt;. The repacker works group by group — rows are keyed by flight number and leg, and each group's operating dates are an independent equivalence-class problem — but the payoff sums over the collection: at the ~40% reduction we measured on production schedules, that's &lt;strong&gt;over a hundred thousand rows of pure representational debris&lt;/strong&gt; removed, with not one operating date touched.&lt;/p&gt;

&lt;p&gt;Fragmentation isn't just untidy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Humans read these rows.&lt;/strong&gt; A slot coordinator scanning a fragmented schedule can't see the shape of the operation. Twelve slivers hide the pattern that one row states plainly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything downstream iterates over rows.&lt;/strong&gt; Conflict detection, telegram generation, schedule comparison — all of it scales with row count, and much of it is worse than linear. Our conflict detection was O(n²) before we replaced it — &lt;strong&gt;fragmentation was directly inflating the n.&lt;/strong&gt; (A story for another day)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparisons produce false differences.&lt;/strong&gt; Diffing an incoming schedule against a stored one when their periods don't share boundaries is its own hard problem — fragmentation multiplies it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we built a repacking operation: take a flight's rows, produce the minimal set of (period, mask) rows that generates &lt;em&gt;exactly&lt;/em&gt; the same dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is harder than "merge adjacent periods"
&lt;/h2&gt;

&lt;p&gt;The naive instinct is interval merging — the classic coalesce-overlapping-ranges exercise. It fails here immediately, because rows interact across &lt;strong&gt;two dimensions that don't compose independently&lt;/strong&gt;: the calendar axis and the weekday axis.&lt;/p&gt;

&lt;p&gt;Two rows with identical masks and adjacent periods can merge along the period axis. Two rows with an identical period and different masks can merge along the mask axis (union the masks). But most real pairs are neither: they overlap partially in period &lt;em&gt;and&lt;/em&gt; differ in mask, and merging them naively either invents dates that were never in the schedule or drops dates that were.&lt;/p&gt;

&lt;p&gt;There's a subtler trap, too. A period–mask pair can encode dates that &lt;em&gt;look&lt;/em&gt; different but &lt;em&gt;generate&lt;/em&gt; the same set. &lt;code&gt;01JUN–30JUN ....5..&lt;/code&gt; and &lt;code&gt;06JUN–27JUN ....5..&lt;/code&gt; are the same Fridays — the first period's edges are slack, because June 1st isn't a Friday. Any correct algorithm has to reason about the &lt;strong&gt;generated date set&lt;/strong&gt;, not about the period boundaries as written.&lt;/p&gt;

&lt;p&gt;Which points at the only safe definition of equivalence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two schedule representations are equal &lt;strong&gt;if they expand to the same set of operating dates.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else follows from taking that definition seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  The algorithm
&lt;/h2&gt;

&lt;p&gt;Repacking runs as a pipeline: expand, deduplicate, then rebuild in three stages, cheapest structure first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 0 — expand and deduplicate
&lt;/h3&gt;

&lt;p&gt;Expand every row to its concrete dates. Drop exact duplicates — rows whose entire date set is already covered by other rows contribute nothing and exist only as editing debris. What remains is the ground truth: one set of dates per &lt;strong&gt;flight number and leg&lt;/strong&gt; (departure / arrival / return) — the unit whose rows describe the same operational data, and the only scope within which rows are ever combined.&lt;/p&gt;

&lt;p&gt;From here on, the original rows are irrelevant. We rebuild from the dates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1 — contiguous runs at a 7-day stride, per weekday
&lt;/h3&gt;

&lt;p&gt;For each weekday independently, sort that weekday's dates and find maximal runs where consecutive dates are exactly 7 days apart. Each run becomes a candidate row: period = first to last date of the run, mask = that single weekday.&lt;/p&gt;

&lt;p&gt;This stage alone converts "every Friday from June to September, except the two cancelled ones" into three tight single-weekday rows instead of a scatter of fragments. Crucially, the periods that come out of this stage are &lt;strong&gt;taut&lt;/strong&gt; — they start and end on dates the flight actually operates, which eliminates the slack-boundary ambiguity by construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2 — the trivial merges first
&lt;/h3&gt;

&lt;p&gt;The cheap cross-weekday case: candidate runs whose periods coincide exactly merge immediately into one row with the union of their masks. The Monday, Wednesday and Friday runs for the same June–September stretch collapse into a single &lt;code&gt;1.3.5..&lt;/code&gt; row.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3 — greedy first-fit combination, tested for holes
&lt;/h3&gt;

&lt;p&gt;What's left is the awkward remainder: runs that &lt;em&gt;almost&lt;/em&gt; fit together. The final pass is greedy, first-fit: &lt;strong&gt;pick a column&lt;/strong&gt; — one weekday's run — &lt;strong&gt;and walk the other runs until you find a partner that merges without holes.&lt;/strong&gt; A proposed merge takes the union period (earliest start to latest end) and the union mask, expands the resulting row, and accepts it only if every date the merged (period, mask) &lt;em&gt;implies&lt;/em&gt; is a real operating date — no holes in the implied grid, no phantom dates beyond the truth. First valid partner wins; repeat until no pair merges. The scan tries weekday-adjacent masks first.&lt;/p&gt;

&lt;p&gt;The January flight in the examples below shows this pass earning its keep: the Monday run's concise period is &lt;code&gt;05JAN–09FEB&lt;/code&gt; and the Tuesday run's is &lt;code&gt;06JAN–03FEB&lt;/code&gt; — different boundaries, so no trivial merge — yet the union row &lt;code&gt;05JAN–09FEB 12.....&lt;/code&gt; implies exactly the real dates, and the merge stands.&lt;/p&gt;

&lt;h3&gt;
  
  
  The guard: every stage proves itself
&lt;/h3&gt;

&lt;p&gt;Here is the part of the design I'd defend hardest.&lt;/p&gt;

&lt;p&gt;After every stage, the pipeline expands its own output and compares the generated date set against the ground truth from Stage 0. &lt;strong&gt;Set equality, or the stage's result is rejected.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originalRows&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;expand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// this stage's optimisation is not lossless for this input —&lt;/span&gt;
    &lt;span class="c1"&gt;// reject it and continue with the previous representation&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when no lossless packing exists for some residue of dates — irregular cancellations often leave one — there's a &lt;strong&gt;per-date fallback&lt;/strong&gt;: those dates are emitted as single-date rows. Ugly, but &lt;em&gt;correct&lt;/em&gt;, and correctness is not negotiable here. A schedule row in a slot coordination system is an airline's permission to land. An optimization that silently drops one Friday in August is not a performance bug; it's a plane with nowhere to go.&lt;/p&gt;

&lt;p&gt;This guard is what let us ship aggressive optimization stages without fear. The greedy stage can be wrong about a merge — the guard catches it. A future maintainer can add a fourth stage with a subtle bug — the guard catches that too. The invariant is enforced &lt;em&gt;structurally&lt;/em&gt;, not by hoping every stage is individually perfect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal-ish, provably lossless
&lt;/h3&gt;

&lt;p&gt;Note what we did &lt;em&gt;not&lt;/em&gt; build: an optimal minimiser. Finding the guaranteed-smallest set of (period, mask) rows is a set-cover-shaped problem, and greedy-with-verification gets ~40% row reduction on production schedules at a complexity a team can maintain. We chose &lt;strong&gt;absolute correctness and good-enough minimality&lt;/strong&gt; over provable minimality — because the guard makes the first property certain, and nobody's operations depend on the second being perfect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;Two anonymized examples — two flights out of the hundreds a single repack pass walks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 1 — same row count, radically better rows.&lt;/strong&gt; Three stored rows for one flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;05JAN–26JAN  123...7
13JAN–02FEB  12...67
02FEB–09FEB  12...67
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmmhhmdwf6segb2262c7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmmhhmdwf6segb2262c7.png" alt="Three overlapping rows repacked into three canonical rows" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thirty date-markings, but only 23 distinct dates — &lt;strong&gt;seven dates are covered twice&lt;/strong&gt;, because the rows overlap in both period and mask (every Monday and Tuesday from 13JAN to 26JAN lives in two rows at once). The repack emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;05JAN–09FEB  12.....
07JAN–21JAN  ..3....
11JAN–08FEB  .....67
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still three rows — but zero overlap, each period concise against its first and last operating date, and the operational shape (&lt;em&gt;Mon/Tue core, Wednesdays for three weeks, weekends from mid-January&lt;/em&gt;) now readable at a glance. This is the case that taught us row count is the wrong success metric on its own: &lt;strong&gt;the real product is canonical, overlap-free form.&lt;/strong&gt; Double-covered dates are quiet poison for anything downstream that counts, diffs or allocates by date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 2 — a merge across misaligned boundaries, and the fallback.&lt;/strong&gt; A different flight, three stored rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;08DEC–22DEC  1......
13DEC–27DEC  .....6.
07DEC–07DEC  ......7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F97ns27rbeafo2bx5omdw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F97ns27rbeafo2bx5omdw.png" alt="Three rows merged to two across misaligned period boundaries" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Monday and Saturday runs have periods that share no boundary — 08–22 versus 13–27. This is exactly the shape that naive interval logic refuses to touch, and exactly where reasoning about generated dates instead of period edges pays off. Propose the merged row &lt;code&gt;08DEC–27DEC 1....6.&lt;/code&gt; and expand it: Mondays {8, 15, 22} — the 29th falls outside the period — and Saturdays {13, 20, 27}, with no phantom Saturday before the 13th because the first Saturday on or after the 8th &lt;em&gt;is&lt;/em&gt; the 13th. The union period introduces no spurious dates; the guard's set-equality check confirms it; the merge stands. &lt;strong&gt;Three rows become two&lt;/strong&gt; — not because the periods aligned, but because the dates did.&lt;/p&gt;

&lt;p&gt;The Sunday, fitting no weekly pattern, passes through untouched as &lt;code&gt;07DEC–07DEC ......7&lt;/code&gt; — a single-date row in period-and-mask costume. Inelegant, and exactly right: that Sunday is a real departure with a real slot, and no packing aesthetic is worth losing it. The fallback isn't an edge-case apology; it's the guard's promise made concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more design choice: repack as a diff, not a rewrite
&lt;/h2&gt;

&lt;p&gt;The pipeline's output is not "delete everything, insert the new rows." It's a &lt;strong&gt;create/delete diff applied onto an undo/redo change stack&lt;/strong&gt; — the same stack that holds a coordinator's manual edits.&lt;/p&gt;

&lt;p&gt;Two things fall out of this. Repacking becomes an &lt;em&gt;undoable action&lt;/em&gt; — a coordinator who dislikes the repacked shape presses Ctrl+Z, which matters more than you'd think for trust in an automated rewrite of their data. And the operation composes with concurrent editing instead of trampling it, because it goes through the same conflict-handling path as every human edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd generalize from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A compact representation is a contract with an equivalence class.&lt;/strong&gt; The moment you choose (period, mask) over a date list, you accept that many encodings mean the same thing — and you will eventually need canonicalization, comparison, and a definition of equality over the &lt;em&gt;meaning&lt;/em&gt;, not the encoding. Choose the representation anyway; just budget for the machinery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimize greedily, verify absolutely.&lt;/strong&gt; Where correctness is binary and the cost of silent loss is high, the winning structure is aggressive heuristics wrapped in a cheap total check. The heuristic can then be as clever or as sloppy as it likes — the guard converts "we believe each stage is right" into "the pipeline cannot emit a wrong answer, only a suboptimal one."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make automated rewrites undoable.&lt;/strong&gt; The cheapest way to get humans to trust an algorithm that rewrites their data is to let them un-do it with one key.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build airport slot coordination and airline schedule systems — IATA telegram processing (SCR/SAL/SMA/SHL/SIR/WCR), schedule algorithms, conflict detection — in Java. Currently relocating to Spain. Find me on &lt;a href="https://www.linkedin.com/in/disnoire/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>algorithms</category>
      <category>datastructures</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
