Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/// Manifests of closed sessions that are not safely finished on disk.
///
/// A closed session stays here while keyframe writes it reserved are still in flight, and while
/// its latest manifest has not been written. It leaves only when both are done, so a failed
/// manifest write is retried instead of dropping the keyframes that manifest lists.
public struct ClosedManifests<Session: Hashable & Sendable, Value: Sendable>: Sendable {
private struct Entry: Sendable {
var value: Value
var unsaved: Bool
var drained: Bool
}

private var entries: [Session: Entry] = [:]

public init() {}

/// Keeps a session that just closed if it is still waiting for writes or its last save
/// failed. A drained, saved session needs nothing more and is not kept.
public mutating func close(_ session: Session, _ value: Value, awaitingWrites: Bool, saved: Bool) {
guard awaitingWrites || !saved else { return }
entries[session] = Entry(value: value, unsaved: !saved, drained: !awaitingWrites)
}

/// Changes a kept manifest, for example to list a late keyframe, and marks it unsaved.
/// Returns false when the session is not kept.
@discardableResult
public mutating func update(_ session: Session, _ change: (inout Value) -> Void) -> Bool {
guard entries[session] != nil else { return false }
change(&entries[session]!.value)
entries[session]!.unsaved = true
return true
}

/// The session's last reserved write has reported back.
public mutating func markDrained(_ session: Session) {
entries[session]?.drained = true
}

/// Writes every unsaved manifest, then releases sessions that are drained and saved. A
/// failed write leaves its session kept and unsaved for the next flush. Returns the sessions
/// written and the failures, each in no particular order.
public mutating func flush(
_ write: (Value) throws -> Void
) -> (saved: [Session], failures: [(session: Session, error: any Error)]) {
var saved: [Session] = []
var failures: [(session: Session, error: any Error)] = []
for (session, entry) in entries where entry.unsaved {
do {
try write(entry.value)
entries[session]?.unsaved = false
saved.append(session)
} catch {
failures.append((session, error))
}
}
entries = entries.filter { !$0.value.drained || $0.value.unsaved }
return (saved, failures)
}

public func value(for session: Session) -> Value? {
entries[session]?.value
}

/// Whether the session's latest manifest is not on disk yet.
public func isUnsaved(_ session: Session) -> Bool {
entries[session]?.unsaved ?? false
}

/// Sessions still kept.
public var sessions: Set<Session> {
Set(entries.keys)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/// The evidence behind one session's results: each point's position, own warnings and the wall
/// that located it, each wall's contacts and checks, and each saved measurement's warnings.
///
/// `LabSession` records every point, wall, check and measurement here and reads qualification
/// back from here, so these rules are the ones the app runs:
///
/// - A check contact with a warning of its own can't confirm a wall (`WallCheck.Outcome`).
/// - Every check re-evaluates every saved measurement, so a wall that fails after a value was
/// saved turns that value into an abstention.
/// - Saved warnings only grow. A later passing check never clears a warning a measurement
/// already has, so an abstention stays one.
///
/// Ids are the session's own ("P3", "W1", "M2"). Adding something that refers to an id the
/// ledger has never seen is a bookkeeping bug in the caller and stops the app.
public struct MeasurementLedger: Sendable {
/// What a measurement runs to, by id.
public enum Target: Hashable, Sendable {
case point(String)
case wall(String)

public var id: String {
switch self {
case .point(let id), .wall(let id): id
}
}
}

private struct Point: Sendable {
let position: SIMD3<Double>
let flags: [MeasurementWarning]
/// Set only for a point located by intersecting this wall's plane.
let wall: String?
}

private struct WallEntry: Sendable {
let wall: Wall
let contacts: [String]
var checks: [WallCheck] = []
}

private struct Saved: Sendable {
let id: String
let from: String
let target: Target
let referenceWall: String?
let compared: MeasuredQuantity
var warnings: [MeasurementWarning]
}

private var points: [String: Point] = [:]
private var walls: [String: WallEntry] = [:]
/// In save order.
private var saved: [Saved] = []

public init() {}

// MARK: - Recording

/// `wall` names the wall whose plane located the point, so the point carries that wall's
/// status from then on.
public mutating func addPoint(_ id: String, at position: SIMD3<Double>, flags: [MeasurementWarning], onWall wall: String? = nil) {
precondition(points[id] == nil, "Point \(id) is already in the ledger")
if let wall {
precondition(walls[wall] != nil, "Point \(id) is on \(wall), which is not in the ledger")
}
points[id] = Point(position: position, flags: flags, wall: wall)
}

public mutating func addWall(_ id: String, _ wall: Wall, contacts: [String]) {
precondition(walls[id] == nil, "Wall \(id) is already in the ledger")
for contact in contacts {
precondition(points[contact] != nil, "Wall \(id) uses contact \(contact), which is not in the ledger")
}
walls[id] = WallEntry(wall: wall, contacts: contacts)
}

/// Records a validation contact against a wall, then re-evaluates every saved measurement.
/// Returns the check's outcome and the ids of saved measurements that gained a warning, in
/// save order.
public mutating func addCheck(
_ validation: WallValidation,
contact pointID: String,
toWall wallID: String
) -> (outcome: WallCheck.Outcome, changedMeasurements: [String]) {
precondition(walls[wallID] != nil, "Check on \(wallID), which is not in the ledger")
let check = WallCheck(passes: validation.passes, contactWarnings: point(pointID).flags)
walls[wallID]?.checks.append(check)
var changed: [String] = []
for index in saved.indices {
let measurement = saved[index]
let current = currentWarnings(
from: measurement.from,
to: measurement.target,
referenceWall: measurement.referenceWall,
compared: measurement.compared
) ?? []
let merged = normalized(measurement.warnings + current)
if merged != measurement.warnings {
saved[index].warnings = merged
changed.append(measurement.id)
}
}
return (check.outcome, changed)
}

/// Saves a measurement and returns its warnings, or nil when the compared quantity doesn't
/// apply to this pair (for example along-wall distance without a reference wall).
public mutating func saveMeasurement(
_ id: String,
from pointID: String,
to target: Target,
referenceWall: String?,
compared: MeasuredQuantity
) -> [MeasurementWarning]? {
precondition(!saved.contains { $0.id == id }, "Measurement \(id) is already in the ledger")
guard let warnings = currentWarnings(from: pointID, to: target, referenceWall: referenceWall, compared: compared) else {
return nil
}
saved.append(Saved(
id: id, from: pointID, target: target, referenceWall: referenceWall, compared: compared, warnings: warnings
))
return warnings
}

// MARK: - Reading

public func wall(_ id: String) -> Wall? {
walls[id]?.wall
}

public func wallStatus(_ id: String) -> WallStatus? {
guard let entry = walls[id] else { return nil }
return WallStatus(contactWarnings: entry.contacts.map { point($0).flags }, checks: entry.checks)
}

/// A point's own warnings plus, for a point on a wall, that wall's current status.
public func evidence(forPoint id: String) -> PointEvidence? {
guard let point = points[id] else { return nil }
return PointEvidence(own: point.flags, wall: point.wall.flatMap(wallStatus))
}

/// Every quantity that applies from a point to a target. Empty when an id is unknown.
public func values(from pointID: String, to target: Target, referenceWall: String?) -> [MeasuredQuantity: Double] {
guard let from = points[pointID], let end = geometry(of: target) else { return [:] }
return measuredValues(from: from.position, to: end, referenceWall: referenceWall.flatMap(wall))
}

/// The warnings a measurement would be saved with now, or nil when the quantity doesn't
/// apply.
public func warnings(
from pointID: String,
to target: Target,
referenceWall: String?,
compared: MeasuredQuantity
) -> [MeasurementWarning]? {
currentWarnings(from: pointID, to: target, referenceWall: referenceWall, compared: compared)
}

/// A saved measurement's warnings, including any added by later wall checks.
public func savedWarnings(_ id: String) -> [MeasurementWarning]? {
saved.first { $0.id == id }?.warnings
}

// MARK: - Private

private func point(_ id: String) -> Point {
guard let point = points[id] else { preconditionFailure("Point \(id) is not in the ledger") }
return point
}

private func geometry(of target: Target) -> MeasurementTarget? {
switch target {
case .point(let id): points[id].map { .point($0.position) }
case .wall(let id): wall(id).map { .wall($0) }
}
}

private func currentWarnings(
from pointID: String,
to target: Target,
referenceWall referenceID: String?,
compared: MeasuredQuantity
) -> [MeasurementWarning]? {
guard let from = points[pointID], let fromEvidence = evidence(forPoint: pointID), let end = geometry(of: target) else {
return nil
}
let reference = referenceID.flatMap(wall)
guard let value = measuredValues(from: from.position, to: end, referenceWall: reference)[compared] else { return nil }
let toEvidence: PointEvidence?
let targetWall: WallStatus?
switch target {
case .point(let id):
toEvidence = evidence(forPoint: id)
targetWall = nil
case .wall(let id):
toEvidence = nil
targetWall = wallStatus(id)
}
return measurementWarnings(
from: fromEvidence,
to: toEvidence,
targetWall: targetWall,
referenceWall: referenceID.flatMap(wallStatus),
compared: compared,
value: value,
beyondWallContacts: readsWallBeyondContacts(from: from.position, to: end, referenceWall: reference, compared: compared)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,26 @@ public func measuredValues(
]
}
}

/// Whether the compared quantity reads a wall's plane or ground line beyond the wall's two
/// contacts, where both are extrapolated.
///
/// Facing gap and height above ground read the target wall at the point's along-wall position.
/// Along-wall distance projects both points onto the reference wall. Straight, horizontal and
/// vertical distances use no wall, so they are never beyond one.
public func readsWallBeyondContacts(
from point: SIMD3<Double>,
to target: MeasurementTarget,
referenceWall: Wall?,
compared: MeasuredQuantity
) -> Bool {
switch (target, compared) {
case (.wall(let wall), .gapToWall), (.wall(let wall), .heightAboveGround):
return !wall.containsAlong(wall.along(point))
case (.point(let other), .alongWall):
guard let referenceWall else { return false }
return [point, other].contains { !referenceWall.containsAlong(referenceWall.along($0)) }
default:
return false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/// The one storage error the app shows and the manifest write that can clear it on retry.
/// A successful current or closed manifest retry clears only its own displayed error.
/// Unrecoverable errors, such as a lost keyframe, survive all manifest retries.
public struct StorageErrorState<Session: Hashable & Sendable>: Sendable, Equatable {
public private(set) var message: String?
private var closedSession: Session?
private var currentManifest = false

public init() {}

/// An error that no retry clears.
public mutating func report(_ message: String) {
self.message = message
closedSession = nil
currentManifest = false
}

public mutating func currentManifestFailed(_ message: String) {
// A later manifest retry cannot recover an earlier lost keyframe.
guard self.message == nil || currentManifest || closedSession != nil else { return }
report(message)
currentManifest = true
}

public mutating func currentManifestSaved() {
guard currentManifest else { return }
clear()
}

/// A failed write of a closed session's manifest, which a later retry can clear.
public mutating func report(_ message: String, closedSession session: Session) {
// A retriable manifest failure must not hide an unrelated error that its retry cannot fix.
guard self.message == nil || closedSession != nil else { return }
self.message = message
closedSession = session
}

/// The closed session's manifest is on disk now.
public mutating func closedSessionSaved(_ session: Session) {
guard closedSession == session else { return }
message = nil
closedSession = nil
}

public mutating func clear() {
message = nil
closedSession = nil
currentManifest = false
}
}
Loading