Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Remembering Doors

~15 min · swift, arrival, offline, value-types, voice

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A record the phone keeps to itself

A door learned once should not have to be learned again, least of all at a station with no signal. EntranceMemory is the phone's record of the doors of the buildings Dad walks to. Per building it holds a name, a centre, the door Dad confirmed (with the barometer's altitude there), the doors he turned down, and OpenStreetMap's doors as last read, with when. It is a value: the app changes a copy and writes it as JSON beside the recent places, and its doc comment is explicit that it is the phone's own record and never leaves it. Pippa's pins live on her server; Dad's doors live in his pocket.

Four constants shape it:

  • sameVenueMetres = 120: two centres this close are the same building, whatever their names. Pippa's pin may call it "Atocha" and Apple's search "Madrid Puerta de Atocha"; as long as the two centres fall within 120 m, the memory files both under one building.
  • sameDoorMetres = 15: a door this close to one already known is that door.
  • candidatesLifetime of 30 days: after that OpenStreetMap is asked again. Only a fresh answer counts as known.
  • limit = 200 buildings. Every change moves a building to the front, and the list is trimmed from the back, so the ones whose record changed longest ago go first. A read does not count: a walk planned from doors already remembered, while OpenStreetMap's answer is still fresh, leaves the building where it is. So this is least recently changed, not least recently visited.

How a plan is made

ArrivalPlan.make turns a destination and the memory into a door to walk to and the doors to try after it. It collects candidates in rank order: the door Dad confirmed, then the door Pippa's pin or handoff named, then OpenStreetMap's ranked doors (just read, or remembered). It drops any door Dad turned down. Two doors within 15 m are one door, kept at the first one's position, and the first one inherits a note or a level it lacked from the later one. So when Dad confirms a door a couple of metres from Pippa's, his door leads and her note "2층 출발 홀" still rides along. If nothing survives, the plan has no entrance and the walk goes to the building's centre as before, with the spoken last stretch still on.

One subtle line keeps the plan stable. A door Pippa named stays attached to the destination itself, while a door the phone chose belongs only to the plan. When the plan is made again (Dad switches from driving to walking, or OpenStreetMap's answer arrives late) her door is still known and still leads. The kit test aLateMapAnswerKeepsPippasDoor pins that down.

In the app, the timing matters as much as the order. When Dad routes a walk, the plan is made at once from memory and the route is asked to that door. OpenStreetMap is asked in the background while the preview shows, and if its answer changes the door while Dad is still looking at the same walk, the preview is routed again. The sketch below runs the ordering and merging rules on synthetic doors around a building's centre.

Dad's two words

Instruments propose; Dad decides. While the last stretch is on, the approach card shows two buttons, "여기 아니야" and "여기가 입구야", and the same words work aloud in a talk with Pippa. The talk still goes to her; Pathfinder acts on the two phrases at once. ArrivalWords.meaning(of:) removes the spaces and looks for any of a handful of phrasings: eight for "not here" (여기아니야, 여기아냐, 입구아니야 and so on) and five for "this is it" (여기가입구야, 여기맞아, 입구찾았어 and others). It only listens while the stretch is active, so "여기 맞아" in an ordinary conversation moves no door.

"여기 아니야" rejects the door for good: it is never chosen again, and if it was Dad's own confirmed door, it stops being his. If another door is left, the current walk ends and a fresh walk starts to the next door, with "다른 입구로 안내할게". If none is left, the stretch keeps going toward the building's centre, still spoken but blind to level, and Pippa says "다른 입구는 모르겠어. 입구를 찾으면 여기가 입구야라고 말해줘".

"여기가 입구야" stores where Dad stands as this building's door, with source dad, the level, note and label of the door he was walking to, and the barometer's absolute altitude when it is accurate to 6 m. From then on that altitude is the level check Pathfinder trusts first: it compares two barometer readings above sea level directly, with no assumed floor height and no baseline. It is still a measurement, not a survey. Each reading is accepted with an estimated accuracy as loose as 6 m, and the verdict calls anything within 2.5 m the same level, so on a bad day it can still name the wrong floor. It also lifts any earlier "not here" within 15 m of that spot. Pippa says "기억할게. 다음엔 여기로 안내할게", and the walk ends a few seconds later.

Notice the order inside meaning(of:): the refusals are checked first, so a sentence holding both ("여기 맞아? 아니, 입구 아니야") is a refusal. My reading of why that is the right default: a wrong refusal costs one reroute, while a wrong confirmation writes a bad door into the memory at the highest rank there is.

Let the person on the spot overrule the instruments, and remember it. A correction that is not kept is a correction Dad has to make again at the same door. A correction that is kept, ranked first and still revocable turns one bad arrival into every good one after it.

Code

PathfinderKit, Arrival/EntranceMemory.swift: confirm and reject (verbatim excerpt)·swift
  /// "여기가 입구야": this is the way in, next time too. It also lifts any
  /// earlier "not here" about the same door.
  public mutating func confirm(_ door: Entrance, name: String, center: Coordinate) {
    var door = door
    door.source = .dad
    door.venue = center
    update(name: name, center: center) { venue in
      venue.confirmed = door
      venue.rejected.removeAll { Geo.distance($0, door.coordinate) <= Self.sameDoorMetres }
    }
  }

  /// "여기 아니야": never this door again, and forget it if it was his own.
  public mutating func reject(_ door: Coordinate, name: String, center: Coordinate) {
    update(name: name, center: center) { venue in
      if !venue.rejected.contains(where: { Geo.distance($0, door) <= Self.sameDoorMetres }) {
        venue.rejected.append(door)
      }
      if let confirmed = venue.confirmed, Geo.distance(confirmed.coordinate, door) <= Self.sameDoorMetres {
        venue.confirmed = nil
      }
    }
  }
A minimal sketch: ArrivalPlan's order, the 15 m merge and the rejected filter, on synthetic doors in metres from a centre (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of ArrivalPlan.make's order: Dad's confirmed door, then
// the door Pippa named, then OpenStreetMap's, best first. Doors he turned
// down are dropped; two doors within 15 m are one door, and the first keeps
// a note or a level it lacked from the later one.
enum Source: Int, Comparable {
  case osm, pippa, dad
  static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
}

struct Door {
  var east: Double, north: Double  // metres from the building's centre
  var source: Source
  var level: Int? = nil
  var note: String? = nil
}

func metres(_ a: Door, _ b: (Double, Double)) -> Double { hypot(a.east - b.0, a.north - b.1) }
let sameDoor = 15.0

func plan(dad: Door?, pippa: Door?, osm: [Door], rejected: [(Double, Double)]) -> [Door] {
  var doors: [Door] = []
  if let dad { doors.append(dad) }
  if let pippa, pippa.source != .osm { doors.append(pippa) }
  doors += osm
  var unique: [Door] = []
  for door in doors where !rejected.contains(where: { metres(door, $0) <= sameDoor }) {
    if let i = unique.firstIndex(where: { metres($0, (door.east, door.north)) <= sameDoor }) {
      if unique[i].note == nil { unique[i].note = door.note }
      if unique[i].level == nil { unique[i].level = door.level }
      continue
    }
    unique.append(door)
  }
  return unique
}

func show(_ title: String, _ doors: [Door]) {
  print(title)
  if doors.isEmpty { print("  (none: walk to the building's centre, last stretch still spoken)") }
  for door in doors {
    print("  \(door.source) at (\(Int(door.east)), \(Int(door.north))) level \(door.level.map(String.init) ?? "?") \(door.note ?? "")")
  }
}

let pippas = Door(east: 80, north: -60, source: .pippa, level: 2, note: "2층 출발 홀")
let osm = [Door(east: 86, north: -55, source: .osm), Door(east: -40, north: 70, source: .osm)]

show("Pippa's pin and OpenStreetMap:", plan(dad: nil, pippa: pippas, osm: osm, rejected: []))
let dads = Door(east: 82, north: -58, source: .dad)
show("Dad confirmed a door two metres from hers:", plan(dad: dads, pippa: pippas, osm: osm, rejected: []))
show("He turned her door down:", plan(dad: nil, pippa: pippas, osm: osm, rejected: [(80, -60)]))
show("Every door turned down:", plan(dad: nil, pippa: nil, osm: osm, rejected: [(86, -55), (-40, 70)]))
PathfinderKit, Arrival/FinalApproach.swift: ArrivalWords, Dad's two phrases (verbatim excerpt)·swift
public enum ArrivalWords {
  public enum Meaning: Equatable, Sendable {
    /// "여기 아니야": this door is wrong.
    case notHere
    /// "여기가 입구야": this is the way in.
    case thisIsIt
  }

  public static func meaning(of heard: String) -> Meaning? {
    let text = heard.replacingOccurrences(of: " ", with: "")
    let notHere = ["여기아니야", "여기아냐", "여기가아니야", "여기가아냐", "여기아닌데", "입구아니야", "입구가아니야", "이입구아니야"]
    if notHere.contains(where: text.contains) { return .notHere }
    let thisIsIt = ["여기가입구야", "여기입구야", "여기맞아", "입구찾았어", "여기가맞아"]
    if thisIsIt.contains(where: text.contains) { return .thisIsIt }
    return nil
  }

External links

Exercise

Extend the plan sketch with an EntranceMemory of your own: a dictionary from a building's centre to its confirmed door and rejected doors, with the 120 m same-building rule. Write three functions, confirm, reject and plan, and a test that walks this sequence: OpenStreetMap offers doors A and B; Dad rejects A; the plan picks B; Dad confirms a point 4 m from A. What should the next plan be, and does your code agree?
Hint
In Pathfinder, confirming lifts an earlier rejection within 15 m, so the point near A becomes his door and leads. If your plan still filters it out, check the order of operations in confirm: the rejection list has to be cleaned in the same change that records the door.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.