Implement Ranks — gapped fractional ordering
Pure ordering math: append at max+1024, head-insert at min−1024, midpoint insertion with precision-exhaustion detection (nil on ties and rounding onto an endpoint), renumbering to whole multiples of 1024, the shared display-order tie-break (order, then folder name), and tombstone-excluding overloads. 18 tests. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -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>) -> 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>) -> 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<T>(
|
||||||
|
_ 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<T>(
|
||||||
|
_ 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user