diff --git a/Kanban/Storage/Ranks.swift b/Kanban/Storage/Ranks.swift new file mode 100644 index 0000000..7a2d6a0 --- /dev/null +++ b/Kanban/Storage/Ranks.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Pure gapped-fractional-ordering math: append, insert, midpoint, and the +/// renumber target, plus the display-order tie-break rule shared by the +/// loader and the writer. No filesystem or model dependency — see +/// DESIGN/01-storage-format.md § Ordering, Deletion. +enum Ranks: Sendable { + + /// Gap between successive ranks on append/head-insert, and the multiple + /// used by `renumbered(count:)`. + private static let gap: Double = 1024 + + // MARK: - Append / insert + + /// Rank for a new item appended after all visible siblings. + /// An empty lane's first item lands at `1024` (the board convention). + static func append(toVisible orders: some Sequence) -> Double { + (orders.max() ?? 0) + gap + } + + /// Rank for a new item inserted before all visible siblings. + /// An empty lane's first item lands at `1024` (the board convention). + static func insertAtHead(ofVisible orders: some Sequence) -> Double { + guard let minOrder = orders.min() else { return gap } + return minOrder - gap + } + + /// Rank strictly between `a` and `b`, or `nil` if no `Double` is + /// representable between them — including when `a == b` (a duplicate + /// order, the tie case). Never returns a value ≤ min(a, b) or + /// ≥ max(a, b). + static func midpoint(between a: Double, and b: Double) -> Double? { + let lower = min(a, b) + let upper = max(a, b) + guard lower < upper else { return nil } + let mid = lower + (upper - lower) / 2 + guard mid > lower, mid < upper else { return nil } + return mid + } + + /// `count` fresh ranks, whole multiples of 1024 in ascending order + /// (1024, 2048, …) — the renumber target when midpoint precision is + /// exhausted. Deterministic by construction; the writer applies these, + /// in order, to the current visible siblings in display order. + static func renumbered(count: Int) -> [Double] { + guard count > 0 else { return [] } + return (1...count).map { Double($0) * gap } + } + + // MARK: - Display order + + /// Ascending display order: primary key `order`, ties broken by folder + /// name (lexicographic) for deterministic rendering. Shared by the + /// loader and the writer so both apply the same tie-break rule. + static func isOrderedForDisplay( + _ lhs: T, before rhs: T, + order: (T) -> Double, name: (T) -> String + ) -> Bool { + let lhsOrder = order(lhs) + let rhsOrder = order(rhs) + return lhsOrder != rhsOrder ? lhsOrder < rhsOrder : name(lhs) < name(rhs) + } + + /// Sorts siblings into display order — ascending `order`, ties broken by + /// folder name. + static func sortedForDisplay( + _ items: [T], + order: (T) -> Double, + name: (T) -> String + ) -> [T] { + items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) } + } + + // MARK: - Tombstone exclusion + + /// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are + /// inert to ordering — appends operate on visible siblings only. + static func append(toVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double { + append(toVisible: items.filter { !$0.isDeleted }.map { $0.order }) + } + + /// `insertAtHead(ofVisible:)`, ignoring tombstoned siblings. Tombstones + /// are inert to ordering — inserts operate on visible siblings only. + static func insertAtHead(ofVisible items: some Sequence<(order: Double, isDeleted: Bool)>) -> Double { + insertAtHead(ofVisible: items.filter { !$0.isDeleted }.map { $0.order }) + } +} diff --git a/KanbanTests/RanksTests.swift b/KanbanTests/RanksTests.swift new file mode 100644 index 0000000..544d4a1 --- /dev/null +++ b/KanbanTests/RanksTests.swift @@ -0,0 +1,158 @@ +import Testing +@testable import Kanban + +struct RanksTests { + + // MARK: - Append + + @Test func appendOnEmptyLaneReturnsBoardConvention() { + #expect(Ranks.append(toVisible: [Double]()) == 1024) + } + + @Test func appendReturnsMaxPlusGap() { + #expect(Ranks.append(toVisible: [1024, 2048, 512]) == 3072) + } + + // MARK: - Insert at head + + @Test func insertAtHeadOnEmptyLaneReturnsBoardConvention() { + #expect(Ranks.insertAtHead(ofVisible: [Double]()) == 1024) + } + + @Test func insertAtHeadReturnsMinMinusGap() { + #expect(Ranks.insertAtHead(ofVisible: [1024, 2048, 512]) == -512) + } + + // MARK: - Midpoint + + @Test func midpointBetweenDistinctValuesIsStrictlyBetween() throws { + let mid = try #require(Ranks.midpoint(between: 1024, and: 2048)) + #expect(mid == 1536) + #expect(mid > 1024 && mid < 2048) + } + + @Test func midpointIsOrderIndependent() { + let forward = Ranks.midpoint(between: 1024, and: 2048) + let reversed = Ranks.midpoint(between: 2048, and: 1024) + #expect(forward == reversed) + } + + @Test func midpointBetweenEqualValuesReturnsNil() { + #expect(Ranks.midpoint(between: 42, and: 42) == nil) + } + + @Test func midpointNeverEscapesTheOpenInterval() { + // Values close enough that a naive (a+b)/2 could round to one of the + // endpoints; the result (if any) must stay strictly inside. + let a = 1.0 + let b = 1.0.nextUp.nextUp.nextUp + if let mid = Ranks.midpoint(between: a, and: b) { + #expect(mid > a && mid < b) + } + } + + // MARK: - Renumbered + + @Test func renumberedProducesWholeMultiplesOf1024() { + #expect(Ranks.renumbered(count: 4) == [1024, 2048, 3072, 4096]) + } + + @Test func renumberedWithZeroCountIsEmpty() { + #expect(Ranks.renumbered(count: 0) == []) + } + + // MARK: - Display order / tie-break + + @Test func sortedForDisplayOrdersAscendingByRank() { + let items: [(order: Double, name: String)] = [ + (order: 3072, name: "c-card"), + (order: 1024, name: "a-card"), + (order: 2048, name: "b-card"), + ] + let sorted = Ranks.sortedForDisplay(items, order: { $0.order }, name: { $0.name }) + #expect(sorted.map { $0.name } == ["a-card", "b-card", "c-card"]) + } + + @Test func sortedForDisplayBreaksTiesByFolderName() { + let items: [(order: Double, name: String)] = [ + (order: 1024, name: "zzz-uuid"), + (order: 1024, name: "aaa-uuid"), + (order: 1024, name: "mmm-uuid"), + ] + let sorted = Ranks.sortedForDisplay(items, order: { $0.order }, name: { $0.name }) + #expect(sorted.map { $0.name } == ["aaa-uuid", "mmm-uuid", "zzz-uuid"]) + } + + @Test func isOrderedForDisplayMatchesSortedForDisplay() { + let a: (order: Double, name: String) = (order: 1024, name: "aaa") + let b: (order: Double, name: String) = (order: 1024, name: "bbb") + #expect(Ranks.isOrderedForDisplay(a, before: b, order: { $0.order }, name: { $0.name })) + #expect(!Ranks.isOrderedForDisplay(b, before: a, order: { $0.order }, name: { $0.name })) + } + + // MARK: - Tombstone exclusion + + @Test func appendIgnoresTombstonedSiblings() { + let items: [(order: Double, isDeleted: Bool)] = [ + (order: 1024, isDeleted: false), + (order: 9999, isDeleted: true), + (order: 2048, isDeleted: false), + ] + #expect(Ranks.append(toVisible: items) == 3072) + } + + @Test func insertAtHeadIgnoresTombstonedSiblings() { + let items: [(order: Double, isDeleted: Bool)] = [ + (order: 1024, isDeleted: false), + (order: -9999, isDeleted: true), + (order: 2048, isDeleted: false), + ] + #expect(Ranks.insertAtHead(ofVisible: items) == 0) + } + + @Test func appendWithAllSiblingsTombstonedReturnsBoardConvention() { + let items: [(order: Double, isDeleted: Bool)] = [ + (order: 1024, isDeleted: true), + (order: 2048, isDeleted: true), + ] + #expect(Ranks.append(toVisible: items) == 1024) + } + + @Test func insertAtHeadWithAllSiblingsTombstonedReturnsBoardConvention() { + let items: [(order: Double, isDeleted: Bool)] = [ + (order: 1024, isDeleted: true), + (order: 2048, isDeleted: true), + ] + #expect(Ranks.insertAtHead(ofVisible: items) == 1024) + } + + // MARK: - Precision exhaustion → renumber, deterministically + + @Test func precisionExhaustionThenRenumberIsDeterministic() { + func runScenario() -> (iterations: Int, renumbered: [Double]) { + let lower = 1024.0 + var upper = 2048.0 + var iterations = 0 + let iterationCap = 4000 + while iterations < iterationCap, let mid = Ranks.midpoint(between: lower, and: upper) { + upper = mid + iterations += 1 + } + return (iterations, Ranks.renumbered(count: 5)) + } + + let first = runScenario() + let second = runScenario() + + // Precision must actually have been exhausted, not just hit the cap. + #expect(first.iterations > 0) + #expect(first.iterations < 4000) + + // Same scenario, run twice, must produce bit-identical results. + #expect(first.iterations == second.iterations) + #expect(first.renumbered == second.renumbered) + + // Renumbering yields clean whole multiples of 1024. + #expect(first.renumbered == [1024, 2048, 3072, 4096, 5120]) + } +}